Skip to content

Commit 59a231f

Browse files
rnb-tronAarebecca
andauthored
fix(exporter): ensure PNG/SVG export covers all foreignObject text (#227)
* fix(exporter): ensure PNG/SVG export covers all foreignObject text content * refactor(exporter): optimize svg exporter * feat(dev): support download image * refactor(exporter): resolve issue comments --------- Co-authored-by: Aaron <antvaaron@gmail.com>
1 parent ea238f5 commit 59a231f

4 files changed

Lines changed: 509 additions & 54 deletions

File tree

__tests__/unit/exporter/svg.test.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,84 @@ vi.mock('../../../src/exporter/font', () => ({
99

1010
const svgNS = 'http://www.w3.org/2000/svg';
1111

12+
function mockRect(
13+
element: Element,
14+
{
15+
left = 0,
16+
top = 0,
17+
width,
18+
height,
19+
}: {
20+
left?: number;
21+
top?: number;
22+
width: number;
23+
height: number;
24+
},
25+
) {
26+
Object.defineProperty(element, 'getBoundingClientRect', {
27+
configurable: true,
28+
value: () =>
29+
({
30+
x: left,
31+
y: top,
32+
left,
33+
top,
34+
width,
35+
height,
36+
right: left + width,
37+
bottom: top + height,
38+
toJSON: () => ({}),
39+
}) as DOMRect,
40+
});
41+
}
42+
43+
function mockSvgCoordinateSpace(
44+
svg: SVGSVGElement,
45+
{ scaleX = 1, scaleY = 1 }: { scaleX?: number; scaleY?: number } = {},
46+
) {
47+
const screenCTM = {
48+
a: scaleX,
49+
d: scaleY,
50+
e: 0,
51+
f: 0,
52+
inverse() {
53+
return {
54+
a: 1 / scaleX,
55+
d: 1 / scaleY,
56+
e: 0,
57+
f: 0,
58+
};
59+
},
60+
};
61+
62+
Object.defineProperty(svg, 'getScreenCTM', {
63+
configurable: true,
64+
value: () => screenCTM,
65+
});
66+
67+
Object.defineProperty(svg, 'createSVGPoint', {
68+
configurable: true,
69+
value: () => {
70+
const point = {
71+
x: 0,
72+
y: 0,
73+
matrixTransform(transform: {
74+
a?: number;
75+
d?: number;
76+
e?: number;
77+
f?: number;
78+
}) {
79+
return {
80+
x: point.x * (transform.a ?? 1) + (transform.e ?? 0),
81+
y: point.y * (transform.d ?? 1) + (transform.f ?? 0),
82+
};
83+
},
84+
};
85+
return point;
86+
},
87+
});
88+
}
89+
1290
describe('exporter/svg', () => {
1391
beforeEach(() => {
1492
document.body.innerHTML = '';
@@ -166,4 +244,79 @@ describe('exporter/svg', () => {
166244
expect(exportedRect?.getAttribute('fill')).toContain('data:image/svg+xml');
167245
expect(exportedRect?.getAttribute('fill')).not.toContain('url(#');
168246
});
247+
248+
it('converts foreignObject overflow measurements from client pixels to svg units', async () => {
249+
const svg = document.createElementNS(svgNS, 'svg');
250+
svg.setAttribute('viewBox', '0 0 100 100');
251+
mockSvgCoordinateSpace(svg, { scaleX: 2, scaleY: 2 });
252+
253+
const foreignObject = document.createElementNS(svgNS, 'foreignObject');
254+
const span = document.createElement('span');
255+
span.style.alignItems = 'flex-end';
256+
Object.defineProperty(span, 'scrollHeight', {
257+
configurable: true,
258+
get: () => 300,
259+
});
260+
foreignObject.appendChild(span);
261+
svg.appendChild(foreignObject);
262+
263+
mockRect(foreignObject, { left: 0, top: 0, width: 200, height: 200 });
264+
265+
const exported = await exportToSVG(svg);
266+
267+
expect(exported.getAttribute('viewBox')).toBe('0 -50 100 150');
268+
expect(exported.getAttribute('width')).toBe('100');
269+
expect(exported.getAttribute('height')).toBe('150');
270+
});
271+
272+
it('expands exports for root svg elements without an explicit viewBox', async () => {
273+
const svg = document.createElementNS(svgNS, 'svg');
274+
svg.setAttribute('width', '200');
275+
svg.setAttribute('height', '100');
276+
mockSvgCoordinateSpace(svg);
277+
278+
const foreignObject = document.createElementNS(svgNS, 'foreignObject');
279+
const span = document.createElement('span');
280+
span.style.alignItems = 'flex-end';
281+
Object.defineProperty(span, 'scrollHeight', {
282+
configurable: true,
283+
get: () => 150,
284+
});
285+
foreignObject.appendChild(span);
286+
svg.appendChild(foreignObject);
287+
288+
mockRect(foreignObject, { left: 0, top: 0, width: 200, height: 100 });
289+
290+
const exported = await exportToSVG(svg);
291+
292+
expect(exported.getAttribute('viewBox')).toBe('0 -50 200 150');
293+
expect(exported.getAttribute('width')).toBe('200');
294+
expect(exported.getAttribute('height')).toBe('150');
295+
});
296+
297+
it('uses rendered bounds when root svg width and height are relative values', async () => {
298+
const svg = document.createElementNS(svgNS, 'svg');
299+
svg.setAttribute('width', '100%');
300+
svg.setAttribute('height', '100%');
301+
mockSvgCoordinateSpace(svg);
302+
mockRect(svg, { left: 0, top: 0, width: 320, height: 180 });
303+
304+
const foreignObject = document.createElementNS(svgNS, 'foreignObject');
305+
const span = document.createElement('span');
306+
span.style.alignItems = 'flex-end';
307+
Object.defineProperty(span, 'scrollHeight', {
308+
configurable: true,
309+
get: () => 270,
310+
});
311+
foreignObject.appendChild(span);
312+
svg.appendChild(foreignObject);
313+
314+
mockRect(foreignObject, { left: 0, top: 0, width: 320, height: 180 });
315+
316+
const exported = await exportToSVG(svg);
317+
318+
expect(exported.getAttribute('viewBox')).toBe('0 -90 320 270');
319+
expect(exported.getAttribute('width')).toBe('320');
320+
expect(exported.getAttribute('height')).toBe('270');
321+
});
169322
});

dev/src/Infographic.tsx

Lines changed: 82 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
registerResourceLoader,
55
Infographic as Renderer,
66
} from '@antv/infographic';
7-
import { useEffect, useRef } from 'react';
7+
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
88

99
const svgTextCache = new Map<string, string>();
1010
const pendingRequests = new Map<string, Promise<string | null>>();
@@ -91,57 +91,90 @@ registerResourceLoader(async (config) => {
9191
}
9292
});
9393

94-
export const Infographic = ({
95-
options,
96-
init,
97-
onError,
98-
}: {
94+
type ExportType = 'png' | 'svg';
95+
96+
export interface InfographicHandle {
97+
download: (type: ExportType, filename?: string) => Promise<void>;
98+
}
99+
100+
type InfographicProps = {
99101
options: string | InfographicOptions;
100102
init?: Partial<InfographicOptions>;
101103
onError?: (error: Error | null) => void;
102-
}) => {
103-
const containerRef = useRef<HTMLDivElement>(null);
104-
const instanceRef = useRef<Renderer | null>(null);
105-
106-
useEffect(() => {
107-
if (!containerRef.current) return;
108-
if (instanceRef.current) return;
109-
110-
const instance = new Renderer({
111-
container: containerRef.current,
112-
svg: {
113-
attributes: {
114-
width: '100%',
115-
height: '100%',
104+
};
105+
106+
function downloadDataURL(dataURL: string, filename: string) {
107+
const link = document.createElement('a');
108+
link.href = dataURL;
109+
link.download = filename;
110+
document.body.appendChild(link);
111+
link.click();
112+
link.remove();
113+
}
114+
115+
export const Infographic = forwardRef<InfographicHandle, InfographicProps>(
116+
({ options, init, onError }, ref) => {
117+
const containerRef = useRef<HTMLDivElement>(null);
118+
const instanceRef = useRef<Renderer | null>(null);
119+
120+
useImperativeHandle(
121+
ref,
122+
() => ({
123+
async download(type, filename = `infographic.${type}`) {
124+
const instance = instanceRef.current;
125+
if (!instance) {
126+
throw new Error('Infographic is not ready yet.');
127+
}
128+
129+
const dataURL = await instance.toDataURL({ type });
130+
downloadDataURL(dataURL, filename);
116131
},
117-
style: {
118-
maxHeight: '80vh',
132+
}),
133+
[],
134+
);
135+
136+
useEffect(() => {
137+
if (!containerRef.current) return;
138+
if (instanceRef.current) return;
139+
140+
const instance = new Renderer({
141+
container: containerRef.current,
142+
svg: {
143+
attributes: {
144+
width: '100%',
145+
height: '100%',
146+
},
147+
style: {
148+
maxHeight: '80vh',
149+
},
119150
},
120-
},
121-
...init,
122-
});
123-
instanceRef.current = instance;
124-
Object.assign(window, { infographic: instance });
125-
126-
return () => {
127-
instance.destroy();
128-
instanceRef.current = null;
129-
};
130-
}, [init]);
131-
132-
useEffect(() => {
133-
const instance = instanceRef.current;
134-
if (!instance || !options) return;
135-
136-
try {
137-
onError?.(null);
138-
instance.render(options);
139-
} catch (err) {
140-
const error = err instanceof Error ? err : new Error(String(err));
141-
console.error('Dev Infographic render error', error);
142-
onError?.(error);
143-
}
144-
}, [options, onError]);
151+
...init,
152+
});
153+
instanceRef.current = instance;
154+
Object.assign(window, { infographic: instance });
145155

146-
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
147-
};
156+
return () => {
157+
instance.destroy();
158+
instanceRef.current = null;
159+
};
160+
}, [init]);
161+
162+
useEffect(() => {
163+
const instance = instanceRef.current;
164+
if (!instance || !options) return;
165+
166+
try {
167+
onError?.(null);
168+
instance.render(options);
169+
} catch (err) {
170+
const error = err instanceof Error ? err : new Error(String(err));
171+
console.error('Dev Infographic render error', error);
172+
onError?.(error);
173+
}
174+
}, [options, onError]);
175+
176+
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
177+
},
178+
);
179+
180+
Infographic.displayName = 'Infographic';

0 commit comments

Comments
 (0)