Skip to content

Commit 1955000

Browse files
committed
fix(exporter): correct wrapped foreignObject export bounds
1 parent 4775b94 commit 1955000

2 files changed

Lines changed: 233 additions & 40 deletions

File tree

__tests__/unit/exporter/svg.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,4 +371,127 @@ describe('exporter/svg', () => {
371371
expect(exported.getAttribute('width')).toBe('320');
372372
expect(exported.getAttribute('height')).toBe('270');
373373
});
374+
375+
it('keeps wrapped foreignObject text within the original export width', async () => {
376+
const svg = document.createElementNS(svgNS, 'svg');
377+
svg.setAttribute('viewBox', '0 0 200 100');
378+
mockSvgCoordinateSpace(svg);
379+
380+
const foreignObject = document.createElementNS(svgNS, 'foreignObject');
381+
const span = document.createElement('span');
382+
span.style.width = '100%';
383+
span.style.height = '100%';
384+
span.style.display = 'flex';
385+
span.style.flexWrap = 'wrap';
386+
span.style.wordBreak = 'break-word';
387+
span.style.whiteSpace = 'pre-wrap';
388+
389+
Object.defineProperty(span, 'scrollWidth', {
390+
configurable: true,
391+
get: () => 320,
392+
});
393+
Object.defineProperty(span, 'scrollHeight', {
394+
configurable: true,
395+
get: () => 160,
396+
});
397+
398+
foreignObject.appendChild(span);
399+
svg.appendChild(foreignObject);
400+
401+
mockRect(foreignObject, { left: 0, top: 0, width: 200, height: 100 });
402+
403+
const exported = await exportToSVG(svg);
404+
405+
expect(exported.getAttribute('viewBox')).toBe('0 0 200 160');
406+
expect(exported.getAttribute('width')).toBe('200');
407+
expect(exported.getAttribute('height')).toBe('160');
408+
});
409+
410+
it('resizes exported foreignObject height to the measured wrapped content height', async () => {
411+
const svg = document.createElementNS(svgNS, 'svg');
412+
svg.setAttribute('viewBox', '0 0 200 100');
413+
mockSvgCoordinateSpace(svg);
414+
415+
const foreignObject = document.createElementNS(svgNS, 'foreignObject');
416+
foreignObject.setAttribute('x', '20');
417+
foreignObject.setAttribute('y', '30');
418+
foreignObject.setAttribute('width', '140');
419+
foreignObject.setAttribute('height', '40');
420+
421+
const span = document.createElement('span');
422+
span.style.width = '100%';
423+
span.style.height = '100%';
424+
span.style.display = 'flex';
425+
span.style.flexWrap = 'wrap';
426+
span.style.wordBreak = 'break-word';
427+
span.style.whiteSpace = 'pre-wrap';
428+
429+
Object.defineProperty(span, 'scrollHeight', {
430+
configurable: true,
431+
get: () => 80,
432+
});
433+
434+
foreignObject.appendChild(span);
435+
svg.appendChild(foreignObject);
436+
437+
mockRect(foreignObject, { left: 20, top: 30, width: 140, height: 40 });
438+
439+
const exported = await exportToSVG(svg);
440+
const exportedForeignObject = exported.querySelector('foreignObject');
441+
442+
expect(exportedForeignObject?.getAttribute('x')).toBe('20');
443+
expect(exportedForeignObject?.getAttribute('y')).toBe('30');
444+
expect(exportedForeignObject?.getAttribute('width')).toBe('140');
445+
expect(exportedForeignObject?.getAttribute('height')).toBe('80');
446+
});
447+
448+
it('uses rendered content height when it is larger than scrollHeight', async () => {
449+
const svg = document.createElementNS(svgNS, 'svg');
450+
svg.setAttribute('viewBox', '0 0 200 100');
451+
mockSvgCoordinateSpace(svg);
452+
453+
const foreignObject = document.createElementNS(svgNS, 'foreignObject');
454+
foreignObject.setAttribute('x', '20');
455+
foreignObject.setAttribute('y', '30');
456+
foreignObject.setAttribute('width', '140');
457+
foreignObject.setAttribute('height', '40');
458+
459+
const span = document.createElement('span');
460+
span.style.width = '100%';
461+
span.style.height = '100%';
462+
span.style.display = 'flex';
463+
span.style.flexWrap = 'wrap';
464+
span.style.wordBreak = 'break-word';
465+
span.style.whiteSpace = 'pre-wrap';
466+
467+
Object.defineProperty(span, 'scrollHeight', {
468+
configurable: true,
469+
get: () => 80,
470+
});
471+
Object.defineProperty(span, 'getBoundingClientRect', {
472+
configurable: true,
473+
value: () =>
474+
({
475+
x: 0,
476+
y: 0,
477+
left: 0,
478+
top: 0,
479+
width: 140,
480+
height: 84.4,
481+
right: 140,
482+
bottom: 84.4,
483+
toJSON: () => ({}),
484+
}) as DOMRect,
485+
});
486+
487+
foreignObject.appendChild(span);
488+
svg.appendChild(foreignObject);
489+
490+
mockRect(foreignObject, { left: 20, top: 30, width: 140, height: 40 });
491+
492+
const exported = await exportToSVG(svg);
493+
const exportedForeignObject = exported.querySelector('foreignObject');
494+
495+
expect(exportedForeignObject?.getAttribute('height')).toBe('84.4');
496+
});
374497
});

