Skip to content

Commit 9022d83

Browse files
authored
refactor/optimize status (#138)
* fix(renderer): fix invalid gradient color render * refactor: optimize exportToSVG to support removeIds * feat(resource): track resource load status and emit loaded event * chore: update version to 0.2.6 * fix: fix cr issues
1 parent 7eac9c1 commit 9022d83

File tree

16 files changed

+511
-29
lines changed

16 files changed

+511
-29
lines changed

__tests__/unit/exporter/svg.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,70 @@ describe('exporter/svg', () => {
100100
expect(decoded).toContain('width="1"');
101101
expect(decoded).toContain('height="1"');
102102
});
103+
104+
it('inlines use elements when removeIds is enabled', async () => {
105+
const defs = document.createElementNS(svgNS, 'defs');
106+
const symbol = document.createElementNS(svgNS, 'symbol');
107+
symbol.id = 'icon-star';
108+
symbol.setAttribute('viewBox', '0 0 10 10');
109+
const path = document.createElementNS(svgNS, 'path');
110+
path.setAttribute('d', 'M0 0 L10 0 L5 10 Z');
111+
symbol.appendChild(path);
112+
defs.appendChild(symbol);
113+
document.body.appendChild(defs);
114+
115+
const svg = document.createElementNS(svgNS, 'svg');
116+
svg.setAttribute('viewBox', '0 0 10 10');
117+
118+
const use = document.createElementNS(svgNS, 'use');
119+
use.setAttribute('href', '#icon-star');
120+
use.setAttribute('x', '1');
121+
use.setAttribute('y', '2');
122+
use.setAttribute('width', '8');
123+
use.setAttribute('height', '8');
124+
svg.appendChild(use);
125+
126+
const exported = await exportToSVG(svg, { removeIds: true });
127+
128+
expect(exported.querySelector('use')).toBeNull();
129+
const inlined = exported.querySelector('svg > svg');
130+
expect(inlined).toBeTruthy();
131+
expect(inlined?.getAttribute('x')).toBe('1');
132+
expect(inlined?.getAttribute('y')).toBe('2');
133+
expect(inlined?.getAttribute('width')).toBe('8');
134+
expect(inlined?.getAttribute('height')).toBe('8');
135+
expect(inlined?.querySelector('path')).toBeTruthy();
136+
});
137+
138+
it('inlines defs references when removeIds is enabled', async () => {
139+
const defs = document.createElementNS(svgNS, 'defs');
140+
const gradient = document.createElementNS(svgNS, 'linearGradient');
141+
gradient.setAttribute('id', 'grad-1');
142+
const stop1 = document.createElementNS(svgNS, 'stop');
143+
stop1.setAttribute('offset', '0');
144+
stop1.setAttribute('stop-color', '#fff');
145+
const stop2 = document.createElementNS(svgNS, 'stop');
146+
stop2.setAttribute('offset', '1');
147+
stop2.setAttribute('stop-color', '#000');
148+
gradient.appendChild(stop1);
149+
gradient.appendChild(stop2);
150+
defs.appendChild(gradient);
151+
152+
const svg = document.createElementNS(svgNS, 'svg');
153+
svg.setAttribute('viewBox', '0 0 10 10');
154+
svg.appendChild(defs);
155+
156+
const rect = document.createElementNS(svgNS, 'rect');
157+
rect.setAttribute('width', '10');
158+
rect.setAttribute('height', '10');
159+
rect.setAttribute('fill', 'url(#grad-1)');
160+
svg.appendChild(rect);
161+
162+
const exported = await exportToSVG(svg, { removeIds: true });
163+
164+
const exportedRect = exported.querySelector('rect');
165+
expect(exported.querySelector('defs')).toBeNull();
166+
expect(exportedRect?.getAttribute('fill')).toContain('data:image/svg+xml');
167+
expect(exportedRect?.getAttribute('fill')).not.toContain('url(#');
168+
});
103169
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@antv/infographic",
3-
"version": "0.2.5",
3+
"version": "0.2.6",
44
"description": "An Infographic Generation and Rendering Framework, bring words to life!",
55
"keywords": [
66
"antv",

site/src/content/reference/infographic-api.en.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ Export the infographic as an image and receive a `data:` URL string. This method
9696
toDataURL(options?: ExportOptions): Promise<string>
9797
```
9898

99-
`options` (see [ExportOptions](/reference/infographic-types#export-options)) accepts `{type: 'svg'; embedResources?: boolean}` or `{type: 'png'; dpr?: number}`. Defaults to PNG when omitted.
99+
`options` (see [ExportOptions](/reference/infographic-types#export-options)) accepts `{type: 'svg'; embedResources?: boolean; removeIds?: boolean}` or `{type: 'png'; dpr?: number}`. Defaults to PNG when omitted.
100100

101101
**Example**:
102102

site/src/content/reference/infographic-api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ getTypes(): string
110110
toDataURL(options?: ExportOptions): Promise<string>
111111
```
112112

113-
`options`(见 [ExportOptions](/reference/infographic-types#export-options))支持 `{type: 'svg'; embedResources?: boolean}``{type: 'png'; dpr?: number}`,不传时默认导出 PNG。
113+
`options`(见 [ExportOptions](/reference/infographic-types#export-options))支持 `{type: 'svg'; embedResources?: boolean; removeIds?: boolean}``{type: 'png'; dpr?: number}`,不传时默认导出 PNG。
114114

115115
**示例:**
116116

site/src/content/reference/infographic-types.en.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type ExportOptions = SVGExportOptions | PNGExportOptions;
8282
| -------------- | ---------- | -------- | ------------------------------ |
8383
| type | `'svg'` | **Yes** | Export format |
8484
| embedResources | `boolean` | No | Inline remote resources (default `true`) |
85+
| removeIds | `boolean` | No | Remove id dependencies (default `false`) |
8586

8687
### PNGExportOptions {#png-export-options}
8788

site/src/content/reference/infographic-types.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type ExportOptions = SVGExportOptions | PNGExportOptions;
8282
| --------------- | --------- | ------ | ---------------------------- |
8383
| type | `'svg'` | **** | 导出类型标识 |
8484
| embedResources | `boolean` || 是否内嵌远程资源,默认 `true` |
85+
| removeIds | `boolean` || 是否移除 id 依赖,默认 `false` |
8586

8687
### PNGExportOptions {#png-export-options}
8788

src/exporter/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { exportToPNGString } from './png';
2-
export { exportToSVGString } from './svg';
2+
export { exportToSVG, exportToSVGString } from './svg';
33
export type {
44
ExportOptions,
55
PNGExportOptions,

src/exporter/svg.ts

Lines changed: 254 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,17 @@ export async function exportToSVG(
2323
svg: SVGSVGElement,
2424
options: Omit<SVGExportOptions, 'type'> = {},
2525
) {
26-
const { embedResources = true } = options;
26+
const { embedResources = true, removeIds = false } = options;
2727
const clonedSVG = svg.cloneNode(true) as SVGSVGElement;
2828
const { width, height } = getViewBox(svg);
2929
setAttributes(clonedSVG, { width, height });
3030

31-
await embedIcons(clonedSVG);
31+
if (removeIds) {
32+
inlineUseElements(clonedSVG);
33+
inlineDefsReferences(clonedSVG);
34+
} else {
35+
await embedIcons(clonedSVG);
36+
}
3237
await embedFonts(clonedSVG, embedResources);
3338

3439
cleanSVG(clonedSVG);
@@ -62,6 +67,253 @@ function getDefs(svg: SVGSVGElement) {
6267
return _defs;
6368
}
6469

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+
65317
function cleanSVG(svg: SVGSVGElement) {
66318
removeBtnGroup(svg);
67319
removeTransientContainer(svg);

0 commit comments

Comments
 (0)