From be28d65d78fbc5b9cf556d53d86d4e623b064d35 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Thu, 4 Mar 2021 13:13:44 +0300 Subject: [PATCH] Implement style computing (#1399) Ref https://github.com/svg/svgo/issues/777 Currently a lot of optimisations are attributes specific and may be broken because of inline or shared styles. In this diff I'm trying to solve the problem with getComputedStyle analog. `computeStyle` collects attributes, shared css rules, inline styles and inherited styles and checks whether they can be statically optimised or left as deoptimisation. --- lib/style.js | 184 +++++++++++ lib/style.test.js | 215 +++++++++++++ plugins/removeHiddenElems.js | 421 ++++++++++++++------------ test/plugins/removeHiddenElems.01.svg | 13 +- test/plugins/removeHiddenElems.02.svg | 13 +- test/plugins/removeHiddenElems.12.svg | 13 + 6 files changed, 663 insertions(+), 196 deletions(-) create mode 100644 lib/style.js create mode 100644 lib/style.test.js diff --git a/lib/style.js b/lib/style.js new file mode 100644 index 000000000..aedea2659 --- /dev/null +++ b/lib/style.js @@ -0,0 +1,184 @@ +'use strict'; + +const stable = require('stable'); +const csstree = require('css-tree'); +const specificity = require('csso/lib/restructure/prepare/specificity'); +const { selectAll, is } = require('css-select'); +const svgoCssSelectAdapter = require('./svgo/css-select-adapter.js'); +const { compareSpecificity } = require('./css-tools.js'); +const { + attrsGroups, + inheritableAttrs, + presentationNonInheritableGroupAttrs, +} = require('../plugins/_collections.js'); + +const cssSelectOptions = { + xmlMode: true, + adapter: svgoCssSelectAdapter, +}; + +const parseRule = (ruleNode, dynamic) => { + let selectors; + let selectorsSpecificity; + const declarations = []; + csstree.walk(ruleNode, (cssNode) => { + if (cssNode.type === 'SelectorList') { + // compute specificity from original node to consider pseudo classes + selectorsSpecificity = specificity(cssNode); + const newSelectorsNode = csstree.clone(cssNode); + csstree.walk(newSelectorsNode, (pseudoClassNode, item, list) => { + if (pseudoClassNode.type === 'PseudoClassSelector') { + dynamic = true; + list.remove(item); + } + }); + selectors = csstree.generate(newSelectorsNode); + return csstree.walk.skip; + } + if (cssNode.type === 'Declaration') { + declarations.push({ + name: cssNode.property, + value: csstree.generate(cssNode.value), + important: cssNode.important, + }); + return csstree.walk.skip; + } + }); + return { + dynamic, + selectors, + specificity: selectorsSpecificity, + declarations, + }; +}; + +const parseStylesheet = (css, dynamic) => { + const rules = []; + const ast = csstree.parse(css); + csstree.walk(ast, (cssNode) => { + if (cssNode.type === 'Rule') { + rules.push(parseRule(cssNode, dynamic || false)); + return csstree.walk.skip; + } + if (cssNode.type === 'Atrule') { + csstree.walk(cssNode, (ruleNode) => { + if (ruleNode.type === 'Rule') { + rules.push(parseRule(ruleNode, dynamic || true)); + return csstree.walk.skip; + } + }); + return csstree.walk.skip; + } + }); + return rules; +}; + +const computeOwnStyle = (node, stylesheet) => { + const computedStyle = {}; + const importantStyles = new Map(); + + // collect attributes + if (node.attrs) { + for (const { name, value } of Object.values(node.attrs)) { + if (attrsGroups.presentation.includes(name)) { + computedStyle[name] = { type: 'static', inherited: false, value }; + importantStyles.set(name, false); + } + } + } + + // collect matching rules + for (const { selectors, declarations, dynamic } of stylesheet) { + if (is(node, selectors, cssSelectOptions)) { + for (const { name, value, important } of declarations) { + const computed = computedStyle[name]; + if (computed && computed.type === 'dynamic') { + continue; + } + if (dynamic) { + computedStyle[name] = { type: 'dynamic', inherited: false }; + continue; + } + if ( + computed == null || + important === true || + importantStyles.get(name) === false + ) { + computedStyle[name] = { type: 'static', inherited: false, value }; + importantStyles.set(name, important); + } + } + } + } + + // collect inline styles + for (const [name, { value, priority }] of node.style.properties) { + const computed = computedStyle[name]; + const important = priority === 'important'; + if (computed && computed.type === 'dynamic') { + continue; + } + if ( + computed == null || + important === true || + importantStyles.get(name) === false + ) { + computedStyle[name] = { type: 'static', inherited: false, value }; + importantStyles.set(name, important); + } + } + + return computedStyle; +}; + +const computeStyle = (node) => { + // find root + let root = node; + while (root.parentNode) { + root = root.parentNode; + } + // find all styles + const styleNodes = selectAll('style', root, cssSelectOptions); + // parse all styles + const stylesheet = []; + for (const styleNode of styleNodes) { + const dynamic = + styleNode.hasAttr('media') && styleNode.attr('media').value !== 'all'; + if ( + styleNode.hasAttr('type') === false || + styleNode.attr('type').value === '' || + styleNode.attr('type').value === 'text/css' + ) { + const children = styleNode.content || []; + for (const child of children) { + const css = child.text || child.cdata; + stylesheet.push(...parseStylesheet(css, dynamic)); + } + } + } + // sort by selectors specificity + stable.inplace(stylesheet, (a, b) => + compareSpecificity(a.specificity, b.specificity) + ); + + // collect inherited styles + const computedStyles = computeOwnStyle(node, stylesheet); + let parent = node; + while (parent.parentNode && parent.parentNode.elem !== '#document') { + const inheritedStyles = computeOwnStyle(parent.parentNode, stylesheet); + for (const [name, computed] of Object.entries(inheritedStyles)) { + if ( + computedStyles[name] == null && + // ignore not inheritable styles + inheritableAttrs.includes(name) === true && + presentationNonInheritableGroupAttrs.includes(name) === false + ) { + computedStyles[name] = { ...computed, inherited: true }; + } + } + parent = parent.parentNode; + } + + return computedStyles; +}; +exports.computeStyle = computeStyle; diff --git a/lib/style.test.js b/lib/style.test.js new file mode 100644 index 000000000..56857246f --- /dev/null +++ b/lib/style.test.js @@ -0,0 +1,215 @@ +'use strict'; + +const { expect } = require('chai'); +const { computeStyle } = require('./style.js'); +const svg2js = require('./svgo/svg2js.js'); + +describe('computeStyle', () => { + it('collects styles', () => { + const root = svg2js(` + + + + + + + + + + + + + + + + + `); + expect(computeStyle(root.querySelector('#class'))).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'red' }, + }); + expect(computeStyle(root.querySelector('#two-classes'))).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'green' }, + stroke: { type: 'static', inherited: false, value: 'black' }, + }); + expect(computeStyle(root.querySelector('#attribute'))).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'purple' }, + }); + expect(computeStyle(root.querySelector('#inline-style'))).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'grey' }, + }); + expect(computeStyle(root.querySelector('#inheritance'))).to.deep.equal({ + fill: { type: 'static', inherited: true, value: 'yellow' }, + }); + expect( + computeStyle(root.querySelector('#nested-inheritance')) + ).to.deep.equal({ + fill: { type: 'static', inherited: true, value: 'blue' }, + }); + }); + + it('prioritizes different kinds of styles', () => { + const root = svg2js(` + + + + + + + + + + `); + expect(computeStyle(root.querySelector('#complex-selector'))).to.deep.equal( + { + fill: { type: 'static', inherited: false, value: 'red' }, + } + ); + expect( + computeStyle(root.querySelector('#attribute-over-inheritance')) + ).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'orange' }, + }); + expect( + computeStyle(root.querySelector('#style-rule-over-attribute')) + ).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'blue' }, + }); + expect( + computeStyle(root.querySelector('#inline-style-over-style-rule')) + ).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'purple' }, + }); + }); + + it('prioritizes important styles', () => { + const root = svg2js(` + + + + + + + `); + expect(computeStyle(root.querySelector('#complex-selector'))).to.deep.equal( + { + fill: { type: 'static', inherited: false, value: 'green' }, + } + ); + expect( + computeStyle(root.querySelector('#style-rule-over-inline-style')) + ).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'green' }, + }); + expect( + computeStyle(root.querySelector('#inline-style-over-style-rule')) + ).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'purple' }, + }); + }); + + it('treats at-rules and pseudo-classes as dynamic styles', () => { + const root = svg2js(` + + + + + + + + + + + `); + expect(computeStyle(root.querySelector('#media-query'))).to.deep.equal({ + fill: { type: 'dynamic', inherited: false }, + }); + expect(computeStyle(root.querySelector('#hover'))).to.deep.equal({ + fill: { type: 'dynamic', inherited: false }, + }); + expect(computeStyle(root.querySelector('#inherited'))).to.deep.equal({ + fill: { type: 'dynamic', inherited: true }, + }); + expect( + computeStyle(root.querySelector('#inherited-overriden')) + ).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'blue' }, + }); + expect(computeStyle(root.querySelector('#static'))).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'black' }, + }); + }); + + it('considers + + + + + + `); + expect(computeStyle(root.querySelector('#media-query'))).to.deep.equal({ + fill: { type: 'dynamic', inherited: false }, + }); + expect(computeStyle(root.querySelector('#kinda-static'))).to.deep.equal({ + fill: { type: 'dynamic', inherited: false }, + }); + expect(computeStyle(root.querySelector('#static'))).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'blue' }, + }); + }); + + it('ignores + + + + + + + `); + expect(computeStyle(root.querySelector('#valid-type'))).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'red' }, + }); + expect(computeStyle(root.querySelector('#empty-type'))).to.deep.equal({ + fill: { type: 'static', inherited: false, value: 'green' }, + }); + expect(computeStyle(root.querySelector('#invalid-type'))).to.deep.equal({}); + }); +}); diff --git a/plugins/removeHiddenElems.js b/plugins/removeHiddenElems.js index cf7fdd528..87aa5fc62 100644 --- a/plugins/removeHiddenElems.js +++ b/plugins/removeHiddenElems.js @@ -1,27 +1,30 @@ 'use strict'; +const { computeStyle } = require('../lib/style.js'); + exports.type = 'perItem'; exports.active = true; -exports.description = 'removes hidden elements (zero sized, with absent attributes)'; +exports.description = + 'removes hidden elements (zero sized, with absent attributes)'; exports.params = { - isHidden: true, - displayNone: true, - opacity0: true, - circleR0: true, - ellipseRX0: true, - ellipseRY0: true, - rectWidth0: true, - rectHeight0: true, - patternWidth0: true, - patternHeight0: true, - imageWidth0: true, - imageHeight0: true, - pathEmptyD: true, - polylineEmptyPoints: true, - polygonEmptyPoints: true + isHidden: true, + displayNone: true, + opacity0: true, + circleR0: true, + ellipseRX0: true, + ellipseRY0: true, + rectWidth0: true, + rectHeight0: true, + patternWidth0: true, + patternHeight0: true, + imageWidth0: true, + imageHeight0: true, + pathEmptyD: true, + polylineEmptyPoints: true, + polygonEmptyPoints: true, }; var regValidPath = /M\s*(?:[-+]?(?:\d*\.\d+|\d+(?:\.|(?!\.)))([eE][-+]?\d+)?(?!\d)\s*,?\s*){2}\D*\d/i; @@ -46,184 +49,218 @@ var regValidPath = /M\s*(?:[-+]?(?:\d*\.\d+|\d+(?:\.|(?!\.)))([eE][-+]?\d+)?(?!\ * @author Kir Belevich */ exports.fn = function (item, params) { + if (item.elem) { + // Removes hidden elements + // https://www.w3schools.com/cssref/pr_class_visibility.asp + const computedStyle = computeStyle(item); + if ( + params.isHidden && + computedStyle.visibility && + computedStyle.visibility.type === 'static' && + computedStyle.visibility.value === 'hidden' && + // keep if any descendant enables visibility + item.querySelector('[visibility=visible]') == null + ) { + return false; + } + + // display="none" + // + // https://www.w3.org/TR/SVG11/painting.html#DisplayProperty + // "A value of display: none indicates that the given element + // and its children shall not be rendered directly" + if ( + params.displayNone && + computedStyle.display && + computedStyle.display.type === 'static' && + computedStyle.display.value === 'none' + ) { + return false; + } + + // opacity="0" + // + // https://www.w3.org/TR/SVG11/masking.html#ObjectAndGroupOpacityProperties + if ( + params.opacity0 && + computedStyle.opacity && + computedStyle.opacity.type === 'static' && + computedStyle.opacity.value === '0' && + // transparent element inside clipPath still affect clipped elements + item.closestElem('clipPath') == null + ) { + return false; + } + + // Circles with zero radius + // + // https://www.w3.org/TR/SVG11/shapes.html#CircleElementRAttribute + // "A value of zero disables rendering of the element" + // + // + if ( + params.circleR0 && + item.isElem('circle') && + item.isEmpty() && + item.hasAttr('r', '0') + ) { + return false; + } + + // Ellipse with zero x-axis radius + // + // https://www.w3.org/TR/SVG11/shapes.html#EllipseElementRXAttribute + // "A value of zero disables rendering of the element" + // + // + if ( + params.ellipseRX0 && + item.isElem('ellipse') && + item.isEmpty() && + item.hasAttr('rx', '0') + ) { + return false; + } + + // Ellipse with zero y-axis radius + // + // https://www.w3.org/TR/SVG11/shapes.html#EllipseElementRYAttribute + // "A value of zero disables rendering of the element" + // + // + if ( + params.ellipseRY0 && + item.isElem('ellipse') && + item.isEmpty() && + item.hasAttr('ry', '0') + ) { + return false; + } + + // Rectangle with zero width + // + // https://www.w3.org/TR/SVG11/shapes.html#RectElementWidthAttribute + // "A value of zero disables rendering of the element" + // + // + if ( + params.rectWidth0 && + item.isElem('rect') && + item.isEmpty() && + item.hasAttr('width', '0') + ) { + return false; + } + + // Rectangle with zero height + // + // https://www.w3.org/TR/SVG11/shapes.html#RectElementHeightAttribute + // "A value of zero disables rendering of the element" + // + // + if ( + params.rectHeight0 && + params.rectWidth0 && + item.isElem('rect') && + item.isEmpty() && + item.hasAttr('height', '0') + ) { + return false; + } - if (item.elem) { - // Removes hidden elements - // https://www.w3schools.com/cssref/pr_class_visibility.asp - if ( - params.isHidden && - item.hasAttr('visibility', 'hidden') && - // keep if any descendant enables visibility - item.querySelector('[visibility=visible]') == null - ) return false; - - // display="none" - // - // https://www.w3.org/TR/SVG11/painting.html#DisplayProperty - // "A value of display: none indicates that the given element - // and its children shall not be rendered directly" - if ( - params.displayNone && - item.hasAttr('display', 'none') - ) return false; - - // opacity="0" - // - // https://www.w3.org/TR/SVG11/masking.html#ObjectAndGroupOpacityProperties - if ( - params.opacity0 && - item.hasAttr('opacity', '0') && - // transparent element inside clipPath still affect clipped elements - item.closestElem('clipPath') == null - ) return false; - - // Circles with zero radius - // - // https://www.w3.org/TR/SVG11/shapes.html#CircleElementRAttribute - // "A value of zero disables rendering of the element" - // - // - if ( - params.circleR0 && - item.isElem('circle') && - item.isEmpty() && - item.hasAttr('r', '0') - ) return false; - - // Ellipse with zero x-axis radius - // - // https://www.w3.org/TR/SVG11/shapes.html#EllipseElementRXAttribute - // "A value of zero disables rendering of the element" - // - // - if ( - params.ellipseRX0 && - item.isElem('ellipse') && - item.isEmpty() && - item.hasAttr('rx', '0') - ) return false; - - // Ellipse with zero y-axis radius - // - // https://www.w3.org/TR/SVG11/shapes.html#EllipseElementRYAttribute - // "A value of zero disables rendering of the element" - // - // - if ( - params.ellipseRY0 && - item.isElem('ellipse') && - item.isEmpty() && - item.hasAttr('ry', '0') - ) return false; - - // Rectangle with zero width - // - // https://www.w3.org/TR/SVG11/shapes.html#RectElementWidthAttribute - // "A value of zero disables rendering of the element" - // - // - if ( - params.rectWidth0 && - item.isElem('rect') && - item.isEmpty() && - item.hasAttr('width', '0') - ) return false; - - // Rectangle with zero height - // - // https://www.w3.org/TR/SVG11/shapes.html#RectElementHeightAttribute - // "A value of zero disables rendering of the element" - // - // - if ( - params.rectHeight0 && - params.rectWidth0 && - item.isElem('rect') && - item.isEmpty() && - item.hasAttr('height', '0') - ) return false; - - // Pattern with zero width - // - // https://www.w3.org/TR/SVG11/pservers.html#PatternElementWidthAttribute - // "A value of zero disables rendering of the element (i.e., no paint is applied)" - // - // - if ( - params.patternWidth0 && - item.isElem('pattern') && - item.hasAttr('width', '0') - ) return false; - - // Pattern with zero height - // - // https://www.w3.org/TR/SVG11/pservers.html#PatternElementHeightAttribute - // "A value of zero disables rendering of the element (i.e., no paint is applied)" - // - // - if ( - params.patternHeight0 && - item.isElem('pattern') && - item.hasAttr('height', '0') - ) return false; - - // Image with zero width - // - // https://www.w3.org/TR/SVG11/struct.html#ImageElementWidthAttribute - // "A value of zero disables rendering of the element" - // - // - if ( - params.imageWidth0 && - item.isElem('image') && - item.hasAttr('width', '0') - ) return false; - - // Image with zero height - // - // https://www.w3.org/TR/SVG11/struct.html#ImageElementHeightAttribute - // "A value of zero disables rendering of the element" - // - // - if ( - params.imageHeight0 && - item.isElem('image') && - item.hasAttr('height', '0') - ) return false; - - // Path with empty data - // - // https://www.w3.org/TR/SVG11/paths.html#DAttribute - // - // - if ( - params.pathEmptyD && - item.isElem('path') && - (!item.hasAttr('d') || !regValidPath.test(item.attr('d').value)) - ) return false; - - // Polyline with empty points - // - // https://www.w3.org/TR/SVG11/shapes.html#PolylineElementPointsAttribute - // - // - if ( - params.polylineEmptyPoints && - item.isElem('polyline') && - !item.hasAttr('points') - ) return false; - - // Polygon with empty points - // - // https://www.w3.org/TR/SVG11/shapes.html#PolygonElementPointsAttribute - // - // - if ( - params.polygonEmptyPoints && - item.isElem('polygon') && - !item.hasAttr('points') - ) return false; + // Pattern with zero width + // + // https://www.w3.org/TR/SVG11/pservers.html#PatternElementWidthAttribute + // "A value of zero disables rendering of the element (i.e., no paint is applied)" + // + // + if ( + params.patternWidth0 && + item.isElem('pattern') && + item.hasAttr('width', '0') + ) { + return false; + } + // Pattern with zero height + // + // https://www.w3.org/TR/SVG11/pservers.html#PatternElementHeightAttribute + // "A value of zero disables rendering of the element (i.e., no paint is applied)" + // + // + if ( + params.patternHeight0 && + item.isElem('pattern') && + item.hasAttr('height', '0') + ) { + return false; } + // Image with zero width + // + // https://www.w3.org/TR/SVG11/struct.html#ImageElementWidthAttribute + // "A value of zero disables rendering of the element" + // + // + if ( + params.imageWidth0 && + item.isElem('image') && + item.hasAttr('width', '0') + ) { + return false; + } + + // Image with zero height + // + // https://www.w3.org/TR/SVG11/struct.html#ImageElementHeightAttribute + // "A value of zero disables rendering of the element" + // + // + if ( + params.imageHeight0 && + item.isElem('image') && + item.hasAttr('height', '0') + ) { + return false; + } + + // Path with empty data + // + // https://www.w3.org/TR/SVG11/paths.html#DAttribute + // + // + if ( + params.pathEmptyD && + item.isElem('path') && + (!item.hasAttr('d') || !regValidPath.test(item.attr('d').value)) + ) { + return false; + } + + // Polyline with empty points + // + // https://www.w3.org/TR/SVG11/shapes.html#PolylineElementPointsAttribute + // + // + if ( + params.polylineEmptyPoints && + item.isElem('polyline') && + !item.hasAttr('points') + ) { + return false; + } + + // Polygon with empty points + // + // https://www.w3.org/TR/SVG11/shapes.html#PolygonElementPointsAttribute + // + // + if ( + params.polygonEmptyPoints && + item.isElem('polygon') && + !item.hasAttr('points') + ) { + return false; + } + } }; diff --git a/test/plugins/removeHiddenElems.01.svg b/test/plugins/removeHiddenElems.01.svg index 49b9eb705..fdf97078f 100644 --- a/test/plugins/removeHiddenElems.01.svg +++ b/test/plugins/removeHiddenElems.01.svg @@ -1,11 +1,20 @@ + - + + @@@ - + + + + diff --git a/test/plugins/removeHiddenElems.02.svg b/test/plugins/removeHiddenElems.02.svg index 0c4c78fdc..63cb005f8 100644 --- a/test/plugins/removeHiddenElems.02.svg +++ b/test/plugins/removeHiddenElems.02.svg @@ -1,11 +1,20 @@ + - + + @@@ - + + + + diff --git a/test/plugins/removeHiddenElems.12.svg b/test/plugins/removeHiddenElems.12.svg index dee42820f..b092c77e2 100644 --- a/test/plugins/removeHiddenElems.12.svg +++ b/test/plugins/removeHiddenElems.12.svg @@ -1,4 +1,12 @@ +Keep invisible elements which have visibile ones inside +and resolve styles + +=== + + @@ -7,14 +15,19 @@ + @@@ + +