1
0
mirror of https://github.com/svg/svgo.git synced 2025-07-31 07:44:22 +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';
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,
@ -21,7 +24,7 @@ exports.params = {
imageHeight0: true,
pathEmptyD: true,
polylineEmptyPoints: true,
polygonEmptyPoints: true
polygonEmptyPoints: true,
};
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
*/
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 &&
item.hasAttr('visibility', 'hidden') &&
computedStyle.visibility &&
computedStyle.visibility.type === 'static' &&
computedStyle.visibility.value === 'hidden' &&
// keep if any descendant enables visibility
item.querySelector('[visibility=visible]') == null
) return false;
) {
return false;
}
// display="none"
//
@ -64,18 +71,26 @@ exports.fn = function (item, params) {
// and its children shall not be rendered directly"
if (
params.displayNone &&
item.hasAttr('display', 'none')
) return false;
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 &&
item.hasAttr('opacity', '0') &&
computedStyle.opacity &&
computedStyle.opacity.type === 'static' &&
computedStyle.opacity.value === '0' &&
// transparent element inside clipPath still affect clipped elements
item.closestElem('clipPath') == null
) return false;
) {
return false;
}
// Circles with zero radius
//
@ -88,7 +103,9 @@ exports.fn = function (item, params) {
item.isElem('circle') &&
item.isEmpty() &&
item.hasAttr('r', '0')
) return false;
) {
return false;
}
// Ellipse with zero x-axis radius
//
@ -101,7 +118,9 @@ exports.fn = function (item, params) {
item.isElem('ellipse') &&
item.isEmpty() &&
item.hasAttr('rx', '0')
) return false;
) {
return false;
}
// Ellipse with zero y-axis radius
//
@ -114,7 +133,9 @@ exports.fn = function (item, params) {
item.isElem('ellipse') &&
item.isEmpty() &&
item.hasAttr('ry', '0')
) return false;
) {
return false;
}
// Rectangle with zero width
//
@ -127,7 +148,9 @@ exports.fn = function (item, params) {
item.isElem('rect') &&
item.isEmpty() &&
item.hasAttr('width', '0')
) return false;
) {
return false;
}
// Rectangle with zero height
//
@ -141,7 +164,9 @@ exports.fn = function (item, params) {
item.isElem('rect') &&
item.isEmpty() &&
item.hasAttr('height', '0')
) return false;
) {
return false;
}
// Pattern with zero width
//
@ -153,7 +178,9 @@ exports.fn = function (item, params) {
params.patternWidth0 &&
item.isElem('pattern') &&
item.hasAttr('width', '0')
) return false;
) {
return false;
}
// Pattern with zero height
//
@ -165,7 +192,9 @@ exports.fn = function (item, params) {
params.patternHeight0 &&
item.isElem('pattern') &&
item.hasAttr('height', '0')
) return false;
) {
return false;
}
// Image with zero width
//
@ -177,7 +206,9 @@ exports.fn = function (item, params) {
params.imageWidth0 &&
item.isElem('image') &&
item.hasAttr('width', '0')
) return false;
) {
return false;
}
// Image with zero height
//
@ -189,7 +220,9 @@ exports.fn = function (item, params) {
params.imageHeight0 &&
item.isElem('image') &&
item.hasAttr('height', '0')
) return false;
) {
return false;
}
// Path with empty data
//
@ -200,7 +233,9 @@ exports.fn = function (item, params) {
params.pathEmptyD &&
item.isElem('path') &&
(!item.hasAttr('d') || !regValidPath.test(item.attr('d').value))
) return false;
) {
return false;
}
// Polyline with empty points
//
@ -211,7 +246,9 @@ exports.fn = function (item, params) {
params.polylineEmptyPoints &&
item.isElem('polyline') &&
!item.hasAttr('points')
) return false;
) {
return false;
}
// Polygon with empty points
//
@ -222,8 +259,8 @@ exports.fn = function (item, params) {
params.polygonEmptyPoints &&
item.isElem('polygon') &&
!item.hasAttr('points')
) return false;
) {
return false;
}
}
};

View File

@ -1,11 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg">
<style>
.a { display: block; }
</style>
<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>
</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>

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">
<style>
.a { opacity: 0.5; }
</style>
<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>
</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>

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">
<style>
.a { visibility: visible; }
</style>
<rect x="96" y="96" width="96" height="96" fill="lime" />
<g visibility="hidden">
<rect x="96" y="96" width="96" height="96" fill="red" />
@ -7,14 +15,19 @@
<g visibility="hidden">
<rect x="196" y="196" width="96" height="96" fill="lime" visibility="visible" />
</g>
<rect x="96" y="96" width="96" height="96" visibility="hidden" class="a" />
</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="196.5" y="196.5" width="95" height="95" fill="red"/>
<g visibility="hidden">
<rect x="196" y="196" width="96" height="96" fill="lime" visibility="visible"/>
</g>
<rect x="96" y="96" width="96" height="96" visibility="hidden" class="a"/>
</svg>

Before

Width:  |  Height:  |  Size: 740 B

After

Width:  |  Height:  |  Size: 1.1 KiB