@antv/infographic 0.2.4 → 0.2.6

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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/infographic.min.js +88 -78
  4. package/dist/infographic.min.js.map +1 -1
  5. package/esm/exporter/index.d.ts +1 -1
  6. package/esm/exporter/index.js +1 -1
  7. package/esm/exporter/svg.js +223 -2
  8. package/esm/exporter/types.d.ts +5 -0
  9. package/esm/index.d.ts +4 -2
  10. package/esm/index.js +3 -2
  11. package/esm/renderer/fonts/loader.js +63 -8
  12. package/esm/renderer/stylize/gradient.js +1 -1
  13. package/esm/resource/index.d.ts +1 -1
  14. package/esm/resource/index.js +1 -1
  15. package/esm/resource/load-tracker.d.ts +6 -0
  16. package/esm/resource/load-tracker.js +36 -0
  17. package/esm/resource/loader.d.ts +1 -0
  18. package/esm/resource/loader.js +27 -14
  19. package/esm/runtime/Infographic.js +13 -0
  20. package/esm/utils/is-browser.d.ts +1 -0
  21. package/esm/utils/is-browser.js +68 -0
  22. package/esm/utils/measure-text.d.ts +1 -0
  23. package/esm/utils/measure-text.js +9 -7
  24. package/lib/exporter/index.d.ts +1 -1
  25. package/lib/exporter/index.js +2 -1
  26. package/lib/exporter/svg.js +223 -2
  27. package/lib/exporter/types.d.ts +5 -0
  28. package/lib/index.d.ts +4 -2
  29. package/lib/index.js +6 -3
  30. package/lib/renderer/fonts/loader.js +63 -8
  31. package/lib/renderer/stylize/gradient.js +1 -1
  32. package/lib/resource/index.d.ts +1 -1
  33. package/lib/resource/index.js +3 -1
  34. package/lib/resource/load-tracker.d.ts +6 -0
  35. package/lib/resource/load-tracker.js +42 -0
  36. package/lib/resource/loader.d.ts +1 -0
  37. package/lib/resource/loader.js +30 -14
  38. package/lib/runtime/Infographic.js +13 -0
  39. package/lib/utils/is-browser.d.ts +1 -0
  40. package/lib/utils/is-browser.js +71 -0
  41. package/lib/utils/measure-text.d.ts +1 -0
  42. package/lib/utils/measure-text.js +11 -7
  43. package/package.json +1 -1
  44. package/src/exporter/index.ts +1 -1
  45. package/src/exporter/svg.ts +254 -2
  46. package/src/exporter/types.ts +5 -0
  47. package/src/index.ts +8 -3
  48. package/src/renderer/fonts/loader.ts +82 -9
  49. package/src/renderer/stylize/gradient.ts +1 -1
  50. package/src/resource/index.ts +1 -1
  51. package/src/resource/load-tracker.ts +51 -0
  52. package/src/resource/loader.ts +27 -12
  53. package/src/runtime/Infographic.tsx +12 -0
  54. package/src/utils/is-browser.ts +79 -0
  55. package/src/utils/measure-text.ts +11 -7
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isBrowser = isBrowser;
4
+ let _isBrowser;
5
+ function isBrowser() {
6
+ if (_isBrowser !== undefined) {
7
+ return _isBrowser;
8
+ }
9
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
10
+ _isBrowser = false;
11
+ return false;
12
+ }
13
+ const body = document.body;
14
+ if (!body) {
15
+ _isBrowser = false;
16
+ return false;
17
+ }
18
+ let hasRealLayout = false;
19
+ try {
20
+ const el = document.createElement('div');
21
+ el.style.cssText = `
22
+ position: absolute;
23
+ left: 11px;
24
+ top: 13px;
25
+ width: 37px;
26
+ height: 19px;
27
+ padding: 0;
28
+ margin: 0;
29
+ border: 0;
30
+ visibility: hidden;
31
+ `;
32
+ body.appendChild(el);
33
+ void el.offsetHeight;
34
+ const rect = el.getBoundingClientRect();
35
+ body.removeChild(el);
36
+ hasRealLayout =
37
+ rect.width === 37 &&
38
+ rect.height === 19 &&
39
+ rect.left !== 0 &&
40
+ rect.top !== 0;
41
+ }
42
+ catch {
43
+ hasRealLayout = false;
44
+ }
45
+ if (!hasRealLayout) {
46
+ _isBrowser = false;
47
+ return false;
48
+ }
49
+ let hasRealCanvas = false;
50
+ try {
51
+ const canvas = document.createElement('canvas');
52
+ canvas.width = 100;
53
+ canvas.height = 50;
54
+ const ctx = canvas.getContext('2d');
55
+ if (!ctx) {
56
+ _isBrowser = false;
57
+ return false;
58
+ }
59
+ ctx.font = '20px sans-serif';
60
+ const metrics = ctx.measureText('Hello');
61
+ hasRealCanvas =
62
+ typeof metrics.width === 'number' &&
63
+ metrics.width > 0 &&
64
+ metrics.width < 1000;
65
+ }
66
+ catch {
67
+ hasRealCanvas = false;
68
+ }
69
+ _isBrowser = hasRealCanvas;
70
+ return hasRealCanvas;
71
+ }
@@ -1,4 +1,5 @@
1
1
  import { TextProps } from '../jsx';
