Skip to content

Commit 111bf93

Browse files
authored
fix(react-charting): use inline styles in SVG export for better compatibility (#33713)
1 parent 6ca7d73 commit 111bf93

File tree

2 files changed

+54
-47
lines changed

2 files changed

+54
-47
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "fix: use inline styles in SVG export for better compatibility",
4+
"packageName": "@fluentui/react-charting",
5+
"email": "110246001+krkshitij@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}

packages/charts/react-charting/src/components/DeclarativeChart/imageExporter.ts

Lines changed: 47 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,35 @@ export function toImage(chartContainer?: HTMLElement | null, opts: IImageExportO
3838
});
3939
}
4040

41+
const SVG_STYLE_PROPERTIES = ['display', 'fill', 'fill-opacity', 'opacity', 'stroke', 'stroke-width', 'transform'];
42+
const SVG_TEXT_STYLE_PROPERTIES = ['font-family', 'font-size', 'font-weight', 'text-anchor'];
43+
4144
function toSVG(chartContainer: HTMLElement, background: string) {
4245
const svg = chartContainer.querySelector<SVGSVGElement>('svg');
4346
if (!svg) {
4447
throw new Error('SVG not found');
4548
}
4649

47-
const { width: svgWidth, height: svgHeight } = svg.getBoundingClientRect();
48-
const classNames = new Set<string>();
49-
const legendGroup = cloneLegendsToSVG(chartContainer, svgWidth, svgHeight, classNames);
50-
const w1 = Math.max(svgWidth, legendGroup.width);
51-
const h1 = svgHeight + legendGroup.height;
5250
const clonedSvg = d3Select(svg.cloneNode(true) as SVGSVGElement)
5351
.attr('width', null)
5452
.attr('height', null)
5553
.attr('viewBox', null);
56-
const { fontFamily } = getComputedStyle(chartContainer);
54+
const svgElements = svg.getElementsByTagName('*');
55+
const clonedSvgElements = clonedSvg.node()!.getElementsByTagName('*');
56+
57+
for (let i = 0; i < svgElements.length; i++) {
58+
if (svgElements[i].tagName.toLowerCase() === 'text') {
59+
copyStyle([...SVG_STYLE_PROPERTIES, ...SVG_TEXT_STYLE_PROPERTIES], svgElements[i], clonedSvgElements[i]);
60+
} else {
61+
copyStyle(SVG_STYLE_PROPERTIES, svgElements[i], clonedSvgElements[i]);
62+
}
63+
}
64+
65+
const { width: svgWidth, height: svgHeight } = svg.getBoundingClientRect();
66+
const legendGroup = cloneLegendsToSVG(chartContainer, svgWidth, svgHeight);
67+
const w1 = Math.max(svgWidth, legendGroup.width);
68+
const h1 = svgHeight + legendGroup.height;
5769

58-
clonedSvg.selectAll('text').style('font-family', fontFamily);
5970
if (legendGroup.node) {
6071
clonedSvg.append(() => legendGroup.node);
6172
}
@@ -66,36 +77,6 @@ function toSVG(chartContainer: HTMLElement, background: string) {
6677
.attr('width', w1)
6778
.attr('height', h1)
6879
.attr('fill', background);
69-
70-
const svgElements = svg.getElementsByTagName('*');
71-
const styleSheets = document.styleSheets;
72-
let styleRules: string = '';
73-
74-
for (let i = svgElements.length - 1; i--; ) {
75-
svgElements[i].classList.forEach(className => {
76-
classNames.add(`.${className}`);
77-
});
78-
}
79-
80-
for (let i = 0; i < styleSheets.length; i++) {
81-
const rules = styleSheets[i].cssRules;
82-
for (let j = 0; j < rules.length; j++) {
83-
if (rules[j].constructor.name === 'CSSStyleRule') {
84-
const selectorText = (rules[j] as CSSStyleRule).selectorText;
85-
const hasClassName = selectorText.split(' ').some(word => classNames.has(word));
86-
87-
if (hasClassName) {
88-
styleRules += rules[j].cssText + ' ';
89-
}
90-
}
91-
}
92-
}
93-
styleRules = resolveCSSVariables(chartContainer, styleRules);
94-
95-
const xmlDocument = new DOMParser().parseFromString('<svg></svg>', 'image/svg+xml');
96-
const styleNode = xmlDocument.createCDATASection(styleRules);
97-
clonedSvg.insert('defs', ':first-child').append('style').attr('type', 'text/css').node()!.appendChild(styleNode);
98-
9980
clonedSvg.attr('width', w1).attr('height', h1).attr('viewBox', `0 0 ${w1} ${h1}`);
10081

10182
return {
@@ -105,7 +86,19 @@ function toSVG(chartContainer: HTMLElement, background: string) {
10586
};
10687
}
10788

108-
function cloneLegendsToSVG(chartContainer: HTMLElement, svgWidth: number, svgHeight: number, classNames: Set<string>) {
89+
const LEGEND_RECT_STYLE_PROPERTIES_MAP = {
90+
'background-color': 'fill',
91+
'border-color': 'stroke',
92+
};
93+
const LEGEND_TEXT_STYLE_PROPERTIES_MAP = {
94+
color: 'fill',
95+
'font-family': 'font-family',
96+
'font-size': 'font-size',
97+
'font-weight': 'font-weight',
98+
opacity: 'opacity',
99+
};
100+
101+
function cloneLegendsToSVG(chartContainer: HTMLElement, svgWidth: number, svgHeight: number) {
109102
const legendButtons = chartContainer.querySelectorAll<HTMLElement>(`
110103
button[class^="legend-"],
111104
[class^="legendContainer-"] div[class^="overflowIndicationTextStyle-"],
@@ -146,35 +139,29 @@ function cloneLegendsToSVG(chartContainer: HTMLElement, svgWidth: number, svgHei
146139

147140
if (legendButtons[i].tagName.toLowerCase() === 'button') {
148141
const legendRect = legendButtons[i].querySelector<HTMLDivElement>('[class^="rect"]');
149-
const { backgroundColor: legendColor, borderColor: legendBorderColor } = getComputedStyle(legendRect!);
150142

151143
legendText = legendButtons[i].querySelector<HTMLDivElement>('[class^="text"]');
152-
legendText!.classList.forEach(className => classNames.add(`.${className}`));
153144
legendItem
154145
.append('rect')
155146
.attr('x', legendX + 8)
156147
.attr('y', svgHeight + legendY + 8)
157148
.attr('width', 12)
158149
.attr('height', 12)
159-
.attr('fill', legendColor)
160150
.attr('stroke-width', 1)
161-
.attr('stroke', legendBorderColor);
151+
.call(selection => copyStyle(LEGEND_RECT_STYLE_PROPERTIES_MAP, legendRect!, selection.node()!));
162152
textOffset = 28;
163153
} else {
164154
legendText = legendButtons[i] as HTMLDivElement;
165-
legendText.classList.forEach(className => classNames.add(`.${className}`));
166155
textOffset = 8;
167156
}
168157

169-
const { color: textColor } = getComputedStyle(legendText!);
170158
legendItem
171159
.append('text')
172160
.attr('x', legendX + textOffset)
173161
.attr('y', svgHeight + legendY + 8)
174162
.attr('dominant-baseline', 'hanging')
175-
.attr('class', legendText!.getAttribute('class'))
176-
.attr('fill', textColor)
177-
.text(legendText!.textContent);
163+
.text(legendText!.textContent)
164+
.call(selection => copyStyle(LEGEND_TEXT_STYLE_PROPERTIES_MAP, legendText!, selection.node()!));
178165
legendX += legendWidth;
179166
}
180167

@@ -271,3 +258,16 @@ function unescapePonyfill(str: string) {
271258
}
272259
return result;
273260
}
261+
262+
function copyStyle(properties: string[] | Record<string, string>, fromEl: Element, toEl: Element) {
263+
const styles = getComputedStyle(fromEl);
264+
if (Array.isArray(properties)) {
265+
properties.forEach(prop => {
266+
d3Select(toEl).style(prop, styles.getPropertyValue(prop));
267+
});
268+
} else {
269+
Object.entries(properties).forEach(([fromProp, toProp]) => {
270+
d3Select(toEl).style(toProp, styles.getPropertyValue(fromProp));
271+
});
272+
}
273+
}

0 commit comments

Comments
 (0)