@antv/infographic 0.2.18 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,17 @@ import { embedFonts } from './font';
11
11
  import type { SVGExportOptions } from './types';
12
12
 
13
13
  const VIEWBOX_CHANGE_TOLERANCE = 0.5;
14
+ type BoundsTuple = [number, number, number, number];
15
+
16
+ interface ForeignObjectExportAdjustment {
17
+ rootBounds: BoundsTuple;
18
+ exportBounds: {
19
+ x: number;
20
+ y: number;
21
+ width: number;
22
+ height: number;
23
+ };
24
+ }
14
25
 
15
26
  export async function exportToSVGString(
16
27
  svg: SVGSVGElement,
@@ -51,41 +62,98 @@ function parseAbsoluteLength(value: string | null): number {
51
62
  return Number.parseFloat(trimmed);
52
63
  }
53
64
 
54
- function measureSpanContentHeight(span: HTMLElement): number {
65
+ function parseCoordinate(value: string | null): number {
66
+ const parsed = parseAbsoluteLength(value);
67
+ return Number.isNaN(parsed) ? 0 : parsed;
68
+ }
69
+
70
+ interface MeasuredSpanContentDimensions {
71
+ height: number;
72
+ width: number;
73
+ }
74
+
75
+ function measureSpanContentDimensions(
76
+ span: HTMLElement,
77
+ measureWidth: boolean,
78
+ ): MeasuredSpanContentDimensions {
55
79
  const prevHeight = span.style.height;
80
+ const prevWidth = span.style.width;
56
81
  const prevOverflow = span.style.overflow;
57
82
  try {
58
83
  span.style.height = 'max-content';
59
84
  span.style.overflow = 'hidden';
60
85
  void span.offsetHeight; // force reflow
61
- return span.scrollHeight;
86
+ const scrollHeight = span.scrollHeight;
87
+ const rectHeight = span.getBoundingClientRect().height;
88
+ let width = span.scrollWidth;
89
+
90
+ if (measureWidth) {
91
+ span.style.width = 'max-content';
92
+ void span.offsetWidth; // force reflow
93
+ width = span.scrollWidth;
94
+ }
95
+
96
+ return {
97
+ height: Math.max(scrollHeight, rectHeight),
98
+ width,
99
+ };
62
100
  } finally {
63
101
  span.style.height = prevHeight;
102
+ span.style.width = prevWidth;
64
103
  span.style.overflow = prevOverflow;
65
104
  }
66
105
  }
67
106
 
68
- function measureSpanContentWidth(span: HTMLElement): number {
69
- const prevWidth = span.style.width;
70
- const prevOverflow = span.style.overflow;
71
- try {
72
- span.style.width = 'max-content';
73
- span.style.overflow = 'hidden';
74
- void span.offsetWidth; // force reflow
75
- return span.scrollWidth;
76
- } finally {
77
- span.style.width = prevWidth;
78
- span.style.overflow = prevOverflow;
79
- }
107
+ function shouldKeepForeignObjectWidth(style: CSSStyleDeclaration): boolean {
108
+ const whiteSpace = style.whiteSpace;
109
+ const flexWrap = style.flexWrap;
110
+ const wordBreak = style.wordBreak;
111
+ const overflowWrap = style.overflowWrap;
112
+
113
+ return (
114
+ flexWrap === 'wrap' ||
115
+ flexWrap === 'wrap-reverse' ||
116
+ whiteSpace === 'pre-wrap' ||
117
+ whiteSpace === 'pre-line' ||
118
+ whiteSpace === 'normal' ||
119
+ overflowWrap === 'break-word' ||
120
+ wordBreak === 'break-word' ||
121
+ wordBreak === 'break-all'
122
+ );
80
123
  }
81
124
 
82
- // Returns [left, top, right, bottom] in SVG coordinates for a foreignObject,
125
+ function createCoordConverter(
126
+ svg: SVGSVGElement,
127
+ element: SVGGraphicsElement,
128
+ ): ((x: number, y: number) => SVGPoint) | null {
129
+ if (typeof element.getScreenCTM !== 'function') return null;
130
+ const screenCTM = element.getScreenCTM();
131
+ if (!screenCTM) return null;
132
+ const inverseCTM = screenCTM.inverse();
133
+
134
+ return (clientX: number, clientY: number) => {
135
+ const pt = svg.createSVGPoint();
136
+ pt.x = clientX;
137
+ pt.y = clientY;
138
+ return pt.matrixTransform(inverseCTM);
139
+ };
140
+ }
141
+
142
+ // Returns [left, top, right, bottom] in target coordinates for a foreignObject,
83
143
  // accounting for flex alignment: bottom/center-aligned content can overflow,
84
144
  // and horizontally aligned content can overflow as well.
85
145
  function getFOContentBoundsInSVG(
86
146
  fo: SVGForeignObjectElement,
87
- content: HTMLElement,
88
147
  toSVGCoord: (x: number, y: number) => SVGPoint,
148
+ {
149
+ contentHeight,
150
+ contentWidth,
151
+ keepForeignObjectWidth,
152
+ }: {
153
+ contentHeight: number;
154
+ contentWidth: number;
155
+ keepForeignObjectWidth: boolean;
156
+ },
89
157
  ): [number, number, number, number] {
90
158
  const foRect = fo.getBoundingClientRect();
91
159
  const foTopLeft = toSVGCoord(foRect.left, foRect.top);
@@ -103,20 +171,17 @@ function getFOContentBoundsInSVG(
103
171
  foRect.height > 0 ? foHeightSVG / foRect.height : 1;
104
172
  const svgUnitsPerClientPxX = foRect.width > 0 ? foWidthSVG / foRect.width : 1;
105
173
 
106
- // Measure actual content dimensions
107
- const realScrollHeight = measureSpanContentHeight(content);
108
174
  const contentHeightSVG =
109
- realScrollHeight > 0
110
- ? realScrollHeight * svgUnitsPerClientPxY
175
+ contentHeight > 0
176
+ ? contentHeight * svgUnitsPerClientPxY
111
177
  : foHeightSVG;
112
178
 
113
- const realScrollWidth = measureSpanContentWidth(content);
114
- const contentWidthSVG =
115
- realScrollWidth > 0 ? realScrollWidth * svgUnitsPerClientPxX : foWidthSVG;
116
-
117
- const computedStyle = window.getComputedStyle(content);
179
+ const computedStyle = window.getComputedStyle(fo.firstElementChild as Element);
118
180
  const alignItems = computedStyle.alignItems;
119
181
  const justifyContent = computedStyle.justifyContent;
182
+ const contentWidthSVG = keepForeignObjectWidth
183
+ ? foWidthSVG
184
+ : Math.max(foWidthSVG, contentWidth * svgUnitsPerClientPxX);
120
185
 
121
186
  // Calculate vertical bounds
122
187
  let top: number, bottom: number;
@@ -153,47 +218,91 @@ function getFOContentBoundsInSVG(
153
218
  return [left, top, right, bottom];
154
219
  }
155
220
 
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 {
221
+ function collectForeignObjectExportAdjustments(svg: SVGSVGElement) {
222
+ const toSVGCoord = createCoordConverter(svg, svg);
223
+ if (!toSVGCoord) return [];
224
+
225
+ return Array.from(
226
+ svg.querySelectorAll<SVGForeignObjectElement>('foreignObject'),
227
+ ).map((fo) => {
228
+ const content = fo.firstElementChild as HTMLElement | null;
229
+ if (!content) return null;
230
+ const computedStyle = window.getComputedStyle(content);
231
+ const keepForeignObjectWidth = shouldKeepForeignObjectWidth(computedStyle);
232
+ const measuredContent = measureSpanContentDimensions(
233
+ content,
234
+ !keepForeignObjectWidth,
235
+ );
236
+
237
+ const parent =
238
+ fo.parentElement instanceof SVGGraphicsElement ? fo.parentElement : svg;
239
+ const toParentCoord = createCoordConverter(svg, parent);
240
+ const toLocalCoord = createCoordConverter(svg, fo);
241
+ if (!toParentCoord) return null;
242
+
243
+ const parentBounds = getFOContentBoundsInSVG(fo, toParentCoord, {
244
+ contentHeight: measuredContent.height,
245
+ contentWidth: measuredContent.width,
246
+ keepForeignObjectWidth,
247
+ });
248
+ const originalX = parseCoordinate(fo.getAttribute('x'));
249
+ const originalY = parseCoordinate(fo.getAttribute('y'));
250
+ const localBounds = toLocalCoord
251
+ ? getFOContentBoundsInSVG(fo, toLocalCoord, {
252
+ contentHeight: measuredContent.height,
253
+ contentWidth: measuredContent.width,
254
+ keepForeignObjectWidth,
255
+ })
256
+ : null;
257
+ const hasTransform = fo.hasAttribute('transform');
258
+ if (hasTransform && !localBounds) return null;
259
+
260
+ const exportBounds = localBounds
261
+ ? {
262
+ x: originalX + localBounds[0],
263
+ y: originalY + localBounds[1],
264
+ width: localBounds[2] - localBounds[0],
265
+ height: localBounds[3] - localBounds[1],
266
+ }
267
+ : {
268
+ x: parentBounds[0],
269
+ y: parentBounds[1],
270
+ width: parentBounds[2] - parentBounds[0],
271
+ height: parentBounds[3] - parentBounds[1],
272
+ };
273
+
274
+ return {
275
+ rootBounds: getFOContentBoundsInSVG(fo, toSVGCoord, {
276
+ contentHeight: measuredContent.height,
277
+ contentWidth: measuredContent.width,
278
+ keepForeignObjectWidth,
279
+ }),
280
+ exportBounds,
281
+ } satisfies ForeignObjectExportAdjustment;
282
+ });
283
+ }
284
+
285
+ function computeFullViewBox(
286
+ svg: SVGSVGElement,
287
+ adjustments: Array<ForeignObjectExportAdjustment | null>,
288
+ ): string | null {
162
289
  const viewBox = getExportViewBox(svg);
163
290
  if (!viewBox) return null;
164
291
 
165
- if (typeof svg.getScreenCTM !== 'function') return null;
166
- const screenCTM = svg.getScreenCTM();
167
- if (!screenCTM) return null;
168
- const inverseCTM = screenCTM.inverse();
169
-
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
- };
176
-
177
292
  let minX = viewBox.x;
178
293
  let minY = viewBox.y;
179
294
  let maxX = viewBox.x + viewBox.width;
180
295
  let maxY = viewBox.y + viewBox.height;
181
296
 
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
- });
297
+ adjustments.forEach((adjustment) => {
298
+ if (!adjustment) return;
299
+ const { rootBounds } = adjustment;
300
+ const [left, top, right, bottom] = rootBounds;
301
+ minX = Math.min(minX, left);
302
+ minY = Math.min(minY, top);
303
+ maxX = Math.max(maxX, right);
304
+ maxY = Math.max(maxY, bottom);
305
+ });
197
306
 
198
307
  const newX = minX;
199
308
  const newY = minY;
@@ -210,6 +319,23 @@ function computeFullViewBox(svg: SVGSVGElement): string | null {
210
319
  return `${newX} ${newY} ${newWidth} ${newHeight}`;
211
320
  }
212
321
 
322
+ function applyForeignObjectExportAdjustments(
323
+ svg: SVGSVGElement,
324
+ adjustments: Array<ForeignObjectExportAdjustment | null>,
325
+ ) {
326
+ const clonedForeignObjects = Array.from(
327
+ svg.querySelectorAll<SVGForeignObjectElement>('foreignObject'),
328
+ );
329
+
330
+ adjustments.forEach((adjustment, index) => {
331
+ if (!adjustment) return;
332
+ const clonedForeignObject = clonedForeignObjects[index];
333
+ if (!clonedForeignObject) return;
334
+
335
+ setAttributes(clonedForeignObject, adjustment.exportBounds);
336
+ });
337
+ }
338
+
213
339
  export async function exportToSVG(
214
340
  svg: SVGSVGElement,
215
341
  options: Omit<SVGExportOptions, 'type'> = {},
@@ -222,7 +348,9 @@ export async function exportToSVG(
222
348
  const clonedSVG = svg.cloneNode(true) as SVGSVGElement;
223
349
 
224
350
  if (typeof document !== 'undefined') {
225
- const fullViewBox = computeFullViewBox(svg);
351
+ const adjustments = collectForeignObjectExportAdjustments(svg);
352
+ applyForeignObjectExportAdjustments(clonedSVG, adjustments);
353
+ const fullViewBox = computeFullViewBox(svg, adjustments);
226
354
  if (fullViewBox) {
227
355
  clonedSVG.setAttribute('viewBox', fullViewBox);
228
356
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.2.18';
1
+ export const VERSION = '0.2.19';