2
+ export declare const setFontExtendFactor: (factor: number) => void;
2
3
  export declare function measureText(text: string | number | undefined, attrs: TextProps): {
3
4
  width: number;
4
5
  height: number;
@@ -3,21 +3,25 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.setFontExtendFactor = void 0;
6
7
  exports.measureText = measureText;
7
8
  const measury_1 = require("measury");
8
9
  const AlibabaPuHuiTi_Regular_1 = __importDefault(require("measury/fonts/AlibabaPuHuiTi-Regular"));
9
10
  const renderer_1 = require("../renderer");
10
11
  const font_1 = require("./font");
12
+ const is_browser_1 = require("./is-browser");
11
13
  const is_node_1 = require("./is-node");
14
+ let FONT_EXTEND_FACTOR = 1.01;
15
+ const setFontExtendFactor = (factor) => {
16
+ FONT_EXTEND_FACTOR = factor;
17
+ };
18
+ exports.setFontExtendFactor = setFontExtendFactor;
12
19
  if (is_node_1.isNode) {
13
20
  (0, measury_1.registerFont)(AlibabaPuHuiTi_Regular_1.default);
14
21
  }
15
- const canUseDOM = !is_node_1.isNode && typeof window !== 'undefined' && typeof document !== 'undefined';
16
22
  let canvasContext = null;
17
23
  let measureSpan = null;
18
24
  function getCanvasContext() {
19
- if (!canUseDOM)
20
- return null;
21
25
  if (canvasContext)
22
26
  return canvasContext;
23
27
  const canvas = document.createElement('canvas');
@@ -25,7 +29,7 @@ function getCanvasContext() {
25
29
  return canvasContext;
26
30
  }
27
31
  function getMeasureSpan() {
28
- if (!canUseDOM || !document.body)
32
+ if (!document.body)
29
33
  return null;
30
34
  if (measureSpan)
31
35
  return measureSpan;
@@ -97,12 +101,12 @@ function measureText(text = '', attrs) {
97
101
  lineHeight,
98
102
  };
99
103
  const fallback = () => (0, measury_1.measureText)(content, options);
100
- const metrics = canUseDOM
104
+ const metrics = (0, is_browser_1.isBrowser)()
101
105
  ? (measureTextInBrowser(content, options) ?? fallback())
102
106
  : fallback();
103
107
  // 额外添加 1% 宽高
104
108
  return {
105
- width: Math.ceil(metrics.width * 1.01),
106
- height: Math.ceil(metrics.height * 1.01),
109
+ width: Math.ceil(metrics.width * FONT_EXTEND_FACTOR),
110
+ height: Math.ceil(metrics.height * FONT_EXTEND_FACTOR),
107
111
  };
108
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antv/infographic",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "An Infographic Generation and Rendering Framework, bring words to life!",
5
5
  "keywords": [
6
6
  "antv",
@@ -1,5 +1,5 @@
1
1
  export { exportToPNGString } from './png';
2
- export { exportToSVGString } from './svg';
2
+ export { exportToSVG, exportToSVGString } from './svg';
3
3
  export type {
4
4
  ExportOptions,
5
5
  PNGExportOptions,
@@ -23,12 +23,17 @@ export async function exportToSVG(
23
23
  svg: SVGSVGElement,
24
24
  options: Omit<SVGExportOptions, 'type'> = {},
25
25
  ) {
26
- const { embedResources = true } = options;
26
+ const { embedResources = true, removeIds = false } = options;
27
27
  const clonedSVG = svg.cloneNode(true) as SVGSVGElement;
28
28
  const { width, height } = getViewBox(svg);
29
29
  setAttributes(clonedSVG, { width, height });
30
30
 
31
- await embedIcons(clonedSVG);
31
+ if (removeIds) {
32
+ inlineUseElements(clonedSVG);
33
+ inlineDefsReferences(clonedSVG);
34
+ } else {
35
+ await embedIcons(clonedSVG);
36
+ }
32
37
  await embedFonts(clonedSVG, embedResources);
33
38
 
34
39
  cleanSVG(clonedSVG);
@@ -62,6 +67,253 @@ function getDefs(svg: SVGSVGElement) {
62
67
  return _defs;
63
68
  }
64
69
 
70
+ function inlineUseElements(svg: SVGSVGElement) {
71
+ const uses = Array.from(svg.querySelectorAll<SVGUseElement>('use'));
72
+ if (!uses.length) return;
73
+
74
+ uses.forEach((use) => {
75
+ const href = getUseHref(use);
76
+ if (!href || !href.startsWith('#')) return;
77
+ const target = resolveUseTarget(svg, href);
78
+ if (!target || target === use) return;
79
+
80
+ const replacement = createInlineElement(use, target);
81
+ if (!replacement) return;
82
+ use.replaceWith(replacement);
83
+ });
84
+ }
85
+
86
+ function getUseHref(use: SVGUseElement) {
87
+ return use.getAttribute('href') ?? use.getAttribute('xlink:href');
88
+ }
89
+
90
+ function resolveUseTarget(svg: SVGSVGElement, href: string) {
91
+ const localTarget = svg.querySelector(href);
92
+ if (localTarget) return localTarget as SVGElement;
93
+ const docTarget = document.querySelector(href);
94
+ return docTarget as SVGElement | null;
95
+ }
96
+
97
+ function createInlineElement(use: SVGUseElement, target: SVGElement) {
98
+ const tag = target.tagName.toLowerCase();
99
+ if (tag === 'symbol') {
100
+ return materializeSymbol(use, target as SVGSymbolElement);
101
+ }
102
+ if (tag === 'svg') {
103
+ return materializeSVG(use, target as SVGSVGElement);
104
+ }
105
+ return materializeElement(use, target);
106
+ }
107
+
108
+ function materializeSymbol(use: SVGUseElement, symbol: SVGSymbolElement) {
109
+ const symbolClone = symbol.cloneNode(true) as SVGSymbolElement;
110
+ const svg = createElement<SVGSVGElement>('svg');
111
+
112
+ applyAttributes(svg, symbolClone, new Set(['id']));
113
+ applyAttributes(svg, use, new Set(['href', 'xlink:href']));
114
+
115
+ while (symbolClone.firstChild) {
116
+ svg.appendChild(symbolClone.firstChild);
117
+ }
118
+
119
+ return svg;
120
+ }
121
+
122
+ function materializeSVG(use: SVGUseElement, source: SVGSVGElement) {
123
+ const clone = source.cloneNode(true) as SVGSVGElement;
124
+ clone.removeAttribute('id');
125
+ applyAttributes(clone, use, new Set(['href', 'xlink:href']));
126
+ return clone;
127
+ }
128
+
129
+ function materializeElement(use: SVGUseElement, source: SVGElement) {
130
+ const clone = source.cloneNode(true) as SVGElement;
131
+ clone.removeAttribute('id');
132
+
133
+ const wrapper = createElement<SVGGElement>('g');
134
+ applyAttributes(
135
+ wrapper,
136
+ use,
137
+ new Set(['href', 'xlink:href', 'x', 'y', 'width', 'height', 'transform']),
138
+ );
139
+
140
+ const transform = buildUseTransform(use);
141
+ if (transform) {
142
+ wrapper.setAttribute('transform', transform);
143
+ }
144
+
145
+ wrapper.appendChild(clone);
146
+ return wrapper;
147
+ }
148
+
149
+ function buildUseTransform(use: SVGUseElement) {
150
+ const x = use.getAttribute('x');
151
+ const y = use.getAttribute('y');
152
+ const translate = x || y ? `translate(${x ?? 0} ${y ?? 0})` : '';
153
+ const transform = use.getAttribute('transform') ?? '';
154
+ if (translate && transform) return `${translate} ${transform}`;
155
+ return translate || transform || null;
156
+ }
157
+
158
+ function applyAttributes(
159
+ target: SVGElement,
160
+ source: SVGElement,
161
+ exclude: Set<string> = new Set(),
162
+ ) {
163
+ Array.from(source.attributes).forEach((attr) => {
164
+ if (exclude.has(attr.name)) return;
165
+ if (attr.name === 'style') {
166
+ mergeStyleAttribute(target, attr.value);
167
+ return;
168
+ }
169
+ if (attr.name === 'class') {
170
+ mergeClassAttribute(target, attr.value);
171
+ return;
172
+ }
173
+ target.setAttribute(attr.name, attr.value);
174
+ });
175
+ }
176
+
177
+ function mergeStyleAttribute(target: SVGElement, value: string) {
178
+ const current = target.getAttribute('style');
179
+ if (!current) {
180
+ target.setAttribute('style', value);
181
+ return;
182
+ }
183
+ const separator = current.trim().endsWith(';') ? '' : ';';
184
+ target.setAttribute('style', `${current}${separator}${value}`);
185
+ }
186
+
187
+ function mergeClassAttribute(target: SVGElement, value: string) {
188
+ const current = target.getAttribute('class');
189
+ if (!current) {
190
+ target.setAttribute('class', value);
191
+ return;
192
+ }
193
+ target.setAttribute('class', `${current} ${value}`.trim());
194
+ }
195
+
196
+ const urlRefRegex = /url\(\s*['"]?#([^'")\s]+)['"]?\s*\)/g;
197
+ function inlineDefsReferences(svg: SVGSVGElement) {
198
+ const referencedIds = collectReferencedIds(svg);
199
+ if (referencedIds.size === 0) {
200
+ removeDefs(svg);
201
+ return;
202
+ }
203
+
204
+ const defsDataUrl = createDefsDataUrl(svg, referencedIds);
205
+ if (!defsDataUrl) return;
206
+
207
+ traverse(svg, (node) => {
208
+ if (node.tagName.toLowerCase() === 'defs') return false;
209
+ const attrs = Array.from(node.attributes);
210
+ attrs.forEach((attr) => {
211
+ const value = attr.value;
212
+ if (!value.includes('url(')) return;
213
+ const updated = value.replace(urlRefRegex, (_match, id) => {
214
+ const encodedId = encodeURIComponent(id);
215
+ return `url("${defsDataUrl}#${encodedId}")`;
216
+ });
217
+ if (updated !== value) node.setAttribute(attr.name, updated);
218
+ });
219
+ });
220
+
221
+ removeDefs(svg);
222
+ }
223
+
224
+ function collectReferencedIds(svg: SVGSVGElement) {
225
+ const ids = new Set<string>();
226
+ traverse(svg, (node) => {
227
+ if (node.tagName.toLowerCase() === 'defs') return false;
228
+ collectIdsFromAttributes(node, (id) => ids.add(id));
229
+ });
230
+ return ids;
231
+ }
232
+
233
+ function collectIdsFromAttributes(
234
+ node: SVGElement,
235
+ addId: (id: string) => void,
236
+ ) {
237
+ for (const attr of Array.from(node.attributes)) {
238
+ const value = attr.value;
239
+ if (value.includes('url(')) {
240
+ for (const match of value.matchAll(urlRefRegex)) {
241
+ if (match[1]) addId(match[1]);
242
+ }
243
+ }
244
+ if (
245
+ (attr.name === 'href' || attr.name === 'xlink:href') &&
246
+ value[0] === '#'
247
+ ) {
248
+ addId(value.slice(1));
249
+ }
250
+ }
251
+ }
252
+
253
+ function createDefsDataUrl(svg: SVGSVGElement, ids: Set<string>) {
254
+ if (ids.size === 0) return null;
255
+
256
+ const collected = collectDefElements(svg, ids);
257
+ if (collected.size === 0) return null;
258
+
259
+ const defsSvg = createElement<SVGSVGElement>('svg', {
260
+ xmlns: 'http://www.w3.org/2000/svg',
261
+ 'xmlns:xlink': 'http://www.w3.org/1999/xlink',
262
+ });
263
+ const defs = createElement<SVGDefsElement>('defs');
264
+
265
+ collected.forEach((node) => {
266
+ defs.appendChild(node.cloneNode(true));
267
+ });
268
+
269
+ if (!defs.children.length) return null;
270
+ defsSvg.appendChild(defs);
271
+
272
+ const serialized = new XMLSerializer().serializeToString(defsSvg);
273
+ return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(serialized);
274
+ }
275
+
276
+ function collectDefElements(svg: SVGSVGElement, ids: Set<string>) {
277
+ const collected = new Map<string, SVGElement>();
278
+ const queue = Array.from(ids);
279
+ const queued = new Set(queue);
280
+ const visited = new Set<string>();
281
+ const enqueue = (id: string) => {
282
+ if (visited.has(id) || queued.has(id)) return;
283
+ queue.push(id);
284
+ queued.add(id);
285
+ };
286
+
287
+ while (queue.length) {
288
+ const id = queue.shift()!;
289
+ if (visited.has(id)) continue;
290
+ visited.add(id);
291
+
292
+ const selector = `#${escapeCssId(id)}`;
293
+ const target = svg.querySelector(selector);
294
+ if (!target) continue;
295
+ collected.set(id, target as SVGElement);
296
+
297
+ traverse(target as SVGElement, (node) => {
298
+ collectIdsFromAttributes(node, enqueue);
299
+ });
300
+ }
301
+
302
+ return collected;
303
+ }
304
+
305
+ function escapeCssId(id: string) {
306
+ if (globalThis.CSS && typeof globalThis.CSS.escape === 'function') {
307
+ return globalThis.CSS.escape(id);
308
+ }
309
+ return id.replace(/([!"#$%&'()*+,./:;<=>?@[\]^`{|}~])/g, '\\$1');
310
+ }
311
+
312
+ function removeDefs(svg: SVGSVGElement) {
313
+ const defsList = Array.from(svg.querySelectorAll('defs'));
314
+ defsList.forEach((defs) => defs.remove());
315
+ }
316
+
65
317
  function cleanSVG(svg: SVGSVGElement) {
66
318
  removeBtnGroup(svg);
67
319
  removeTransientContainer(svg);
@@ -5,6 +5,11 @@ export interface SVGExportOptions {
5
5
  * @default true
6
6
  */
7
7
  embedResources?: boolean;
8
+ /**
9
+ * 是否移除 id 依赖(展开 <use> 并内联 defs 引用)
10
+ * @default false
11
+ */
12
+ removeIds?: boolean;
8
13
  }
9
14
 
10
15
  export interface PNGExportOptions {
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  ZoomWheel,
15
15
  } from './editor/interactions';
16
16
  export { EditBar, Plugin, ResizeElement } from './editor/plugins';
17
+ export { exportToSVG } from './exporter';
17
18
  export {
18
19
  Defs,
19
20
  Ellipse,
@@ -38,9 +39,8 @@ export {
38
39
  getFont,
39
40
  getFonts,
40
41
  getPalette,
41
- getPalettes,
42
42
  getPaletteColor,
43
-
43
+ getPalettes,
44
44
  registerFont,
45
45
  registerPalette,
46
46
  registerPattern,
@@ -51,9 +51,14 @@ export { Infographic } from './runtime';
51
51
  export { parseSyntax } from './syntax';
52
52
  export { getTemplate, getTemplates, registerTemplate } from './templates';
53
53
  export { getTheme, getThemes, registerTheme } from './themes';
54
- export { parseSVG } from './utils';
54
+ export { parseSVG, setFontExtendFactor } from './utils';
55
55
 
56
56
  export type { EditBarOptions } from './editor';
57
+ export type {
58
+ ExportOptions,
59
+ PNGExportOptions,
60
+ SVGExportOptions,
61
+ } from './exporter';
57
62
  export type {
58
63
  Bounds,
59
64
  ComponentType,
@@ -1,3 +1,7 @@
1
+ import {
2
+ getSvgLoadPromise,
3
+ trackSvgLoadPromise,
4
+ } from '../../resource/load-tracker';
1
5
  import { join, normalizeFontWeightName, splitFontFamily } from '../../utils';
2
6
  import { getFont, getFonts } from './registry';
3
7
 
@@ -36,6 +40,66 @@ const FONT_LOAD_MAP = new WeakMap<
36
40
  HTMLHeadElement,
37
41
  Map<string, HTMLLinkElement>
38
42
  >();
43
+ const FONT_PROMISE_MAP = new WeakMap<
44
+ HTMLHeadElement,
45
+ Map<string, Promise<void>>
46
+ >();
47
+
48
+ function trackFontPromise(
49
+ target: HTMLHeadElement,
50
+ id: string,
51
+ promise: Promise<void>,
52
+ ): Promise<void> {
53
+ let map = FONT_PROMISE_MAP.get(target);
54
+ if (!map) {
55
+ map = new Map();
56
+ FONT_PROMISE_MAP.set(target, map);
57
+ }
58
+
59
+ map.set(id, promise);
60
+
61
+ promise.finally(() => {
62
+ const map = FONT_PROMISE_MAP.get(target);
63
+ if (!map) return;
64
+ if (map.get(id) === promise) map.delete(id);
65
+ if (map.size === 0) FONT_PROMISE_MAP.delete(target);
66
+ });
67
+
68
+ return promise;
69
+ }
70
+
71
+ function isLinkLoaded(link: HTMLLinkElement): boolean {
72
+ if (link.dataset.infographicFontLoaded === 'true') return true;
73
+ try {
74
+ return !!link.sheet;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ function getFontLoadPromise(
81
+ target: HTMLHeadElement,
82
+ id: string,
83
+ link?: HTMLLinkElement,
84
+ ): Promise<void> {
85
+ const existing = FONT_PROMISE_MAP.get(target)?.get(id);
86
+ if (existing) return existing;
87
+
88
+ if (!link || isLinkLoaded(link)) {
89
+ return trackFontPromise(target, id, Promise.resolve());
90
+ }
91
+
92
+ const promise = new Promise<void>((resolve) => {
93
+ const done = () => {
94
+ link.dataset.infographicFontLoaded = 'true';
95
+ resolve();
96
+ };
97
+ link.addEventListener('load', done, { once: true });
98
+ link.addEventListener('error', done, { once: true });
99
+ });
100
+
101
+ return trackFontPromise(target, id, promise);
102
+ }
39
103
 
40
104
  export function loadFont(svg: SVGSVGElement, font: string) {
41
105
  const doc = svg.ownerDocument;
@@ -45,18 +109,27 @@ export function loadFont(svg: SVGSVGElement, font: string) {
45
109
  if (!FONT_LOAD_MAP.has(target)) FONT_LOAD_MAP.set(target, new Map());
46
110
  const map = FONT_LOAD_MAP.get(target)!;
47
111
 
48
- const links: HTMLLinkElement[] = [];
49
112
  const urls = getFontURLs(font);
113
+ if (!urls.length) return;
114
+
115
+ const links: HTMLLinkElement[] = [];
50
116
  urls.forEach((url) => {
51
117
  const id = `${font}-${url}`;
52
- if (map.has(id)) return;
53
-
54
- const link = doc.createElement('link');
55
- link.id = id;
56
- link.rel = 'stylesheet';
57
- link.href = url;
58
- links.push(link);
59
- map.set(id, link);
118
+ const promiseKey = `font:${id}`;
119
+ if (getSvgLoadPromise<void>(svg, promiseKey)) return;
120
+
121
+ let link = map.get(id);
122
+ if (!link) {
123
+ link = doc.createElement('link');
124
+ link.id = id;
125
+ link.rel = 'stylesheet';
126
+ link.href = url;
127
+ links.push(link);
128
+ map.set(id, link);
129
+ }
130
+
131
+ const promise = getFontLoadPromise(target, id, link);
132
+ trackSvgLoadPromise(svg, promiseKey, promise);
60
133
  });
61
134
 
62
135
  if (!links.length) return;
@@ -30,7 +30,7 @@ export function applyGradientStyle(
30
30
  }
31
31
  });
32
32
 
33
- const id = getGradientId(config);
33
+ const id = getGradientId(actualConfig);
34
34
 
35
35
  if (type === 'linear-gradient') {
36
36
  const { angle = 0 } = actualConfig;
@@ -1,4 +1,4 @@
1
- export { loadResource } from './loader';
1
+ export { getSvgLoadPromises, loadResource, waitForSvgLoads } from './loader';
2
2
  export * from './loaders';
3
3
  export { registerResourceLoader } from './registry';
4
4
  export type * from './types';
@@ -0,0 +1,51 @@
1
+ type SvgLoadPromise = Promise<unknown>;
2
+
3
+ const SVG_LOAD_PROMISE_MAP = new WeakMap<
4
+ SVGSVGElement,
5
+ Map<string, SvgLoadPromise>
6
+ >();
7
+
8
+ export function getSvgLoadPromises(svg: SVGSVGElement): SvgLoadPromise[] {
9
+ const map = SVG_LOAD_PROMISE_MAP.get(svg);
10
+ return map ? Array.from(map.values()) : [];
11
+ }
12
+
13
+ export function getSvgLoadPromise<T = unknown>(
14
+ svg: SVGSVGElement,
15
+ key: string,
16
+ ): Promise<T> | undefined {
17
+ return SVG_LOAD_PROMISE_MAP.get(svg)?.get(key) as Promise<T> | undefined;
18
+ }
19
+
20
+ export function trackSvgLoadPromise<T>(
21
+ svg: SVGSVGElement,
22
+ key: string,
23
+ promise: Promise<T>,
24
+ ): Promise<T> {
25
+ let map = SVG_LOAD_PROMISE_MAP.get(svg);
26
+ if (!map) {
27
+ map = new Map();
28
+ SVG_LOAD_PROMISE_MAP.set(svg, map);
29
+ }
30
+
31
+ map.set(key, promise as SvgLoadPromise);
32
+
33
+ promise.finally(() => {
34
+ const map = SVG_LOAD_PROMISE_MAP.get(svg);
35
+ if (!map) return;
36
+ if (map.get(key) === promise) map.delete(key);
37
+ if (map.size === 0) SVG_LOAD_PROMISE_MAP.delete(svg);
38
+ });
39
+
40
+ return promise;
41
+ }
42
+
43
+ export async function waitForSvgLoads(svg: SVGSVGElement): Promise<void> {
44
+ await Promise.resolve();
45
+ while (true) {
46
+ const promises = getSvgLoadPromises(svg);
47
+ if (!promises.length) break;
48
+ await Promise.allSettled(promises);
49
+ await Promise.resolve();
50
+ }
51
+ }