1
0
mirror of https://github.com/svg/svgo.git synced 2025-08-06 04:22:39 +03:00

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.
This commit is contained in:
Bogdan Chadkin
2021-03-04 13:13:44 +03:00
committed by GitHub
parent f3a6cf259a
commit be28d65d78
6 changed files with 665 additions and 198 deletions

184
lib/style.js Normal file
View File

@@ -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;

215
lib/style.test.js Normal file
View File

@@ -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(`
<svg>
<rect id="class" class="a" />
<rect id="two-classes" class="b a" />
<rect id="attribute" fill="purple" />
<rect id="inline-style" style="fill: grey;" />
<g fill="yellow">
<rect id="inheritance" />
<g style="fill: blue;">
<g>
<rect id="nested-inheritance" />
</g>
</g>
</g>
<style>
.a { fill: red; }
</style>
<style>
<![CDATA[
.b { fill: green; stroke: black; }
]]>
</style>
</svg>
`);
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(`
<svg>
<style>
g > .a { fill: red; }
.a { fill: green; }
.b { fill: blue; }
</style>
<g fill="yellow">
<rect id="complex-selector" class="a" />
<rect id="attribute-over-inheritance" fill="orange" />
<rect id="style-rule-over-attribute" class="b" fill="grey" />
<rect id="inline-style-over-style-rule" style="fill: purple;" class="b" />
</g>
</svg>
`);
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(`
<svg>
<style>
g > .a { fill: red; }
.b { fill: green !important; }
</style>
<rect id="complex-selector" class="a b" />
<rect id="style-rule-over-inline-style" style="fill: orange;" class="b" />
<rect id="inline-style-over-style-rule" style="fill: purple !important;" class="b" />
</svg>
`);
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(`
<svg>
<style>
@media screen {
.a { fill: red; }
}
.b:hover { fill: green; }
.c { fill: blue; }
.d { fill: purple; }
</style>
<rect id="media-query" class="a d" style="fill: orange;" />
<rect id="hover" class="b" style="fill: yellow;" />
<g class="a">
<rect id="inherited" />
<rect id="inherited-overriden" class="c" />
</g>
<rect id="static" class="c" style="fill: black" />
</svg>
`);
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 <style> media attribute', () => {
const root = svg2js(`
<svg>
<style media="print">
@media screen {
.a { fill: red; }
}
.b { fill: green; }
</style>
<style media="all">
.c { fill: blue; }
</style>
<rect id="media-query" class="a" />
<rect id="kinda-static" class="b" />
<rect id="static" class="c" />
</svg>
`);
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 <style> with invalid type', () => {
const root = svg2js(`
<svg>
<style type="text/css">
.a { fill: red; }
</style>
<style type="">
.b { fill: green; }
</style>
<style type="text/invalid">
.c { fill: blue; }
</style>
<rect id="valid-type" class="a" />
<rect id="empty-type" class="b" />
<rect id="invalid-type" class="c" />
</svg>
`);
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({});
});
});

View File