src/exporter/svg.ts

Lines changed: 110 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import { embedFonts } from './font';
1111
import type { SVGExportOptions } from './types';
1212

1313
const VIEWBOX_CHANGE_TOLERANCE = 0.5;
14+
type BoundsTuple = [number, number, number, number];
15+
16+
interface ForeignObjectExportAdjustment {
17+
rootBounds: BoundsTuple;
18+
localBounds: BoundsTuple;
19+
}
1420

1521
export async function exportToSVGString(
1622
svg: SVGSVGElement,
@@ -58,7 +64,9 @@ function measureSpanContentHeight(span: HTMLElement): number {
5864
span.style.height = 'max-content';
5965
span.style.overflow = 'hidden';
6066
void span.offsetHeight; // force reflow
61-
return span.scrollHeight;
67+
const scrollHeight = span.scrollHeight;
68+
const rectHeight = span.getBoundingClientRect().height;
69+
return Math.max(scrollHeight, rectHeight);
6270
} finally {
6371
span.style.height = prevHeight;
6472
span.style.overflow = prevOverflow;
@@ -79,7 +87,39 @@ function measureSpanContentWidth(span: HTMLElement): number {
7987
}
8088
}
8189

82-
// Returns [left, top, right, bottom] in SVG coordinates for a foreignObject,
90+
function shouldKeepForeignObjectWidth(style: CSSStyleDeclaration): boolean {
91+
const whiteSpace = style.whiteSpace;
92+
const flexWrap = style.flexWrap;
93+
const wordBreak = style.wordBreak;
94+
95+
return (
96+
flexWrap === 'wrap' ||
97+
flexWrap === 'wrap-reverse' ||
98+
whiteSpace === 'pre-wrap' ||
99+
whiteSpace === 'normal' ||
100+
wordBreak === 'break-word' ||
101+
wordBreak === 'break-all'
102+
);
103+
}
104+
105+
function createCoordConverter(
106+
svg: SVGSVGElement,
107+
element: SVGGraphicsElement,
108+
): ((x: number, y: number) => SVGPoint) | null {
109+
if (typeof element.getScreenCTM !== 'function') return null;
110+
const screenCTM = element.getScreenCTM();
111+
if (!screenCTM) return null;
112+
const inverseCTM = screenCTM.inverse();
113+
114+
return (clientX: number, clientY: number) => {
115+
const pt = svg.createSVGPoint();
116+
pt.x = clientX;
117+
pt.y = clientY;
118+
return pt.matrixTransform(inverseCTM);
119+
};
120+
}
121+
122+
// Returns [left, top, right, bottom] in target coordinates for a foreignObject,
83123
// accounting for flex alignment: bottom/center-aligned content can overflow,
84124
// and horizontally aligned content can overflow as well.
85125
function getFOContentBoundsInSVG(
@@ -110,13 +150,17 @@ function getFOContentBoundsInSVG(
110150
? realScrollHeight * svgUnitsPerClientPxY
111151
: foHeightSVG;
112152

113-
const realScrollWidth = measureSpanContentWidth(content);
114-
const contentWidthSVG =
115-
realScrollWidth > 0 ? realScrollWidth * svgUnitsPerClientPxX : foWidthSVG;
116-
117153
const computedStyle = window.getComputedStyle(content);
118154
const alignItems = computedStyle.alignItems;
119155
const justifyContent = computedStyle.justifyContent;
156+
const contentWidthSVG = shouldKeepForeignObjectWidth(computedStyle)
157+
? foWidthSVG
158+
: (() => {
159+
const realScrollWidth = measureSpanContentWidth(content);
160+
return realScrollWidth > 0
161+
? Math.max(foWidthSVG, realScrollWidth * svgUnitsPerClientPxX)
162+
: foWidthSVG;
163+
})();
120164

121165
// Calculate vertical bounds
122166
let top: number, bottom: number;
@@ -153,47 +197,49 @@ function getFOContentBoundsInSVG(
153197
return [left, top, right, bottom];
154198
}
155199

156-
/**
157-
* Computes a viewBox that fully covers all foreignObject text content,
158-
* accounting for overflow caused by flex alignment (bottom/center align
159-
* can push content outside the foreignObject bounds).
160-
*/
161-
function computeFullViewBox(svg: SVGSVGElement): string | null {
162-
const viewBox = getExportViewBox(svg);
163-
if (!viewBox) return null;
200+
function collectForeignObjectExportAdjustments(svg: SVGSVGElement) {
201+
const toSVGCoord = createCoordConverter(svg, svg);
202+
if (!toSVGCoord) return [];
164203

165-
if (typeof svg.getScreenCTM !== 'function') return null;
166-
const screenCTM = svg.getScreenCTM();
167-
if (!screenCTM) return null;
168-
const inverseCTM = screenCTM.inverse();
204+
return Array.from(
205+
svg.querySelectorAll<SVGForeignObjectElement>('foreignObject'),
206+
).flatMap((fo) => {
207+
const content = fo.firstElementChild as HTMLElement | null;
208+
if (!content) return [];
169209

170-
const toSVGCoord = (clientX: number, clientY: number) => {
171-
const pt = svg.createSVGPoint();
172-
pt.x = clientX;
173-
pt.y = clientY;
174-
return pt.matrixTransform(inverseCTM);
175-
};
210+
const parent =
211+
fo.parentElement instanceof SVGGraphicsElement ? fo.parentElement : svg;
212+
const toParentCoord = createCoordConverter(svg, parent);
213+
if (!toParentCoord) return [];
214+
215+
return [
216+
{
217+
rootBounds: getFOContentBoundsInSVG(fo, content, toSVGCoord),
218+
localBounds: getFOContentBoundsInSVG(fo, content, toParentCoord),
219+
} satisfies ForeignObjectExportAdjustment,
220+
];
221+
});
222+
}
223+
224+
function computeFullViewBox(
225+
svg: SVGSVGElement,
226+
adjustments: ForeignObjectExportAdjustment[],
227+
): string | null {
228+
const viewBox = getExportViewBox(svg);
229+
if (!viewBox) return null;
176230

177231
let minX = viewBox.x;
178232
let minY = viewBox.y;
179233
let maxX = viewBox.x + viewBox.width;
180234
let maxY = viewBox.y + viewBox.height;
181235

182-
svg
183-
.querySelectorAll<SVGForeignObjectElement>('foreignObject')
184-
.forEach((fo) => {
185-
const content = fo.firstElementChild as HTMLElement;
186-
if (!content) return;
187-
const [left, top, right, bottom] = getFOContentBoundsInSVG(
188-
fo,
189-
content,
190-
toSVGCoord,
191-
);
192-
minX = Math.min(minX, left);
193-
minY = Math.min(minY, top);
194-
maxX = Math.max(maxX, right);
195-
maxY = Math.max(maxY, bottom);
196-
});
236+
adjustments.forEach(({ rootBounds }) => {
237+
const [left, top, right, bottom] = rootBounds;
238+
minX = Math.min(minX, left);
239+
minY = Math.min(minY, top);
240+
maxX = Math.max(maxX, right);
241+
maxY = Math.max(maxY, bottom);
242+
});
197243

198244
const newX = minX;
199245
const newY = minY;
@@ -210,6 +256,28 @@ function computeFullViewBox(svg: SVGSVGElement): string | null {
210256
return `${newX} ${newY} ${newWidth} ${newHeight}`;
211257
}
212258

259+
function applyForeignObjectExportAdjustments(
260+
svg: SVGSVGElement,
261+
adjustments: ForeignObjectExportAdjustment[],
262+
) {
263+
const clonedForeignObjects = Array.from(
264+
svg.querySelectorAll<SVGForeignObjectElement>('foreignObject'),
265+
);
266+
267+
adjustments.forEach((adjustment, index) => {
268+
const clonedForeignObject = clonedForeignObjects[index];
269+
if (!clonedForeignObject) return;
270+
271+
const [left, top, right, bottom] = adjustment.localBounds;
272+
setAttributes(clonedForeignObject, {
273+
x: left,
274+
y: top,
275+
width: right - left,
276+
height: bottom - top,
277+
});
278+
});
279+
}
280+
213281
export async function exportToSVG(
214282
svg: SVGSVGElement,
215283
options: Omit<SVGExportOptions, 'type'> = {},
@@ -222,7 +290,9 @@ export async function exportToSVG(
222290
const clonedSVG = svg.cloneNode(true) as SVGSVGElement;
223291

224292
if (typeof document !== 'undefined') {
225-
const fullViewBox = computeFullViewBox(svg);
293+
const adjustments = collectForeignObjectExportAdjustments(svg);
294+
applyForeignObjectExportAdjustments(clonedSVG, adjustments);
295+
const fullViewBox = computeFullViewBox(svg, adjustments);
226296
if (fullViewBox) {
227297
clonedSVG.setAttribute('viewBox', fullViewBox);
228298
}

0 commit comments

Comments
 (0)