@@ -11,6 +11,12 @@ import { embedFonts } from './font';
1111import type { SVGExportOptions } from './types' ;
1212
1313const VIEWBOX_CHANGE_TOLERANCE = 0.5 ;
14+ type BoundsTuple = [ number , number , number , number ] ;
15+
16+ interface ForeignObjectExportAdjustment {
17+ rootBounds : BoundsTuple ;
18+ localBounds : BoundsTuple ;
19+ }
1420
1521export 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.
85125function 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+
213281export 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