Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove default styles #71

Merged
merged 2 commits into from
May 12, 2022
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
142 changes: 105 additions & 37 deletions src/dom-to-image-more.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
cacheBust: false,
// Use (existing) authentication credentials for external URIs (CORS requests)
useCredentials: false,
// Default resolve timeout
// Default resolve timeout
httpTimeout: 30000
};

Expand Down Expand Up @@ -57,7 +57,7 @@
* @param {Object} options.style - an object whose properties to be copied to node's style before rendering.
* @param {Number} options.quality - a Number between 0 and 1 indicating image quality (applicable to JPEG only),
defaults to 1.0.
* @param {Booolean} options.raster - Used internally to track whether the output is a raster image not requiring CSS reduction.
* @param {Boolean} options.raster - Used internally to track whether the output is a raster image not requiring CSS reduction.
* @param {Number} options.scale - a Number multiplier to scale up the canvas before rendering to reduce fuzzy images, defaults to 1.0.
* @param {String} options.imagePlaceholder - dataURL to use as a placeholder for failed images, default behaviour is to fail fast on images we can't fetch
* @param {Boolean} options.cacheBust - set to true to cache bust by appending the time to the request url
Expand Down Expand Up @@ -168,6 +168,8 @@
* @return {Promise} - A promise that is fulfilled with a canvas object
* */
function toCanvas(node, options) {
options = options || {};
options.raster = true;
return draw(node, options || {});
}

Expand Down Expand Up @@ -207,6 +209,7 @@
ctx.scale(scale, scale);
ctx.drawImage(image, 0, 0);
}
removeSandbox();
return canvas;
});

Expand All @@ -225,7 +228,7 @@
}
}

function cloneNode(node, filter, root, vector) {
function cloneNode(node, filter, root, vector, parentComputedStyles = null) {
if (!root && filter && !filter(node)) return Promise.resolve();

return Promise.resolve(node)
Expand Down Expand Up @@ -258,11 +261,12 @@
});

