Skip to content

Commit 9e9b9e3

Browse files
committed
Fix legend styles in prints
1 parent dc9b156 commit 9e9b9e3

File tree

2 files changed

+162
-13
lines changed

2 files changed

+162
-13
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,4 @@
106106
"vitest": "^3.1.1",
107107
"vue": "^3.5.14"
108108
}
109-
}
109+
}

src/dom-to-png.js

Lines changed: 161 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ function applyAllComputedStylesDeep(clone, original, inheritedFontFamily) {
6262

6363
let styleMap = {};
6464

65+
// Get all already-inline styles
6566
let inlineStyle = clone.getAttribute('style');
6667
if (typeof inlineStyle !== 'string') inlineStyle = '';
6768
inlineStyle.split(';').forEach(s => {
@@ -71,12 +72,22 @@ function applyAllComputedStylesDeep(clone, original, inheritedFontFamily) {
7172
}
7273
});
7374

75+
// Copy *every* computed property
7476
for (let i = 0; i < computedStyle.length; i += 1) {
7577
const property = computedStyle[i];
7678
const value = computedStyle.getPropertyValue(property);
7779
styleMap[property] = value;
7880
}
7981

82+
// ---- Strongest part: force resolved color and background-color ----
83+
// getPropertyValue always resolves to an actual color, not 'inherit'
84+
styleMap['color'] = computedStyle.color;
85+
styleMap['background-color'] = computedStyle.backgroundColor;
86+
// Add font props for safety (if they matter to your charts)
87+
styleMap['font-family'] = computedStyle.fontFamily || inheritedFontFamily || '';
88+
styleMap['font-size'] = computedStyle.fontSize;
89+
styleMap['font-weight'] = computedStyle.fontWeight;
90+
8091
if (
8192
isFlex ||
8293
computedStyle.display.includes('grid') ||
@@ -94,9 +105,7 @@ function applyAllComputedStylesDeep(clone, original, inheritedFontFamily) {
94105
styleMap[prop] = computedStyle.getPropertyValue(prop);
95106
});
96107

97-
if (inheritedFontFamily) {
98-
styleMap['font-family'] = inheritedFontFamily;
99-
}
108+
// No more inheritedFontFamily here; use resolved computedStyle above
100109

101110
styleMap['overflow'] = 'visible';
102111
styleMap['overflow-x'] = 'visible';
@@ -108,6 +117,7 @@ function applyAllComputedStylesDeep(clone, original, inheritedFontFamily) {
108117
}
109118
clone.setAttribute('style', styleString);
110119

120+
// Recursively walk
111121
const cloneChildren = clone.children || [];
112122
const originalChildren = original.children || [];
113123
for (let i = 0; i < cloneChildren.length; i++) {
@@ -117,6 +127,7 @@ function applyAllComputedStylesDeep(clone, original, inheritedFontFamily) {
117127
}
118128
}
119129

130+
120131
/**
121132
* Ensures all <text> elements in the given SVG element tree have the given font family
122133
* as both an attribute and a style property.
@@ -204,9 +215,11 @@ function extractFontFaceRules() {
204215
try {
205216
const rules = sheet.cssRules;
206217
if (!rules) continue;
207-
208218
for (const rule of rules) {
209-
if (rule.constructor.name === "CSSFontFaceRule" || rule.cssText.startsWith("@font-face")) {
219+
if (
220+
(typeof CSSFontFaceRule !== "undefined" && rule instanceof CSSFontFaceRule) ||
221+
rule.cssText.trim().startsWith("@font-face")
222+
) {
210223
fontCssRules.push(rule.cssText);
211224
}
212225
}
@@ -218,21 +231,155 @@ function extractFontFaceRules() {
218231
return fontCssRules;
219232
}
220233

234+
235+
/**
236+
* Injects all @font-face CSS rules into an SVG element as an embedded <style> tag within <defs>.
237+
* This ensures custom fonts are preserved when the SVG is exported or rasterized.
238+
*
239+
* @param {SVGSVGElement} svgEl - The SVG element to inject font-face styles into.
240+
*/
221241
function injectFontFaceStyles(svgEl) {
222242
const fontRules = extractFontFaceRules();
223243
if (!fontRules.length) return;
224-
225244
const style = document.createElement('style');
226245
style.setAttribute('type', 'text/css');
227246
style.textContent = fontRules.join('\n');
228-
229247
const defs = svgEl.querySelector('defs') || document.createElementNS(XMLNS, 'defs');
230248
defs.appendChild(style);
231249
if (!svgEl.querySelector('defs')) {
232250
svgEl.insertBefore(defs, svgEl.firstChild);
233251
}
234252
}
235253

254+
/**
255+
* Recursively inlines all computed styles from the live DOM into the corresponding elements
256+
* within each <foreignObject> in a cloned SVG tree.
257+
* This is used to preserve appearance for HTML content inside SVG exports.
258+
*
259+
* @param {SVGSVGElement} cloneSvg - The cloned SVG element containing <foreignObject> nodes to style.
260+
* @param {SVGSVGElement} liveSvg - The original live SVG element to read computed styles from.
261+
*/
262+
function inlineForeignObjectStylesInClone(cloneSvg, liveSvg) {
263+
const cloneFOs = cloneSvg.querySelectorAll('foreignObject');
264+
const liveFOs = liveSvg.querySelectorAll('foreignObject');
265+
266+
cloneFOs.forEach((cloneFO, foIdx) => {
267+
const liveFO = liveFOs[foIdx];
268+
if (!liveFO) return;
269+
270+
function applyStylesRecursively(cloneNode, liveNode) {
271+
if (!cloneNode || !liveNode) return;
272+
if (cloneNode.nodeType === 1 && liveNode.nodeType === 1) {
273+
const computedStyle = window.getComputedStyle(liveNode);
274+
let styleString = '';
275+
for (let j = 0; j < computedStyle.length; j++) {
276+
const property = computedStyle[j];
277+
styleString += `${property}:${computedStyle.getPropertyValue(property)};`;
278+
}
279+
cloneNode.setAttribute('style', styleString);
280+
}
281+
const cloneChildren = cloneNode.children || [];
282+
const liveChildren = liveNode.children || [];
283+
for (let i = 0; i < cloneChildren.length; i++) {
284+
applyStylesRecursively(cloneChildren[i], liveChildren[i]);
285+
}
286+
}
287+
288+
applyStylesRecursively(cloneFO, liveFO);
289+
});
290+
}
291+
292+
/**
293+
* Injects critical CSS style rules used by HTML nodes inside each <foreignObject> of the cloned SVG.
294+
* This collects all matching CSS rules from same-origin stylesheets and inserts them
295+
* as <style> tags inside the relevant <foreignObject> nodes.
296+
*
297+
* @param {SVGSVGElement} cloneSvg - The cloned SVG element containing <foreignObject> nodes.
298+
*/
299+
function injectCriticalStylesIntoForeignObjects(cloneSvg) {
300+
const foreignObjects = cloneSvg.querySelectorAll('foreignObject');
301+
foreignObjects.forEach(fo => {
302+
let css = '';
303+
const elements = Array.from(fo.querySelectorAll('*'));
304+
if (elements.length === 0) return;
305+
306+
for (const sheet of document.styleSheets) {
307+
let rules;
308+
try { rules = sheet.cssRules; } catch { continue; }
309+
if (!rules) continue;
310+
for (const rule of rules) {
311+
if (typeof CSSStyleRule !== "undefined" && !(rule instanceof CSSStyleRule)) continue;
312+
try {
313+
// Only include if any element matches this selector
314+
if (elements.some(el => el.matches(rule.selectorText))) {
315+
css += rule.cssText + "\n";
316+
}
317+
} catch { continue; }
318+
}
319+
}
320+
if (css) {
321+
const styleTag = document.createElement('style');
322+
styleTag.textContent = css;
323+
fo.insertBefore(styleTag, fo.firstChild);
324+
}
325+
});
326+
}
327+
328+
/**
329+
* Ensures the correct HTML namespace is set on the root element of each <foreignObject> within an SVG.
330+
* Adds xmlns="http://www.w3.org/1999/xhtml" to the root HTML node if not present.
331+
*
332+
* @param {SVGSVGElement} cloneSvg - The cloned SVG element containing <foreignObject> nodes.
333+
*/
334+
function ensureForeignObjectRootNamespace(cloneSvg) {
335+
const foreignObjects = cloneSvg.querySelectorAll('foreignObject');
336+
foreignObjects.forEach(fo => {
337+
const root = fo.firstElementChild;
338+
if (root && root.tagName.toLowerCase() !== 'svg') {
339+
root.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
340+
}
341+
});
342+
}
343+
344+
/**
345+
* Recursively applies all computed styles from the live DOM into the corresponding HTML elements
346+
* inside <foreignObject> nodes in the cloned SVG, ensuring visual fidelity.
347+
*
348+
* @param {SVGSVGElement} cloneSvg - The cloned SVG element containing <foreignObject> nodes.
349+
* @param {SVGSVGElement} liveSvg - The original SVG element to read computed styles from.
350+
*/
351+
function inlineForeignObjectHTMLComputedStyles(cloneSvg, liveSvg) {
352+
const cloneFOs = cloneSvg.querySelectorAll('foreignObject');
353+
const liveFOs = liveSvg.querySelectorAll('foreignObject');
354+
cloneFOs.forEach((cloneFO, idx) => {
355+
const liveFO = liveFOs[idx];
356+
if (!cloneFO || !liveFO) return;
357+
const cloneRoot = cloneFO.firstElementChild;
358+
const liveRoot = liveFO.firstElementChild;
359+
if (cloneRoot && liveRoot) {
360+
walkAllAndApply(cloneRoot, liveRoot);
361+
}
362+
});
363+
}
364+
365+
/**
366+
* Recursively walks two parallel DOM trees, applying all computed styles from the live node
367+
* to the cloned node and all of their descendants.
368+
*
369+
* @param {Element} cloneNode - The cloned DOM node to apply styles to.
370+
* @param {Element} liveNode - The live DOM node to read computed styles from.
371+
*/
372+
function walkAllAndApply(cloneNode, liveNode) {
373+
if (cloneNode.nodeType !== 1 || !liveNode) return;
374+
applyAllComputedStylesDeep(cloneNode, liveNode);
375+
const cloneChildren = cloneNode.children || [];
376+
const liveChildren = liveNode.children || [];
377+
for (let i = 0; i < cloneChildren.length; i++) {
378+
walkAllAndApply(cloneChildren[i], liveChildren[i]);
379+
}
380+
}
381+
382+
236383
/**
237384
* Converts a DOM element (including HTML, SVG, and canvas) into a high-resolution PNG data URL.
238385
*
@@ -289,14 +436,16 @@ async function domToPng({ container, scale = 2 }) {
289436
const liveSvg = container.querySelector('svg[aria-label]');
290437
const cloneSvg = clone.querySelector('svg[aria-label]');
291438
if (liveSvg && cloneSvg) {
292-
const bbox = liveSvg.getBoundingClientRect();
293-
const svgWidth = bbox.width;
294-
const svgHeight = bbox.height;
295-
296-
// Only modify the clone
439+
ensureForeignObjectRootNamespace(cloneSvg);
440+
injectCriticalStylesIntoForeignObjects(cloneSvg);
441+
inlineForeignObjectStylesInClone(cloneSvg, liveSvg);
442+
inlineForeignObjectHTMLComputedStyles(cloneSvg, liveSvg);
297443
applyAllSvgComputedStylesInline(cloneSvg);
298444
setFontFamilyOnAllSvgTextElements(cloneSvg, containerFontFamily);
299445

446+
const bbox = liveSvg.getBoundingClientRect();
447+
const svgWidth = bbox.width;
448+
const svgHeight = bbox.height;
300449
const scaledWidth = Math.round(svgWidth * scale);
301450
const scaledHeight = Math.round(svgHeight * scale);
302451
const pngDataUrl = await svgElementToPngDataUrl(cloneSvg, scaledWidth, scaledHeight);

0 commit comments

Comments
 (0)