@@ -1,10 +1,13 @@
'use strict'; 'use strict';
const { computeStyle } = require('../lib/style.js');
exports.type = 'perItem'; exports.type = 'perItem';
exports.active = true; 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 = { exports.params = {
isHidden: true, isHidden: true,
@@ -21,7 +24,7 @@ exports.params = {
imageHeight0: true, imageHeight0: true,
pathEmptyD: true, pathEmptyD: true,
polylineEmptyPoints: true, polylineEmptyPoints: true,
polygonEmptyPoints: true polygonEmptyPoints: true,
}; };
var regValidPath = /M\s*(?:[-+]?(?:\d*\.\d+|\d+(?:\.|(?!\.)))([eE][-+]?\d+)?(?!\d)\s*,?\s*){2}\D*\d/i; var regValidPath = /M\s*(?:[-+]?(?:\d*\.\d+|\d+(?:\.|(?!\.)))([eE][-+]?\d+)?(?!\d)\s*,?\s*){2}\D*\d/i;
@@ -46,16 +49,20 @@ var regValidPath = /M\s*(?:[-+]?(?:\d*\.\d+|\d+(?:\.|(?!\.)))([eE][-+]?\d+)?(?!\
* @author Kir Belevich * @author Kir Belevich
*/ */
exports.fn = function (item, params) { exports.fn = function (item, params) {
if (item.elem) { if (item.elem) {
// Removes hidden elements // Removes hidden elements
// https://www.w3schools.com/cssref/pr_class_visibility.asp // https://www.w3schools.com/cssref/pr_class_visibility.asp
const computedStyle = computeStyle(item);
if ( if (
params.isHidden && params.isHidden &&
item.hasAttr('visibility', 'hidden') && computedStyle.visibility &&
computedStyle.visibility.type === 'static' &&
computedStyle.visibility.value === 'hidden' &&
// keep if any descendant enables visibility // keep if any descendant enables visibility
item.querySelector('[visibility=visible]') == null item.querySelector('[visibility=visible]') == null
) return false; ) {
return false;
}
// display="none" // display="none"
// //
@@ -64,18 +71,26 @@ exports.fn = function (item, params) {
// and its children shall not be rendered directly" // and its children shall not be rendered directly"
if ( if (
params.displayNone && params.displayNone &&
item.hasAttr('display', 'none') computedStyle.display &&
) return false; computedStyle.display.type === 'static' &&
computedStyle.display.value === 'none'
) {
return false;
}
// opacity="0" // opacity="0"
// //
// https://www.w3.org/TR/SVG11/masking.html#ObjectAndGroupOpacityProperties // https://www.w3.org/TR/SVG11/masking.html#ObjectAndGroupOpacityProperties
if ( if (
params.opacity0 && params.opacity0 &&
item.hasAttr('opacity', '0') && computedStyle.opacity &&
computedStyle.opacity.type === 'static' &&
computedStyle.opacity.value === '0' &&
// transparent element inside clipPath still affect clipped elements // transparent element inside clipPath still affect clipped elements
item.closestElem('clipPath') == null item.closestElem('clipPath') == null
) return false; ) {
return false;
}
// Circles with zero radius // Circles with zero radius
// //
@@ -88,7 +103,9 @@ exports.fn = function (item, params) {
item.isElem('circle') && item.isElem('circle') &&
item.isEmpty() && item.isEmpty() &&
item.hasAttr('r', '0') item.hasAttr('r', '0')
) return false; ) {
return false;
}
// Ellipse with zero x-axis radius // Ellipse with zero x-axis radius
// //
@@ -101,7 +118,9 @@ exports.fn = function (item, params) {
item.isElem('ellipse') && item.isElem('ellipse') &&
item.isEmpty() && item.isEmpty() &&
item.hasAttr('rx', '0') item.hasAttr('rx', '0')
) return false; ) {
return false;
}
// Ellipse with zero y-axis radius // Ellipse with zero y-axis radius
// //
@@ -114,7 +133,9 @@ exports.fn = function (item, params) {
item.isElem('ellipse') && item.isElem('ellipse') &&
item.isEmpty() && item.isEmpty() &&
item.hasAttr('ry', '0') item.hasAttr('ry', '0')
) return false; ) {
return false;
}
// Rectangle with zero width // Rectangle with zero width
// //
@@ -127,7 +148,9 @@ exports.fn = function (item, params) {
item.isElem('rect') && item.isElem('rect') &&
item.isEmpty() && item.isEmpty() &&
item.hasAttr('width', '0') item.hasAttr('width', '0')
) return false; ) {
return false;
}
// Rectangle with zero height // Rectangle with zero height
// //
@@ -141,7 +164,9 @@ exports.fn = function (item, params) {
item.isElem('rect') && item.isElem('rect') &&
item.isEmpty() && item.isEmpty() &&
item.hasAttr('height', '0') item.hasAttr('height', '0')
) return false; ) {
return false;
}
// Pattern with zero width // Pattern with zero width
// //
@@ -153,7 +178,9 @@ exports.fn = function (item, params) {
params.patternWidth0 && params.patternWidth0 &&
item.isElem('pattern') && item.isElem('pattern') &&
item.hasAttr('width', '0') item.hasAttr('width', '0')
) return false; ) {
return false;
}
// Pattern with zero height // Pattern with zero height
// //
@@ -165,7 +192,9 @@ exports.fn = function (item, params) {
params.patternHeight0 && params.patternHeight0 &&
item.isElem('pattern') && item.isElem('pattern') &&
item.hasAttr('height', '0') item.hasAttr('height', '0')
) return false; ) {
return false;
}
// Image with zero width // Image with zero width
// //
@@ -177,7 +206,9 @@ exports.fn = function (item, params) {
params.imageWidth0 && params.imageWidth0 &&
item.isElem('image') && item.isElem('image') &&
item.hasAttr('width', '0') item.hasAttr('width', '0')
) return false; ) {
return false;
}
// Image with zero height // Image with zero height
// //
@@ -189,7 +220,9 @@ exports.fn = function (item, params) {
params.imageHeight0 && params.imageHeight0 &&
item.isElem('image') && item.isElem('image') &&
item.hasAttr('height', '0') item.hasAttr('height', '0')
) return false; ) {
return false;
}
// Path with empty data // Path with empty data
// //
@@ -200,7 +233,9 @@ exports.fn = function (item, params) {
params.pathEmptyD && params.pathEmptyD &&
item.isElem('path') && item.isElem('path') &&
(!item.hasAttr('d') || !regValidPath.test(item.attr('d').value)) (!item.hasAttr('d') || !regValidPath.test(item.attr('d').value))
) return false; ) {
return false;
}
// Polyline with empty points // Polyline with empty points
// //
@@ -211,7 +246,9 @@ exports.fn = function (item, params) {
params.polylineEmptyPoints && params.polylineEmptyPoints &&
item.isElem('polyline') && item.isElem('polyline') &&
!item.hasAttr('points') !item.hasAttr('points')
) return false; ) {
return false;
}
// Polygon with empty points // Polygon with empty points
// //
@@ -222,8 +259,8 @@ exports.fn = function (item, params) {
params.polygonEmptyPoints && params.polygonEmptyPoints &&
item.isElem('polygon') && item.isElem('polygon') &&
!item.hasAttr('points') !item.hasAttr('points')
) return false; ) {
return false;
}
} }
}; };

