From 9228a8706302e0c4ea79983df2696f0ddffb9894 Mon Sep 17 00:00:00 2001 From: zm-cttae <114668551+zm-cttae@users.noreply.github.com> Date: Tue, 25 Apr 2023 11:25:16 +0100 Subject: [PATCH 1/5] Add SVG output compression (closes #92) This algorithm has $O(log(N)$ or $O(c*N)$ growth - #135 Refs: #36, #91, #92 --- src/dom-to-image-more.js | 272 ++++++++++++++++++++++++++++----------- 1 file changed, 195 insertions(+), 77 deletions(-) diff --git a/src/dom-to-image-more.js b/src/dom-to-image-more.js index 05cecf56..772e721a 100644 --- a/src/dom-to-image-more.js +++ b/src/dom-to-image-more.js @@ -143,9 +143,14 @@ onCloneResult = options.onclone(clone); } - return Promise.resolve(onCloneResult).then(function () { - return clone; - }); + if (options.compress) { + compressSvg(clone); + } + + return Promise.resolve(onCloneResult) + .then(function () { + return clone; + }); } function makeSvgDataUri(node) { @@ -558,6 +563,115 @@ return node; }); } + + function compressSvg(clone) { + const sandboxWindow = ensureSandboxWindow(); + sandboxWindow.document.body.appendChild(clone); + + // Generate ascending DOM tree for reverse level order traversal. + // CSS inheritance is computed downward (preorder traversal) and is additive-cumulative. + // The filter op is subtractive and goes upward (to only splice out inheritable style declarations). + const walker = document.createTreeWalker(clone, NodeFilter.SHOW_ELEMENT); + const tree = [walker.currentNode]; + let node; + while ((node = walker.nextNode())) tree.push(node); + + function getNodeDepth(node, root, depth) { + const parent = node.parentElement; + return parent === clone.parentElement ? depth : getNodeDepth(parent, root, ++depth); + } + + const depths = tree.map((element) => getNodeDepth(element, clone, 1)); + + const pyramid = []; + let depth = Math.max.apply(Math, depths); + while (depth) { + for (let index = 0; index < tree.length; index++) { + if (depths[index] === depth) pyramid.push(tree[index]); + } + depth--; + } + + let delta; + while (delta !== 0) { + delta = 0; + pyramid.forEach(filterWinningInlineStyles); + } + + sandboxWindow.document.body.removeChild(clone); + + // Exploratory filter to reduce an inline style to winning declarations (<2ms / element). + // Destructively remove declarations and check if there is a computed value change. If so, restore. + function filterWinningInlineStyles(element) { + if (!element.attributes.style) return; + + const targetStyle = element.style; + const computedStyles = getComputedStyle(element); + delta += targetStyle.cssText.length; + + // Hack to disable dynamic changes in CSS computed values. + // Prevents false positives in the declaration filter. + const animations = { 'animation-duration': '', 'transition-duration': '' }; + for (const name in animations) { + animations[name] = targetStyle.getPropertyValue(name); + if (animations[name]) targetStyle.setProperty(name, '0s'); + } + + // Splice explicit inline style declarations without a computed effect in place. + // By prioritising standard CSS properties & lots of hyphens, we reduce attack time & perf load. + tokenizeCssTextDeclarations(targetStyle.cssText) + .map(getCssTextProperty) + .sort(compareHyphenCount) + .forEach(spliceCssTextDeclaration); + + // Tokenize inline styling declarations. + function tokenizeCssTextDeclarations(cssText) { + return cssText.replace(/;$/, '').split(/;\s*(?=-*\w+(?:-\w+)*:\s*(?:[^"']*["'][^"']*["'])*[^"']*$)/g); + } + + // Get property name from CSS declaration. + function getCssTextProperty(declaration) { + return declaration.slice(0, declaration.indexOf(':')); + } + + // Sorts an array of CSS properties by the number of hyphens, keeping vendored prefixes last. + // Optimize for compression gains and early hits by sending shorthand, vendored and custom properties last. + function compareHyphenCount(a, b) { + const isCustom = (name) => /^--\b/.test(name); + const isVendored = (name) => /^-\b/.test(name); + + return ( + (isCustom(a) & !isCustom(b)) * 0b1000000 | + (isVendored(a) & !isVendored(b)) * 0b0100000 | + Math.max(a.split('-').length - b.split('-').length, 0b0011111) + ); + } + + // Filters style declarations in place to keep the filter deterministic. + // The styles dumped by `copyUserComputedStyleFast` are position-dependent. + function spliceCssTextDeclaration(name) { + if (name === 'width' || name === 'height') return; // cross-browser portability + if (name === 'animation-duration' || name === 'transition-duration') return; // dynamic properties + + const value = targetStyle.getPropertyValue(name); + const declarations = tokenizeCssTextDeclarations(targetStyle.cssText); + let index = declarations.findIndex(d => name === getCssTextProperty(d)); + if (index === -1) return; + + targetStyle.cssText = [].concat(declarations.slice(0, index), declarations.slice(index + 1)).join('; ') + ';'; + if (value === computedStyles.getPropertyValue(name)) return; + targetStyle.cssText = declarations.join('; ') + ';'; + } + + // Restore dynamic CSS properties. + for (const name in animations) if (animations[name].length) targetStyle.setProperty(name, animations[name]); + + delta -= targetStyle.cssText.length; + + if (element.getAttribute('style') === '') element.removeAttribute('style'); + } + } + function newUtil() { let uid_index = 0; @@ -1262,80 +1376,6 @@ return tagHierarchy.join('>'); // it's like CSS } - function ensureSandboxWindow() { - if (sandbox) { - return sandbox.contentWindow; - } - - // figure out how this document is defined (doctype and charset) - const charsetToUse = document.characterSet || 'UTF-8'; - const docType = document.doctype; - const docTypeDeclaration = docType - ? `' - : ''; - - // Create a hidden sandbox