function cloneChildrenInOrder(parent, childs) {
var computedStyles = getComputedStyle(original);
var done = Promise.resolve();
childs.forEach(function(child) {
done = done
.then(function() {
return cloneNode(child, filter, false, vector);
return cloneNode(child, filter, false, vector, computedStyles);
})
.then(function(childClone) {
if (childClone) parent.appendChild(childClone);
Expand All @@ -285,11 +289,7 @@
});

function cloneStyle() {
if (vector) {
copyStyle(getUserComputedStyle(original, root), clone.style);
} else {
copyStyle(getComputedStyle(original), clone.style);
}
copyStyle(original, clone);

function copyFont(source, target) {
target.font = source.font;
Expand All @@ -308,27 +308,25 @@
target.fontWeight = source.fontWeight;
}

function copyStyle(source, target) {
if (source.cssText) {
target.cssText = source.cssText;
copyFont(source, target); // here we re-assign the font props.
} else copyProperties(source, target);

function copyProperties(from, to) {
util.asArray(from).forEach(function(name) {
to.setProperty(
name,
from.getPropertyValue(name),
from.getPropertyPriority(name)
);
});
function copyStyle(sourceElement, targetElement) {
var sourceComputedStyles = getComputedStyle(sourceElement);
if (sourceComputedStyles.cssText) {
targetElement.style.cssText = sourceComputedStyles.cssText;
copyFont(sourceComputedStyles, targetElement.style); // here we re-assign the font props.
} else {
if (vector) {
copyUserComputedStyle(sourceElement, sourceComputedStyles, targetElement, root);
} else {
copyUserComputedStyleFast(sourceComputedStyles, parentComputedStyles, targetElement);
}

// Remove positioning of root elements, which stops them from being captured correctly
if (root) {
['inset-block', 'inset-block-start', 'inset-block-end'].forEach((prop) => target.removeProperty(prop));
['inset-block', 'inset-block-start', 'inset-block-end']
.forEach((prop) => targetElement.style.removeProperty(prop));
['left', 'right', 'top', 'bottom'].forEach((prop) => {
if (target.getPropertyValue(prop)) {
target.setProperty(prop, '0px');
if (targetElement.style.getPropertyValue(prop)) {
targetElement.style.setProperty(prop, '0px');
}
});
}
Expand Down Expand Up @@ -870,27 +868,97 @@
}
}

function getUserComputedStyle(element, root) {
var clonedStyle = document.createElement(element.tagName).style;
var computedStyles = getComputedStyle(element);
var inlineStyles = element.style;

for (var style of computedStyles) {
var value = computedStyles.getPropertyValue(style);
// `copyUserComputedStyle` and `copyUserComputedStyleFast` copy element styles, omitting defaults to reduce memory
// consumption. The former is slow and omits all defaults, while the latter is faster and omits most defaults. Out
// of ~340 CSS rules, usually <=10 are set, so it makes sense to only copy what we need. By omitting defaults, the
// data URI is <=10% of the original length, which means we can capture pages 10 times as complex before hitting
// the Firefox 97+ data URI max length, which is 32 MB. In addition, generated SVGs are much more performant.
// See https://stackoverflow.com/questions/42025329/how-to-get-the-applied-style-from-an-element.
function copyUserComputedStyle(sourceElement, sourceComputedStyles, targetElement, root) {
var targetStyle = targetElement.style;
var inlineStyles = sourceElement.style;

for (var style of sourceComputedStyles) {
var value = sourceComputedStyles.getPropertyValue(style);
var inlineValue = inlineStyles.getPropertyValue(style);

inlineStyles.setProperty(style, root ? 'initial' : 'unset');
var initialValue = computedStyles.getPropertyValue(style);
var initialValue = sourceComputedStyles.getPropertyValue(style);

if (initialValue !== value) {
clonedStyle.setProperty(style, value);
targetStyle.setProperty(style, value);
} else {
clonedStyle.removeProperty(style);
targetStyle.removeProperty(style);
}

inlineStyles.setProperty(style, inlineValue);
}
}

return clonedStyle;
function copyUserComputedStyleFast(sourceComputedStyles, parentComputedStyles, targetElement) {
var defaultStyle = getDefaultStyle(targetElement.tagName);
var targetStyle = targetElement.style;

util.asArray(sourceComputedStyles).forEach(function(name) {
var sourceValue = sourceComputedStyles.getPropertyValue(name);
// If the style does not match the default, or it does not match the parent's, set it. We don't know which
// styles are inherited from the parent and which aren't, so we have to always check both.
if (sourceValue !== defaultStyle[name] ||
(parentComputedStyles && sourceValue !== parentComputedStyles.getPropertyValue(name))) {
targetStyle.setProperty(name, sourceValue, sourceComputedStyles.getPropertyPriority(name));
}
});
}

var removeDefaultStylesTimeoutId = null;
var sandbox = null;
var tagNameDefaultStyles = {};

function getDefaultStyle(tagName) {
if (tagNameDefaultStyles[tagName]) {
return tagNameDefaultStyles[tagName];
}
if (!sandbox) {
// Create a hidden sandbox <iframe> element within we can create default HTML elements and query their
// computed styles. Elements must be rendered in order to query their computed styles. The <iframe> won't
// render at all with `display: none`, so we have to use `visibility: hidden` with `position: fixed`.
sandbox = document.createElement('iframe');
sandbox.style.visibility = 'hidden';
sandbox.style.position = 'fixed';
document.body.appendChild(sandbox);
// Ensure that the iframe is rendered in standard mode
sandbox.contentWindow.document.write('<!DOCTYPE html><meta charset="UTF-8"><title>sandbox</title><body>');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it's worthwhile reaching up to ensure the meta charset assumption is valid

Copy link
Author

@joswhite joswhite May 12, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not? Let's change this to

sandbox.contentWindow.document.write('<!DOCTYPE html><meta charset="' +
(document.characterSet || 'UTF-8') + '"><title>sandbox</title><body>');

According to tests with the latest Chrome, Edge, and Firefox, document.characterSet is always defined, so we could use document.characterSet in place of (document.characterSet || 'UTF-8'), but I haven't been able to test earlier versions of these browsers so keeping the default as UTF-8 seems to be a good idea.

Do we need a PR for this or would you like to change it?

}
var defaultElement = document.createElement(tagName);
sandbox.contentWindow.document.body.appendChild(defaultElement);
// Ensure that there is some content, so that properties like margin are applied.
defaultElement.textContent = '.';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe set this to a zero-width non-breaking space instead?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in defaultElement.textContent = ' ';?

For block elements like div, that results in the defaultElement having a height of 0px but still having a width. Since we override height to auto, it only changes the block-size, persective-origin, and transform-origin being returned on the default style, to a different number. For inline elements like span, that results in defaultElement having a width of 0px but still having a height. In this case it only changes persective-origin and transform-origin being returned on the default style.

Not sure whether this is desirable or not. Seems to be equivalent. The element being cloned would need to have the CSS property set, and the value would have to match the defaultElement computed value, in order for it to cause problems (i.e. omitting the style from the cloned HTML). Very slim chance this would happen.

I'm inclined to stick with some text content (rather than spaces), due to the StackOverflow answer from which I derived this section of code: https://stackoverflow.com/questions/42025329/how-to-get-the-applied-style-from-an-element/42068963#42068963. In his code snippet the author includes

// ensure that there is some content, so that e.g. margin is applied
elVanilla.textContent = 'foo';

I'm fine with either though. Feel free to change it to a single space if you'd like.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A small PITA from using .... it's the start of a CSS class in a <style> tag, or a invalid dot operator in a embedded <script> tag. I plan to change it to ; as a minimum in #102.

var defaultComputedStyle = sandbox.contentWindow.getComputedStyle(defaultElement);
var defaultStyle = {};
// Copy styles to an object, making sure that 'width' and 'height' are given the default value of 'auto', since
// their initial value is always 'auto' despite that the default computed value is sometimes an absolute length.
util.asArray(defaultComputedStyle).forEach(function(name) {
defaultStyle[name] =
(name === 'width' || name === 'height') ? 'auto' : defaultComputedStyle.getPropertyValue(name);
});
sandbox.contentWindow.document.body.removeChild(defaultElement);
tagNameDefaultStyles[tagName] = defaultStyle;
return defaultStyle;
}

function removeSandbox() {
if (!sandbox) {
return;
}
document.body.removeChild(sandbox);
sandbox = null;
if (removeDefaultStylesTimeoutId) {
clearTimeout(removeDefaultStylesTimeoutId);
}
removeDefaultStylesTimeoutId = setTimeout(() => {
removeDefaultStylesTimeoutId = null;
tagNameDefaultStyles = {};
}, 20*1000);
}

})(this);