dom-to-svg
Version:
Take SVG screenshots of DOM elements
417 lines • 24.3 kB
JavaScript
import cssValueParser from 'postcss-value-parser';
import { getAccessibilityAttributes } from './accessibility.js';
import { copyCssStyles, isVisible, isTransparent, hasUniformBorder, parseCSSLength, unescapeStringValue, getBorderRadiiForSide, calculateOverlappingCurvesFactor, } from './css.js';
import { svgNamespace, isHTMLAnchorElement, isHTMLImageElement, isHTMLInputElement, isHTMLElement, isSVGSVGElement, } from './dom.js';
import { convertLinearGradient } from './gradients.js';
import { createStackingLayers, establishesStackingContext, determineStackingLayer, sortStackingLayerChildren, cleanupStackingLayerChildren, } from './stacking.js';
import { handleSvgNode } from './svg.js';
import { copyTextStyles } from './text.js';
import { walkNode } from './traversal.js';
import { doRectanglesIntersect, isTaggedUnionMember } from './util.js';
export function handleElement(element, context) {
var _a, _b, _c, _d, _e, _f, _g;
const cleanupFunctions = [];
try {
const window = element.ownerDocument.defaultView;
if (!window) {
throw new Error("Element's ownerDocument has no defaultView");
}
const bounds = element.getBoundingClientRect(); // Includes borders
const rectanglesIntersect = doRectanglesIntersect(bounds, context.options.captureArea);
const styles = window.getComputedStyle(element);
const parentStyles = element.parentElement && window.getComputedStyle(element.parentElement);
const svgContainer = isHTMLAnchorElement(element) && context.options.keepLinks
? createSvgAnchor(element, context)
: context.svgDocument.createElementNS(svgNamespace, 'g');
// Add IDs, classes, debug info
svgContainer.dataset.tag = element.tagName.toLowerCase();
const id = element.id || context.getUniqueId(element.classList[0] || element.tagName.toLowerCase());
svgContainer.id = id;
const className = element.getAttribute('class');
if (className) {
svgContainer.setAttribute('class', className);
}
// Title
if (isHTMLElement(element) && element.title) {
const svgTitle = context.svgDocument.createElementNS(svgNamespace, 'title');
svgTitle.textContent = element.title;
svgContainer.prepend(svgTitle);
}
// Which parent should the container itself be appended to?
const stackingLayerName = determineStackingLayer(styles, parentStyles);
const stackingLayer = stackingLayerName
? context.stackingLayers[stackingLayerName]
: context.parentStackingLayer;
if (stackingLayer) {
context.currentSvgParent.setAttribute('aria-owns', [context.currentSvgParent.getAttribute('aria-owns'), svgContainer.id].filter(Boolean).join(' '));
}
// If the parent is within the same stacking layer, append to the parent.
// Otherwise append to the right stacking layer.
const elementToAppendTo = context.parentStackingLayer === stackingLayer ? context.currentSvgParent : stackingLayer;
svgContainer.dataset.zIndex = styles.zIndex; // Used for sorting
elementToAppendTo.append(svgContainer);
// If the element establishes a stacking context, create subgroups for each stacking layer.
let childContext;
let backgroundContainer;
let ownStackingLayers;
if (establishesStackingContext(styles, parentStyles)) {
ownStackingLayers = createStackingLayers(svgContainer);
backgroundContainer = ownStackingLayers.rootBackgroundAndBorders;
childContext = {
...context,
currentSvgParent: svgContainer,
stackingLayers: ownStackingLayers,
parentStackingLayer: stackingLayer,
};
}
else {
backgroundContainer = svgContainer;
childContext = {
...context,
currentSvgParent: svgContainer,
parentStackingLayer: stackingLayer,
};
}
// Opacity
if (styles.opacity !== '1') {
svgContainer.setAttribute('opacity', styles.opacity);
}
// Accessibility
for (const [name, value] of getAccessibilityAttributes(element, context)) {
svgContainer.setAttribute(name, value);
}
// Handle ::before and ::after by creating temporary child elements in the DOM.
// Avoid infinite loop, in case `element` already is already a synthetic element created by us for a pseudo element.
if (isHTMLElement(element) && !element.dataset.pseudoElement) {
const handlePseudoElement = (pseudoSelector, position) => {
const pseudoElementStyles = window.getComputedStyle(element, pseudoSelector);
const content = cssValueParser(pseudoElementStyles.content).nodes.find(isTaggedUnionMember('type', 'string'));
if (!content) {
return;
}
// Pseudo elements are inline by default (like a span)
const span = element.ownerDocument.createElement('span');
span.dataset.pseudoElement = pseudoSelector;
copyCssStyles(pseudoElementStyles, span.style);
span.textContent = unescapeStringValue(content.value);
element.dataset.pseudoElementOwner = id;
cleanupFunctions.push(() => element.removeAttribute('data-pseudo-element-owner'));
const style = element.ownerDocument.createElement('style');
// Hide the *actual* pseudo element temporarily while we have a real DOM equivalent in the DOM
style.textContent = `[data-pseudo-element-owner="${id}"]${pseudoSelector} { display: none !important; }`;
element.before(style);
cleanupFunctions.push(() => style.remove());
element[position](span);
cleanupFunctions.push(() => span.remove());
};
handlePseudoElement('::before', 'prepend');
handlePseudoElement('::after', 'append');
// TODO handle ::marker etc
}
if (rectanglesIntersect) {
addBackgroundAndBorders(styles, bounds, backgroundContainer, window, context);
}
// If element is overflow: hidden, create a masking rectangle to hide any overflowing content of any descendants.
// Use <mask> instead of <clipPath> as Figma supports <mask>, but not <clipPath>.
if (styles.overflow !== 'visible') {
const mask = context.svgDocument.createElementNS(svgNamespace, 'mask');
mask.id = context.getUniqueId('mask-for-' + id);
const visibleRectangle = createBox(bounds, context);
visibleRectangle.setAttribute('fill', '#ffffff');
mask.append(visibleRectangle);
svgContainer.append(mask);
svgContainer.setAttribute('mask', `url(#${mask.id})`);
childContext = {
...childContext,
ancestorMasks: [{ mask, forElement: element }, ...childContext.ancestorMasks],
};
}
if (isHTMLElement(element) &&
(styles.position === 'absolute' || styles.position === 'fixed') &&
context.ancestorMasks.length > 0 &&
element.offsetParent) {
// Absolute and fixed elements are out of the flow and will bleed out of an `overflow: hidden` ancestor
// as long as their offsetParent is higher up than the mask element.
for (const { mask, forElement } of context.ancestorMasks) {
if (element.offsetParent.contains(forElement) || element.offsetParent === forElement) {
// Add a cutout to the ancestor mask
const visibleRectangle = createBox(bounds, context);
visibleRectangle.setAttribute('fill', '#ffffff');
mask.append(visibleRectangle);
}
else {
break;
}
}
}
if (rectanglesIntersect &&
isHTMLImageElement(element) &&
// Make sure the element has a src/srcset attribute (the relative URL). `element.src` is absolute and always defined.
(element.getAttribute('src') || element.getAttribute('srcset'))) {
const svgImage = context.svgDocument.createElementNS(svgNamespace, 'image');
svgImage.id = `${id}-image`; // read by inlineResources()
svgImage.setAttribute('xlink:href', element.currentSrc || element.src);
const paddingLeft = (_a = parseCSSLength(styles.paddingLeft, bounds.width)) !== null && _a !== void 0 ? _a : 0;
const paddingRight = (_b = parseCSSLength(styles.paddingRight, bounds.width)) !== null && _b !== void 0 ? _b : 0;
const paddingTop = (_c = parseCSSLength(styles.paddingTop, bounds.height)) !== null && _c !== void 0 ? _c : 0;
const paddingBottom = (_d = parseCSSLength(styles.paddingBottom, bounds.height)) !== null && _d !== void 0 ? _d : 0;
svgImage.setAttribute('x', (bounds.x + paddingLeft).toString());
svgImage.setAttribute('y', (bounds.y + paddingTop).toString());
svgImage.setAttribute('width', (bounds.width - paddingLeft - paddingRight).toString());
svgImage.setAttribute('height', (bounds.height - paddingTop - paddingBottom).toString());
if (element.alt) {
svgImage.setAttribute('aria-label', element.alt);
}
svgContainer.append(svgImage);
}
else if (rectanglesIntersect && isHTMLInputElement(element) && bounds.width > 0 && bounds.height > 0) {
// Handle button labels or input field content
if (element.value) {
const svgTextElement = context.svgDocument.createElementNS(svgNamespace, 'text');
copyTextStyles(styles, svgTextElement);
svgTextElement.setAttribute('dominant-baseline', 'central');
svgTextElement.setAttribute('xml:space', 'preserve');
svgTextElement.setAttribute('x', (bounds.x + ((_e = parseCSSLength(styles.paddingLeft, bounds.width)) !== null && _e !== void 0 ? _e : 0)).toString());
const top = bounds.top + ((_f = parseCSSLength(styles.paddingTop, bounds.height)) !== null && _f !== void 0 ? _f : 0);
const bottom = bounds.bottom + ((_g = parseCSSLength(styles.paddingBottom, bounds.height)) !== null && _g !== void 0 ? _g : 0);
const middle = (top + bottom) / 2;
svgTextElement.setAttribute('y', middle.toString());
svgTextElement.textContent = element.value;
childContext.stackingLayers.inFlowInlineLevelNonPositionedDescendants.append(svgTextElement);
}
}
else if (rectanglesIntersect && isSVGSVGElement(element) && isVisible(styles)) {
handleSvgNode(element, { ...childContext, idPrefix: `${id}-` });
}
else {
// Walk children even if rectangles don't intersect,
// because children can overflow the parent's bounds as long as overflow: visible (default).
for (const child of element.childNodes) {
walkNode(child, childContext);
}
if (ownStackingLayers) {
sortStackingLayerChildren(ownStackingLayers);
cleanupStackingLayerChildren(ownStackingLayers);
}
}
}
finally {
for (const cleanup of cleanupFunctions) {
cleanup();
}
}
}
function addBackgroundAndBorders(styles, bounds, backgroundAndBordersContainer, window, context) {
var _a, _b, _c, _d;
if (isVisible(styles)) {
if (bounds.width > 0 &&
bounds.height > 0 &&
(!isTransparent(styles.backgroundColor) || hasUniformBorder(styles) || styles.backgroundImage !== 'none')) {
const box = createBackgroundAndBorderBox(bounds, styles, context);
backgroundAndBordersContainer.append(box);
if (styles.backgroundImage !== 'none') {
const backgrounds = cssValueParser(styles.backgroundImage)
.nodes.filter(isTaggedUnionMember('type', 'function'))
.reverse();
const xBackgroundPositions = styles.backgroundPositionX.split(/\s*,\s*/g);
const yBackgroundPositions = styles.backgroundPositionY.split(/\s*,\s*/g);
const backgroundRepeats = styles.backgroundRepeat.split(/\s*,\s*/g);
for (const [index, backgroundNode] of backgrounds.entries()) {
const backgroundPositionX = (_a = parseCSSLength(xBackgroundPositions[index], bounds.width)) !== null && _a !== void 0 ? _a : 0;
const backgroundPositionY = (_b = parseCSSLength(yBackgroundPositions[index], bounds.height)) !== null && _b !== void 0 ? _b : 0;
const backgroundRepeat = backgroundRepeats[index];
if (backgroundNode.value === 'url' && backgroundNode.nodes[0]) {
const urlArgument = backgroundNode.nodes[0];
const image = context.svgDocument.createElementNS(svgNamespace, 'image');
image.id = context.getUniqueId('background-image'); // read by inlineResources()
const [cssWidth = 'auto', cssHeight = 'auto'] = styles.backgroundSize.split(' ');
const backgroundWidth = (_c = parseCSSLength(cssWidth, bounds.width)) !== null && _c !== void 0 ? _c : bounds.width;
const backgroundHeight = (_d = parseCSSLength(cssHeight, bounds.height)) !== null && _d !== void 0 ? _d : bounds.height;
image.setAttribute('width', backgroundWidth.toString());
image.setAttribute('height', backgroundHeight.toString());
if (cssWidth !== 'auto' && cssHeight !== 'auto') {
image.setAttribute('preserveAspectRatio', 'none');
}
else if (styles.backgroundSize === 'contain') {
image.setAttribute('preserveAspectRatio', 'xMidYMid meet');
}
else if (styles.backgroundSize === 'cover') {
image.setAttribute('preserveAspectRatio', 'xMidYMid slice');
}
// Technically not correct, because relative URLs should be resolved relative to the stylesheet,
// not the page. But we have no means to know what stylesheet the style came from
// (unless we iterate through all rules in all style sheets and find the matching one).
const url = new URL(unescapeStringValue(urlArgument.value), window.location.href);
image.setAttribute('xlink:href', url.href);
if (backgroundRepeat === 'no-repeat' ||
(backgroundPositionX === 0 &&
backgroundPositionY === 0 &&
backgroundWidth === bounds.width &&
backgroundHeight === bounds.height)) {
image.setAttribute('x', bounds.x.toString());
image.setAttribute('y', bounds.y.toString());
backgroundAndBordersContainer.append(image);
}
else {
image.setAttribute('x', '0');
image.setAttribute('y', '0');
const pattern = context.svgDocument.createElementNS(svgNamespace, 'pattern');
pattern.setAttribute('patternUnits', 'userSpaceOnUse');
pattern.setAttribute('patternContentUnits', 'userSpaceOnUse');
pattern.setAttribute('x', (bounds.x + backgroundPositionX).toString());
pattern.setAttribute('y', (bounds.y + backgroundPositionY).toString());
pattern.setAttribute('width', (backgroundRepeat === 'repeat' || backgroundRepeat === 'repeat-x'
? backgroundWidth
: // If background shouldn't repeat on this axis, make the tile as big as the element so the repetition is cut off.
backgroundWidth + bounds.x + backgroundPositionX).toString());
pattern.setAttribute('height', (backgroundRepeat === 'repeat' || backgroundRepeat === 'repeat-y'
? backgroundHeight
: // If background shouldn't repeat on this axis, make the tile as big as the element so the repetition is cut off.
backgroundHeight + bounds.y + backgroundPositionY).toString());
pattern.id = context.getUniqueId('pattern');
pattern.append(image);
box.before(pattern);
box.setAttribute('fill', `url(#${pattern.id})`);
}
}
else if (/^(-webkit-)?linear-gradient$/.test(backgroundNode.value)) {
const linearGradientCss = cssValueParser.stringify(backgroundNode);
const svgLinearGradient = convertLinearGradient(linearGradientCss, context);
if (backgroundPositionX !== 0 || backgroundPositionY !== 0) {
svgLinearGradient.setAttribute('gradientTransform', `translate(${backgroundPositionX}, ${backgroundPositionY})`);
}
svgLinearGradient.id = context.getUniqueId('linear-gradient');
box.before(svgLinearGradient);
box.setAttribute('fill', `url(#${svgLinearGradient.id})`);
}
}
}
}
if (!hasUniformBorder(styles)) {
// Draw lines for each border
for (const borderLine of createBorders(styles, bounds, context)) {
backgroundAndBordersContainer.append(borderLine);
}
}
}
}
function createBox(bounds, context) {
const box = context.svgDocument.createElementNS(svgNamespace, 'rect');
// TODO consider rotation
box.setAttribute('width', bounds.width.toString());
box.setAttribute('height', bounds.height.toString());
box.setAttribute('x', bounds.x.toString());
box.setAttribute('y', bounds.y.toString());
return box;
}
function createBackgroundAndBorderBox(bounds, styles, context) {
const background = createBox(bounds, context);
// TODO handle background image and other properties
if (styles.backgroundColor) {
background.setAttribute('fill', styles.backgroundColor);
}
if (hasUniformBorder(styles)) {
// Uniform border, use stroke
// Cannot use borderColor/borderWidth directly as in Firefox those are empty strings.
// Need to get the border property from some specific side (they are all the same in this condition).
// https://stackoverflow.com/questions/41696063/getcomputedstyle-returns-empty-strings-on-ff-when-instead-crome-returns-a-comp
background.setAttribute('stroke', styles.borderTopColor);
background.setAttribute('stroke-width', styles.borderTopWidth);
if (styles.borderTopStyle === 'dashed') {
// > Displays a series of short square-ended dashes or line segments.
// > The exact size and length of the segments are not defined by the specification and are implementation-specific.
background.setAttribute('stroke-dasharray', '1');
}
}
// Set border radius
// Approximation, always assumes uniform border-radius by using the top-left horizontal radius and the top-left vertical radius for all corners.
// TODO support irregular border radii on all corners by drawing border as a <path>.
const overlappingCurvesFactor = calculateOverlappingCurvesFactor(styles, bounds);
const radiusX = getBorderRadiiForSide('top', styles, bounds)[0] * overlappingCurvesFactor;
const radiusY = getBorderRadiiForSide('left', styles, bounds)[0] * overlappingCurvesFactor;
if (radiusX !== 0) {
background.setAttribute('rx', radiusX.toString());
}
if (radiusY !== 0) {
background.setAttribute('ry', radiusY.toString());
}
return background;
}
function* createBorders(styles, bounds, context) {
for (const side of ['top', 'bottom', 'right', 'left']) {
if (hasBorder(styles, side)) {
yield createBorder(styles, bounds, side, context);
}
}
}
function hasBorder(styles, side) {
return (!!styles.getPropertyValue(`border-${side}-color`) &&
!isTransparent(styles.getPropertyValue(`border-${side}-color`)) &&
styles.getPropertyValue(`border-${side}-width`) !== '0px');
}
function createBorder(styles, bounds, side, context) {
// TODO handle border-radius for non-uniform borders
const border = context.svgDocument.createElementNS(svgNamespace, 'line');
border.setAttribute('stroke-linecap', 'square');
const color = styles.getPropertyValue(`border-${side}-color`);
border.setAttribute('stroke', color);
border.setAttribute('stroke-width', styles.getPropertyValue(`border-${side}-width`));
// Handle inset/outset borders
const borderStyle = styles.getPropertyValue(`border-${side}-style`);
if ((borderStyle === 'inset' && (side === 'top' || side === 'left')) ||
(borderStyle === 'outset' && (side === 'right' || side === 'bottom'))) {
const match = color.match(/rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)/);
if (!match) {
throw new Error(`Unexpected color: ${color}`);
}
const components = match.slice(1, 4).map(value => parseInt(value, 10) * 0.3);
if (match[4]) {
components.push(parseFloat(match[4]));
}
// Low-light border
// https://stackoverflow.com/questions/4147940/how-do-browsers-determine-which-exact-colors-to-use-for-border-inset-or-outset
border.setAttribute('stroke', `rgba(${components.join(', ')})`);
}
if (side === 'top') {
border.setAttribute('x1', bounds.left.toString());
border.setAttribute('x2', bounds.right.toString());
border.setAttribute('y1', bounds.top.toString());
border.setAttribute('y2', bounds.top.toString());
}
else if (side === 'left') {
border.setAttribute('x1', bounds.left.toString());
border.setAttribute('x2', bounds.left.toString());
border.setAttribute('y1', bounds.top.toString());
border.setAttribute('y2', bounds.bottom.toString());
}
else if (side === 'right') {
border.setAttribute('x1', bounds.right.toString());
border.setAttribute('x2', bounds.right.toString());
border.setAttribute('y1', bounds.top.toString());
border.setAttribute('y2', bounds.bottom.toString());
}
else if (side === 'bottom') {
border.setAttribute('x1', bounds.left.toString());
border.setAttribute('x2', bounds.right.toString());
border.setAttribute('y1', bounds.bottom.toString());
border.setAttribute('y2', bounds.bottom.toString());
}
return border;
}
function createSvgAnchor(element, context) {
const svgAnchor = context.svgDocument.createElementNS(svgNamespace, 'a');
if (element.href && !element.href.startsWith('javascript:')) {
svgAnchor.setAttribute('href', element.href);
}
if (element.rel) {
svgAnchor.setAttribute('rel', element.rel);
}
if (element.target) {
svgAnchor.setAttribute('target', element.target);
}
if (element.download) {
svgAnchor.setAttribute('download', element.download);
}
return svgAnchor;
}
//# sourceMappingURL=element.js.map