View File

@@ -1,11 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<style>
.a { display: block; }
</style>
<g> <g>
<path display="none" d="..."/> <rect display="none" x="0" y="0" width="20" height="20" />
<rect display="none" class="a" x="0" y="0" width="20" height="20" />
</g> </g>
</svg> </svg>
@@@ @@@
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<g/> <style>
.a { display: block; }
</style>
<g>
<rect display="none" class="a" x="0" y="0" width="20" height="20"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 466 B

View File

@@ -1,11 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<style>
.a { opacity: 0.5; }
</style>
<g> <g>
<path opacity="0" d="..."/> <rect opacity="0" x="0" y="0" width="20" height="20" />
<rect opacity="0" class="a" x="0" y="0" width="20" height="20" />
</g> </g>
</svg> </svg>
@@@ @@@
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<g/> <style>
.a { opacity: 0.5; }
</style>
<g>
<rect opacity="0" class="a" x="0" y="0" width="20" height="20"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 164 B

After

Width:  |  Height:  |  Size: 453 B

View File

@@ -1,4 +1,12 @@
Keep invisible elements which have visibile ones inside
and resolve styles
===
<svg width="480" height="360" xmlns="http://www.w3.org/2000/svg"> <svg width="480" height="360" xmlns="http://www.w3.org/2000/svg">
<style>
.a { visibility: visible; }
</style>
<rect x="96" y="96" width="96" height="96" fill="lime" /> <rect x="96" y="96" width="96" height="96" fill="lime" />
<g visibility="hidden"> <g visibility="hidden">
<rect x="96" y="96" width="96" height="96" fill="red" /> <rect x="96" y="96" width="96" height="96" fill="red" />
@@ -7,14 +15,19 @@
<g visibility="hidden"> <g visibility="hidden">
<rect x="196" y="196" width="96" height="96" fill="lime" visibility="visible" /> <rect x="196" y="196" width="96" height="96" fill="lime" visibility="visible" />
</g> </g>
<rect x="96" y="96" width="96" height="96" visibility="hidden" class="a" />
</svg> </svg>
@@@ @@@
<svg width="480" height="360" xmlns="http://www.w3.org/2000/svg"> <svg width="480" height="360" xmlns="http://www.w3.org/2000/svg">
<style>
.a { visibility: visible; }
</style>
<rect x="96" y="96" width="96" height="96" fill="lime"/> <rect x="96" y="96" width="96" height="96" fill="lime"/>
<rect x="196.5" y="196.5" width="95" height="95" fill="red"/> <rect x="196.5" y="196.5" width="95" height="95" fill="red"/>
<g visibility="hidden"> <g visibility="hidden">
<rect x="196" y="196" width="96" height="96" fill="lime" visibility="visible"/> <rect x="196" y="196" width="96" height="96" fill="lime" visibility="visible"/>
</g> </g>
<rect x="96" y="96" width="96" height="96" visibility="hidden" class="a"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 740 B

After

Width:  |  Height:  |  Size: 1.1 KiB