Skip to content

Commit cf14d23

Browse files
committed
fix(exporter): preserve foreignObject adjustment mapping
1 parent 1955000 commit cf14d23

2 files changed

Lines changed: 74 additions & 13 deletions

File tree

__tests__/unit/exporter/svg.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,4 +494,61 @@ describe('exporter/svg', () => {
494494

495495
expect(exportedForeignObject?.getAttribute('height')).toBe('84.4');
496496
});
497+
498+
it('keeps foreignObject adjustments aligned when earlier foreignObjects are skipped', async () => {
499+
const svg = document.createElementNS(svgNS, 'svg');
500+
svg.setAttribute('viewBox', '0 0 200 100');
501+
mockSvgCoordinateSpace(svg);
502+
503+
const skippedForeignObject = document.createElementNS(svgNS, 'foreignObject');
504+
skippedForeignObject.setAttribute('x', '0');
505+
skippedForeignObject.setAttribute('y', '0');
506+
skippedForeignObject.setAttribute('width', '10');
507+
skippedForeignObject.setAttribute('height', '10');
508+
svg.appendChild(skippedForeignObject);
509+
510+
const adjustedForeignObject = document.createElementNS(
511+
svgNS,
512+
'foreignObject',
513+
);
514+
adjustedForeignObject.setAttribute('x', '20');
515+
adjustedForeignObject.setAttribute('y', '30');
516+
adjustedForeignObject.setAttribute('width', '140');
517+
adjustedForeignObject.setAttribute('height', '40');
518+
519+
const span = document.createElement('span');
520+
span.style.width = '100%';
521+
span.style.height = '100%';
522+
span.style.display = 'flex';
523+
span.style.flexWrap = 'wrap';
524+
span.style.wordBreak = 'break-word';
525+
span.style.whiteSpace = 'pre-wrap';
526+
527+
Object.defineProperty(span, 'scrollHeight', {
528+
configurable: true,
529+
get: () => 80,
530+
});
531+
532+
adjustedForeignObject.appendChild(span);
533+
svg.appendChild(adjustedForeignObject);
534+
535+
mockRect(skippedForeignObject, { left: 0, top: 0, width: 10, height: 10 });
536+
mockRect(adjustedForeignObject, {
537+
left: 20,
538+
top: 30,
539+
width: 140,
540+
height: 40,
541+
});
542+
543+
const exported = await exportToSVG(svg);
544+
const [firstForeignObject, secondForeignObject] =
545+
exported.querySelectorAll('foreignObject');
546+
547+
expect(firstForeignObject?.getAttribute('width')).toBe('10');
548+
expect(firstForeignObject?.getAttribute('height')).toBe('10');
549+
expect(secondForeignObject?.getAttribute('x')).toBe('20');
550+
expect(secondForeignObject?.getAttribute('y')).toBe('30');
551+
expect(secondForeignObject?.getAttribute('width')).toBe('140');
552+
expect(secondForeignObject?.getAttribute('height')).toBe('80');
553+
});
497554
});

src/exporter/svg.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,15 @@ function shouldKeepForeignObjectWidth(style: CSSStyleDeclaration): boolean {
9191
const whiteSpace = style.whiteSpace;
9292
const flexWrap = style.flexWrap;
9393
const wordBreak = style.wordBreak;
94+
const overflowWrap = style.overflowWrap;
9495

9596
return (
9697
flexWrap === 'wrap' ||
9798
flexWrap === 'wrap-reverse' ||
9899
whiteSpace === 'pre-wrap' ||
100+
whiteSpace === 'pre-line' ||
99101
whiteSpace === 'normal' ||
102+
overflowWrap === 'break-word' ||
100103
wordBreak === 'break-word' ||
101104
wordBreak === 'break-all'
102105
);
@@ -203,27 +206,25 @@ function collectForeignObjectExportAdjustments(svg: SVGSVGElement) {
203206

204207
return Array.from(
205208
svg.querySelectorAll<SVGForeignObjectElement>('foreignObject'),
206-
).flatMap((fo) => {
209+
).map((fo) => {
207210
const content = fo.firstElementChild as HTMLElement | null;
208-
if (!content) return [];
211+
if (!content) return null;
209212

210213
const parent =
211214
fo.parentElement instanceof SVGGraphicsElement ? fo.parentElement : svg;
212215
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-
];
216+
if (!toParentCoord) return null;
217+
218+
return {
219+
rootBounds: getFOContentBoundsInSVG(fo, content, toSVGCoord),
220+
localBounds: getFOContentBoundsInSVG(fo, content, toParentCoord),
221+
} satisfies ForeignObjectExportAdjustment;
221222
});
222223
}
223224

224225
function computeFullViewBox(
225226
svg: SVGSVGElement,
226-
adjustments: ForeignObjectExportAdjustment[],
227+
adjustments: Array<ForeignObjectExportAdjustment | null>,
227228
): string | null {
228229
const viewBox = getExportViewBox(svg);
229230
if (!viewBox) return null;
@@ -233,7 +234,9 @@ function computeFullViewBox(
233234
let maxX = viewBox.x + viewBox.width;
234235
let maxY = viewBox.y + viewBox.height;
235236

236-
adjustments.forEach(({ rootBounds }) => {
237+
adjustments.forEach((adjustment) => {
238+
if (!adjustment) return;
239+
const { rootBounds } = adjustment;
237240
const [left, top, right, bottom] = rootBounds;
238241
minX = Math.min(minX, left);
239242
minY = Math.min(minY, top);
@@ -258,13 +261,14 @@ function computeFullViewBox(
258261

259262
function applyForeignObjectExportAdjustments(
260263
svg: SVGSVGElement,
261-
adjustments: ForeignObjectExportAdjustment[],
264+
adjustments: Array<ForeignObjectExportAdjustment | null>,
262265
) {
263266
const clonedForeignObjects = Array.from(
264267
svg.querySelectorAll<SVGForeignObjectElement>('foreignObject'),
265268
);
266269

267270
adjustments.forEach((adjustment, index) => {
271+
if (!adjustment) return;
268272
const clonedForeignObject = clonedForeignObjects[index];
269273
if (!clonedForeignObject) return;
270274

0 commit comments

Comments
 (0)