Skip to content

Commit ced3f6d

Browse files
authored
fix(exporter): 修复 PNG 导出多行文本截断与宽度异常问题 (#244)
* fix(exporter): correct wrapped foreignObject export bounds * fix(exporter): preserve foreignObject adjustment mapping * fix: cr * perf: optimize measurement methods to reduce reflows
1 parent 4775b94 commit ced3f6d

2 files changed

Lines changed: 454 additions & 59 deletions

File tree

__tests__/unit/exporter/svg.test.ts

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,47 @@ function mockSvgCoordinateSpace(
8787
});
8888
}
8989

90+
function mockElementCoordinateSpace(
91+
svg: SVGSVGElement,
92+
element: SVGGraphicsElement,
93+
{
94+
scaleX = 1,
95+
scaleY = 1,
96+
translateX = 0,
97+
translateY = 0,
98+
}: {
99+
scaleX?: number;
100+
scaleY?: number;
101+
translateX?: number;
102+
translateY?: number;
103+
} = {},
104+
) {
105+
const screenCTM = {
106+
a: scaleX,
107+
d: scaleY,
108+
e: translateX,
109+
f: translateY,
110+
inverse() {
111+
return {
112+
a: 1 / scaleX,
113+
d: 1 / scaleY,
114+
e: -translateX / scaleX,
115+
f: -translateY / scaleY,
116+
};
117+
},
118+
};
119+
120+
Object.defineProperty(element, 'getScreenCTM', {
121+
configurable: true,
122+
value: () => screenCTM,
123+
});
124+
125+
Object.defineProperty(element, 'createSVGPoint', {
126+
configurable: true,
127+
value: () => svg.createSVGPoint(),
128+
});
129+
}
130+
90131
describe('exporter/svg', () => {
91132
beforeEach(() => {
92133
document.body.innerHTML = '';
@@ -371,4 +412,230 @@ describe('exporter/svg', () => {
371412
expect(exported.getAttribute('width')).toBe('320');
372413
expect(exported.getAttribute('height')).toBe('270');
373414
});
415+
416+
it('keeps wrapped foreignObject text within the original export width', async () => {
417+
const svg = document.createElementNS(svgNS, 'svg');
418+
svg.setAttribute('viewBox', '0 0 200 100');
419+
mockSvgCoordinateSpace(svg);
420+
421+
const foreignObject = document.createElementNS(svgNS, 'foreignObject');
422+
const span = document.createElement('span');
423+
span.style.width = '100%';
424+
span.style.height = '100%';
425+
span.style.display = 'flex';
426+
span.style.flexWrap = 'wrap';
427+
span.style.wordBreak = 'break-word';
428+
span.style.whiteSpace = 'pre-wrap';
429+
430+
Object.defineProperty(span, 'scrollWidth', {
431+
configurable: true,
432+
get: () => 320,
433+
});
434+
Object.defineProperty(span, 'scrollHeight', {
435+
configurable: true,
436+
get: () => 160,
437+
});
438+
439+
foreignObject.appendChild(span);
440+
svg.appendChild(foreignObject);
441+
442+
mockRect(foreignObject, { left: 0, top: 0, width: 200, height: 100 });
443+
444+
const exported = await exportToSVG(svg);
445+
446+
expect(exported.getAttribute('viewBox')).toBe('0 0 200 160');
447+
expect(exported.getAttribute('width')).toBe('200');
448+
expect(exported.getAttribute('height')).toBe('160');
449+
});
450+
451+
it('resizes exported foreignObject height to the measured wrapped content height', async () => {
452+
const svg = document.createElementNS(svgNS, 'svg');
453+
svg.setAttribute('viewBox', '0 0 200 100');
454+
mockSvgCoordinateSpace(svg);
455+
456+
const foreignObject = document.createElementNS(svgNS, 'foreignObject');
457+
foreignObject.setAttribute('x', '20');
458+
foreignObject.setAttribute('y', '30');
459+
foreignObject.setAttribute('width', '140');
460+
foreignObject.setAttribute('height', '40');
461+
462+
const span = document.createElement('span');
463+
span.style.width = '100%';
464+
span.style.height = '100%';
465+
span.style.display = 'flex';
466+
span.style.flexWrap = 'wrap';
467+
span.style.wordBreak = 'break-word';
468+
span.style.whiteSpace = 'pre-wrap';
469+
470+
Object.defineProperty(span, 'scrollHeight', {
471+
configurable: true,
472+
get: () => 80,
473+
});
474+
475+
foreignObject.appendChild(span);
476+
svg.appendChild(foreignObject);
477+
478+
mockRect(foreignObject, { left: 20, top: 30, width: 140, height: 40 });
479+
480+
const exported = await exportToSVG(svg);
481+
const exportedForeignObject = exported.querySelector('foreignObject');
482+
483+
expect(exportedForeignObject?.getAttribute('x')).toBe('20');
484+
expect(exportedForeignObject?.getAttribute('y')).toBe('30');
485+
expect(exportedForeignObject?.getAttribute('width')).toBe('140');
486+
expect(exportedForeignObject?.getAttribute('height')).toBe('80');
487+
});
488+
489+
it('uses rendered content height when it is larger than scrollHeight', async () => {
490+
const svg = document.createElementNS(svgNS, 'svg');
491+
svg.setAttribute('viewBox', '0 0 200 100');
492+
mockSvgCoordinateSpace(svg);
493+
494+
const foreignObject = document.createElementNS(svgNS, 'foreignObject');
495+
foreignObject.setAttribute('x', '20');
496+
foreignObject.setAttribute('y', '30');
497+
foreignObject.setAttribute('width', '140');
498+
foreignObject.setAttribute('height', '40');
499+
500+
const span = document.createElement('span');
501+
span.style.width = '100%';
502+
span.style.height = '100%';
503+
span.style.display = 'flex';
504+
span.style.flexWrap = 'wrap';
505+
span.style.wordBreak = 'break-word';
506+
span.style.whiteSpace = 'pre-wrap';
507+
508+
Object.defineProperty(span, 'scrollHeight', {
509+
configurable: true,
510+
get: () => 80,
511+
});
512+
Object.defineProperty(span, 'getBoundingClientRect', {
513+
configurable: true,
514+
value: () =>
515+
({
516+
x: 0,
517+
y: 0,
518+
left: 0,
519+
top: 0,
520+
width: 140,
521+
height: 84.4,
522+
right: 140,
523+
bottom: 84.4,
524+
toJSON: () => ({}),
525+
}) as DOMRect,
526+
});
527+
528+
foreignObject.appendChild(span);
529+
svg.appendChild(foreignObject);
530+
531+
mockRect(foreignObject, { left: 20, top: 30, width: 140, height: 40 });
532+
533+
const exported = await exportToSVG(svg);
534+
const exportedForeignObject = exported.querySelector('foreignObject');
535+
536+
expect(exportedForeignObject?.getAttribute('height')).toBe('84.4');
537+
});
538+
539+
it('keeps foreignObject adjustments aligned when earlier foreignObjects are skipped', async () => {
540+
const svg = document.createElementNS(svgNS, 'svg');
541+
svg.setAttribute('viewBox', '0 0 200 100');
542+
mockSvgCoordinateSpace(svg);
543+
544+
const skippedForeignObject = document.createElementNS(svgNS, 'foreignObject');
545+
skippedForeignObject.setAttribute('x', '0');
546+
skippedForeignObject.setAttribute('y', '0');
547+
skippedForeignObject.setAttribute('width', '10');
548+
skippedForeignObject.setAttribute('height', '10');
549+
svg.appendChild(skippedForeignObject);
550+
551+
const adjustedForeignObject = document.createElementNS(
552+
svgNS,
553+
'foreignObject',
554+
);
555+
adjustedForeignObject.setAttribute('x', '20');
556+
adjustedForeignObject.setAttribute('y', '30');
557+
adjustedForeignObject.setAttribute('width', '140');
558+
adjustedForeignObject.setAttribute('height', '40');
559+
560+
const span = document.createElement('span');
561+
span.style.width = '100%';
562+
span.style.height = '100%';
563+
span.style.display = 'flex';
564+
span.style.flexWrap = 'wrap';
565+
span.style.wordBreak = 'break-word';
566+
span.style.whiteSpace = 'pre-wrap';
567+
568+
Object.defineProperty(span, 'scrollHeight', {
569+
configurable: true,
570+
get: () => 80,
571+
});
572+
573+
adjustedForeignObject.appendChild(span);
574+
svg.appendChild(adjustedForeignObject);
575+
576+
mockRect(skippedForeignObject, { left: 0, top: 0, width: 10, height: 10 });
577+
mockRect(adjustedForeignObject, {
578+
left: 20,
579+
top: 30,
580+
width: 140,
581+
height: 40,
582+
});
583+
584+
const exported = await exportToSVG(svg);
585+
const [firstForeignObject, secondForeignObject] =
586+
exported.querySelectorAll('foreignObject');
587+
588+
expect(firstForeignObject?.getAttribute('width')).toBe('10');
589+
expect(firstForeignObject?.getAttribute('height')).toBe('10');
590+
expect(secondForeignObject?.getAttribute('x')).toBe('20');
591+
expect(secondForeignObject?.getAttribute('y')).toBe('30');
592+
expect(secondForeignObject?.getAttribute('width')).toBe('140');
593+
expect(secondForeignObject?.getAttribute('height')).toBe('80');
594+
});
595+
596+
it('does not double-apply foreignObject transforms when resizing exported bounds', async () => {
597+
const svg = document.createElementNS(svgNS, 'svg');
598+
svg.setAttribute('viewBox', '0 0 200 100');
599+
mockSvgCoordinateSpace(svg);
600+
601+
const foreignObject = document.createElementNS(svgNS, 'foreignObject');
602+
foreignObject.setAttribute('x', '20');
603+
foreignObject.setAttribute('y', '30');
604+
foreignObject.setAttribute('width', '140');
605+
foreignObject.setAttribute('height', '40');
606+
foreignObject.setAttribute('transform', 'translate(5 7)');
607+
608+
const span = document.createElement('span');
609+
span.style.width = '100%';
610+
span.style.height = '100%';
611+
span.style.display = 'flex';
612+
span.style.flexWrap = 'wrap';
613+
span.style.wordBreak = 'break-word';
614+
span.style.whiteSpace = 'pre-wrap';
615+
616+
Object.defineProperty(span, 'scrollHeight', {
617+
configurable: true,
618+
get: () => 80,
619+
});
620+
621+
foreignObject.appendChild(span);
622+
svg.appendChild(foreignObject);
623+
624+
mockRect(foreignObject, { left: 25, top: 37, width: 140, height: 40 });
625+
mockElementCoordinateSpace(svg, foreignObject as SVGGraphicsElement, {
626+
translateX: 25,
627+
translateY: 37,
628+
});
629+
630+
const exported = await exportToSVG(svg);
631+
const exportedForeignObject = exported.querySelector('foreignObject');
632+
633+
expect(exportedForeignObject?.getAttribute('x')).toBe('20');
634+
expect(exportedForeignObject?.getAttribute('y')).toBe('30');
635+
expect(exportedForeignObject?.getAttribute('width')).toBe('140');
636+
expect(exportedForeignObject?.getAttribute('height')).toBe('80');
637+
expect(exportedForeignObject?.getAttribute('transform')).toBe(
638+
'translate(5 7)',
639+
);
640+
});
374641
});

0 commit comments

Comments
 (0)