From e7093b8379b7345896cad2b66fde16ae84f31c94 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 16 Apr 2026 06:38:01 +0000
Subject: [PATCH 1/5] Initial plan
From 70471bf3b023695084e6419eb29fd001c0ca31d4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 16 Apr 2026 06:42:46 +0000
Subject: [PATCH 2/5] refactor: apply code quality improvements across multiple
files
- Rename `options` state to `previewCode` in Playground.tsx for clarity
- Add ITEM_POSITION_H/ITEM_POSITION_V constants in chart-line.tsx
- Fix gradient area color to use last colorStopsData color instead of colorPrimary
- Add NaN check for width/height in svg.ts getExportViewBox
- Add braces around single-statement if in embedIcons
- Add empty id guard in collectDefElements
- Replace simple regex CSS escape fallback with full CSS.escape algorithm
- Remove redundant condition in search.ts loadSearchResource
- Use charCodeAt comparison in getCommonPrefixLength for performance
- Extract isBetterMatch helper function in templates/utils.ts
Agent-Logs-Url: https://github.com/antvis/Infographic/sessions/289607cf-6b2e-4c1c-b79d-ec538a1b098c
Co-authored-by: Aarebecca <25787943+Aarebecca@users.noreply.github.com>
---
dev/src/Playground.tsx | 6 +-
src/designs/structures/chart-line.tsx | 13 ++++-
src/exporter/svg.ts | 83 ++++++++++++++++++++++++++-
src/resource/loaders/search.ts | 3 -
src/templates/utils.ts | 36 ++++++++++--
5 files changed, 123 insertions(+), 18 deletions(-)
diff --git a/dev/src/Playground.tsx b/dev/src/Playground.tsx
index 949b6d01a..76230a5c1 100644
--- a/dev/src/Playground.tsx
+++ b/dev/src/Playground.tsx
@@ -30,12 +30,12 @@ export const Playground = () => {
const saved = getStoredValues<{ code: string }>(STORAGE_KEY);
return saved?.code || DEFAULT_CODE;
});
- const [options, setOptions] = useState(code);
+ const [previewCode, setPreviewCode] = useState(code);
// Debounce: update preview 500ms after last edit, also persist
useEffect(() => {
const timer = setTimeout(() => {
- setOptions(code);
+ setPreviewCode(code);
setStoredValues(STORAGE_KEY, { code });
}, 500);
return () => clearTimeout(timer);
@@ -86,7 +86,7 @@ export const Playground = () => {
-
+
diff --git a/src/designs/structures/chart-line.tsx b/src/designs/structures/chart-line.tsx
index 7fce86661..d1536d793 100644
--- a/src/designs/structures/chart-line.tsx
+++ b/src/designs/structures/chart-line.tsx
@@ -51,12 +51,15 @@ export const ChartLine: ComponentType = (props) => {
const [paddingTop, paddingRight, paddingBottom, paddingLeft] =
parsePadding(padding);
+ const ITEM_POSITION_H = 'center' as const;
+ const ITEM_POSITION_V = 'normal' as const;
+
const itemProps = {
indexes: [0],
datum: items[0],
data,
- positionH: 'center',
- positionV: 'normal',
+ positionH: ITEM_POSITION_H,
+ positionV: ITEM_POSITION_V,
};
const sampleBounds = getElementBounds( );
const labelWidth = sampleBounds.width;
@@ -396,7 +399,11 @@ export const ChartLine: ComponentType = (props) => {
{areaStops}
-
+
{gridElements}
diff --git a/src/exporter/svg.ts b/src/exporter/svg.ts
index 2e3afeb21..65f87592c 100644
--- a/src/exporter/svg.ts
+++ b/src/exporter/svg.ts
@@ -26,7 +26,7 @@ function getExportViewBox(svg: SVGSVGElement) {
const width = parseAbsoluteLength(svg.getAttribute('width'));
const height = parseAbsoluteLength(svg.getAttribute('height'));
- if (width > 0 && height > 0) {
+ if (!Number.isNaN(width) && width > 0 && !Number.isNaN(height) && height > 0) {
return { x: 0, y: 0, width, height };
}
@@ -253,7 +253,9 @@ async function embedIcons(svg: SVGSVGElement) {
if (!existsSymbol) {
const symbolElement = document.querySelector(href);
- if (symbolElement) defs.appendChild(symbolElement.cloneNode(true));
+ if (symbolElement) {
+ defs.appendChild(symbolElement.cloneNode(true));
+ }
}
});
}
@@ -490,6 +492,10 @@ function collectDefElements(svg: SVGSVGElement, ids: Set) {
if (visited.has(id)) continue;
visited.add(id);
+ if (!id) {
+ continue;
+ }
+
const selector = `#${escapeCssId(id)}`;
const target = svg.querySelector(selector);
if (!target) continue;
@@ -507,7 +513,78 @@ function escapeCssId(id: string) {
if (globalThis.CSS && typeof globalThis.CSS.escape === 'function') {
return globalThis.CSS.escape(id);
}
- return id.replace(/([!"#$%&'()*+,./:;<=>?@[\]^`{|}~])/g, '\\$1');
+
+ // Fallback implementation based on the CSS.escape algorithm
+ const cssEscape = (value: string): string => {
+ const string = String(value);
+ const length = string.length;
+ let result = '';
+
+ if (length === 0) {
+ return '';
+ }
+
+ for (let i = 0; i < length; i++) {
+ const codeUnit = string.charCodeAt(i);
+
+ // Null character
+ if (codeUnit === 0x0000) {
+ result += '\uFFFD';
+ continue;
+ }
+
+ // Control characters or DEL
+ if (
+ (codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
+ codeUnit === 0x007f
+ ) {
+ result += '\\' + codeUnit.toString(16) + ' ';
+ continue;
+ }
+
+ // Escape if first character is a digit
+ if (i === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) {
+ result += '\\' + string.charAt(i);
+ continue;
+ }
+
+ // Escape if second character is a digit and first is a hyphen
+ if (
+ i === 1 &&
+ codeUnit >= 0x0030 &&
+ codeUnit <= 0x0039 &&
+ string.charCodeAt(0) === 0x002d
+ ) {
+ result += '\\' + string.charAt(i);
+ continue;
+ }
+
+ // If the character is the first and is a hyphen followed by end of string, escape it
+ if (i === 0 && length === 1 && codeUnit === 0x002d) {
+ result += '\\' + string.charAt(i);
+ continue;
+ }
+
+ // Characters that are safe to use unescaped
+ if (
+ codeUnit >= 0x0030 && codeUnit <= 0x0039 || // 0-9
+ codeUnit >= 0x0041 && codeUnit <= 0x005a || // A-Z
+ codeUnit >= 0x0061 && codeUnit <= 0x007a || // a-z
+ codeUnit === 0x002d || // -
+ codeUnit === 0x005f // _
+ ) {
+ result += string.charAt(i);
+ continue;
+ }
+
+ // All other characters
+ result += '\\' + string.charAt(i);
+ }
+
+ return result;
+ };
+
+ return cssEscape(id);
}
function removeDefs(svg: SVGSVGElement) {
diff --git a/src/resource/loaders/search.ts b/src/resource/loaders/search.ts
index 0ed6c307c..65487197e 100644
--- a/src/resource/loaders/search.ts
+++ b/src/resource/loaders/search.ts
@@ -43,9 +43,6 @@ export async function loadSearchResource(query: string, format?: string) {
const svgText = commaIndex >= 0 ? result.slice(commaIndex + 1) : result;
return loadSVGResource(svgText);
}
- if (mimeType === 'image/svg+xml' && format === 'svg' && isBase64) {
- return loadImageBase64Resource(result);
- }
return loadImageBase64Resource(result);
}
diff --git a/src/templates/utils.ts b/src/templates/utils.ts
index 6f1ab465c..7766e3ffa 100644
--- a/src/templates/utils.ts
+++ b/src/templates/utils.ts
@@ -43,13 +43,34 @@ function getCommonPrefixLength(source: string, target: string): number {
const limit = Math.min(source.length, target.length);
let index = 0;
- while (index < limit && source[index] === target[index]) {
+ while (
+ index < limit &&
+ source.charCodeAt(index) === target.charCodeAt(index)
+ ) {
index += 1;
}
return index;
}
+function isBetterMatch(
+ bestMatch: string | undefined,
+ bestDistance: number,
+ bestPrefixLength: number,
+ candidateKey: string,
+ candidateDistance: number,
+ candidatePrefixLength: number,
+): boolean {
+ return (
+ candidateDistance < bestDistance ||
+ (candidateDistance === bestDistance &&
+ candidatePrefixLength > bestPrefixLength) ||
+ (candidateDistance === bestDistance &&
+ candidatePrefixLength === bestPrefixLength &&
+ (!bestMatch || candidateKey < bestMatch))
+ );
+}
+
export function findClosestTemplateKey(
type: string,
keys: Iterable,
@@ -71,11 +92,14 @@ export function findClosestTemplateKey(
const prefixLength = getCommonPrefixLength(normalizedType, normalizedKey);
if (
- distance < bestDistance ||
- (distance === bestDistance && prefixLength > bestPrefixLength) ||
- (distance === bestDistance &&
- prefixLength === bestPrefixLength &&
- (!bestMatch || key < bestMatch))
+ isBetterMatch(
+ bestMatch,
+ bestDistance,
+ bestPrefixLength,
+ key,
+ distance,
+ prefixLength,
+ )
) {
bestMatch = key;
bestDistance = distance;
From 22eb36341e8d9a0b4edefa8da517f811e63c4615 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 16 Apr 2026 06:45:29 +0000
Subject: [PATCH 3/5] fix: move empty id check before visited set, add
parentheses for operator precedence
- Move !id guard before visited.add() to prevent empty strings being added
- Add parentheses around range checks in CSS escape fallback for correct precedence
Agent-Logs-Url: https://github.com/antvis/Infographic/sessions/289607cf-6b2e-4c1c-b79d-ec538a1b098c
Co-authored-by: Aarebecca <25787943+Aarebecca@users.noreply.github.com>
---
src/exporter/svg.ts | 11 ++++-------
1 file changed, 4 insertions(+), 7 deletions(-)
diff --git a/src/exporter/svg.ts b/src/exporter/svg.ts
index 65f87592c..48969a4ee 100644
--- a/src/exporter/svg.ts
+++ b/src/exporter/svg.ts
@@ -489,13 +489,10 @@ function collectDefElements(svg: SVGSVGElement, ids: Set) {
while (queue.length) {
const id = queue.shift()!;
+ if (!id) continue;
if (visited.has(id)) continue;
visited.add(id);
- if (!id) {
- continue;
- }
-
const selector = `#${escapeCssId(id)}`;
const target = svg.querySelector(selector);
if (!target) continue;
@@ -567,9 +564,9 @@ function escapeCssId(id: string) {
// Characters that are safe to use unescaped
if (
- codeUnit >= 0x0030 && codeUnit <= 0x0039 || // 0-9
- codeUnit >= 0x0041 && codeUnit <= 0x005a || // A-Z
- codeUnit >= 0x0061 && codeUnit <= 0x007a || // a-z
+ (codeUnit >= 0x0030 && codeUnit <= 0x0039) || // 0-9
+ (codeUnit >= 0x0041 && codeUnit <= 0x005a) || // A-Z
+ (codeUnit >= 0x0061 && codeUnit <= 0x007a) || // a-z
codeUnit === 0x002d || // -
codeUnit === 0x005f // _
) {
From 0013094db76de5af06623d82ff42f7b36c4ca5ce Mon Sep 17 00:00:00 2001
From: Aaron
Date: Thu, 16 Apr 2026 16:07:36 +0800
Subject: [PATCH 4/5] fix: address review comments in svg export
---
.../designs/structures/chart-line.test.tsx | 38 ++++++
__tests__/unit/exporter/svg.test.ts | 52 +++++++
src/designs/structures/chart-line.tsx | 11 +-
src/exporter/svg.ts | 128 +++++++++---------
4 files changed, 158 insertions(+), 71 deletions(-)
create mode 100644 __tests__/unit/designs/structures/chart-line.test.tsx
diff --git a/__tests__/unit/designs/structures/chart-line.test.tsx b/__tests__/unit/designs/structures/chart-line.test.tsx
new file mode 100644
index 000000000..a68309742
--- /dev/null
+++ b/__tests__/unit/designs/structures/chart-line.test.tsx
@@ -0,0 +1,38 @@
+/** @jsxImportSource ../../../../src */
+import { describe, expect, it } from 'vitest';
+import type { ComponentType, ParsedInfographicOptions } from '../../../../src';
+import { Rect, renderSVG } from '../../../../src';
+import type { BaseItemProps } from '../../../../src/designs/items';
+import { ChartLine } from '../../../../src/designs/structures/chart-line';
+import type { ParsedData } from '../../../../src/types';
+import { minifySvg } from '../../../utils';
+
+const Item: ComponentType<
+ Omit &
+ Partial>
+> = ({ x = 0, y = 0 }) => ;
+
+describe('ChartLine', () => {
+ it('renders a single terminal area stop for the fading gradient', () => {
+ const data = {
+ items: [{ value: 10 }, { value: 20 }, { value: 15 }],
+ xTitle: 'Month',
+ yTitle: 'Value',
+ } as ParsedData;
+ const options = {
+ data,
+ themeConfig: {
+ colorBg: '#ffffff',
+ colorPrimary: '#1677ff',
+ },
+ } as ParsedInfographicOptions;
+
+ const svg = minifySvg(
+ renderSVG(
+ ,
+ ),
+ );
+
+ expect(svg.match(/stop-opacity="0.04"/g) ?? []).toHaveLength(1);
+ });
+});
diff --git a/__tests__/unit/exporter/svg.test.ts b/__tests__/unit/exporter/svg.test.ts
index a8efc0197..63f8b9196 100644
--- a/__tests__/unit/exporter/svg.test.ts
+++ b/__tests__/unit/exporter/svg.test.ts
@@ -245,6 +245,58 @@ describe('exporter/svg', () => {
expect(exportedRect?.getAttribute('fill')).not.toContain('url(#');
});
+ it.each(['1grad', '-1grad'])(
+ 'inlines defs references for ids requiring CSS.escape fallback (%s)',
+ async (gradientId) => {
+ const originalCSS = globalThis.CSS;
+ Object.defineProperty(globalThis, 'CSS', {
+ configurable: true,
+ value: undefined,
+ });
+
+ try {
+ const defs = document.createElementNS(svgNS, 'defs');
+ const gradient = document.createElementNS(svgNS, 'linearGradient');
+ gradient.setAttribute('id', gradientId);
+ const stop1 = document.createElementNS(svgNS, 'stop');
+ stop1.setAttribute('offset', '0');
+ stop1.setAttribute('stop-color', '#fff');
+ const stop2 = document.createElementNS(svgNS, 'stop');
+ stop2.setAttribute('offset', '1');
+ stop2.setAttribute('stop-color', '#000');
+ gradient.appendChild(stop1);
+ gradient.appendChild(stop2);
+ defs.appendChild(gradient);
+
+ const svg = document.createElementNS(svgNS, 'svg');
+ svg.setAttribute('viewBox', '0 0 10 10');
+ svg.appendChild(defs);
+
+ const rect = document.createElementNS(svgNS, 'rect');
+ rect.setAttribute('width', '10');
+ rect.setAttribute('height', '10');
+ rect.setAttribute('fill', `url(#${gradientId})`);
+ svg.appendChild(rect);
+
+ const exported = await exportToSVG(svg, { removeIds: true });
+
+ const exportedRect = exported.querySelector('rect');
+ expect(exported.querySelector('defs')).toBeNull();
+ expect(exportedRect?.getAttribute('fill')).toContain(
+ 'data:image/svg+xml',
+ );
+ expect(exportedRect?.getAttribute('fill')).toContain(
+ `#${encodeURIComponent(gradientId)}`,
+ );
+ } finally {
+ Object.defineProperty(globalThis, 'CSS', {
+ configurable: true,
+ value: originalCSS,
+ });
+ }
+ },
+ );
+
it('converts foreignObject overflow measurements from client pixels to svg units', async () => {
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('viewBox', '0 0 100 100');
diff --git a/src/designs/structures/chart-line.tsx b/src/designs/structures/chart-line.tsx
index d1536d793..1460cb36a 100644
--- a/src/designs/structures/chart-line.tsx
+++ b/src/designs/structures/chart-line.tsx
@@ -18,6 +18,9 @@ export interface ChartLineProps extends BaseStructureProps {
valueFormatter?: (value: number, datum: ItemDatum) => string;
}
+const ITEM_POSITION_H = 'center' as const;
+const ITEM_POSITION_V = 'normal' as const;
+
export const ChartLine: ComponentType = (props) => {
const {
Title,
@@ -51,9 +54,6 @@ export const ChartLine: ComponentType = (props) => {
const [paddingTop, paddingRight, paddingBottom, paddingLeft] =
parsePadding(padding);
- const ITEM_POSITION_H = 'center' as const;
- const ITEM_POSITION_V = 'normal' as const;
-
const itemProps = {
indexes: [0],
datum: items[0],
@@ -399,11 +399,6 @@ export const ChartLine: ComponentType = (props) => {
{areaStops}
-
{gridElements}
diff --git a/src/exporter/svg.ts b/src/exporter/svg.ts
index 48969a4ee..ec349ee7d 100644
--- a/src/exporter/svg.ts
+++ b/src/exporter/svg.ts
@@ -26,7 +26,12 @@ function getExportViewBox(svg: SVGSVGElement) {
const width = parseAbsoluteLength(svg.getAttribute('width'));
const height = parseAbsoluteLength(svg.getAttribute('height'));
- if (!Number.isNaN(width) && width > 0 && !Number.isNaN(height) && height > 0) {
+ if (
+ !Number.isNaN(width) &&
+ width > 0 &&
+ !Number.isNaN(height) &&
+ height > 0
+ ) {
return { x: 0, y: 0, width, height };
}
@@ -506,80 +511,77 @@ function collectDefElements(svg: SVGSVGElement, ids: Set) {
return collected;
}
-function escapeCssId(id: string) {
- if (globalThis.CSS && typeof globalThis.CSS.escape === 'function') {
- return globalThis.CSS.escape(id);
+// Fallback implementation based on the CSS.escape algorithm
+function cssEscape(value: string): string {
+ const string = String(value);
+ const length = string.length;
+ let result = '';
+
+ if (length === 0) {
+ return '';
}
- // Fallback implementation based on the CSS.escape algorithm
- const cssEscape = (value: string): string => {
- const string = String(value);
- const length = string.length;
- let result = '';
+ for (let i = 0; i < length; i++) {
+ const codeUnit = string.charCodeAt(i);
- if (length === 0) {
- return '';
+ // Null character
+ if (codeUnit === 0x0000) {
+ result += '\uFFFD';
+ continue;
}
- for (let i = 0; i < length; i++) {
- const codeUnit = string.charCodeAt(i);
-
- // Null character
- if (codeUnit === 0x0000) {
- result += '\uFFFD';
- continue;
- }
+ // Control characters or DEL
+ if ((codeUnit >= 0x0001 && codeUnit <= 0x001f) || codeUnit === 0x007f) {
+ result += '\\' + codeUnit.toString(16) + ' ';
+ continue;
+ }
- // Control characters or DEL
- if (
- (codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
- codeUnit === 0x007f
- ) {
- result += '\\' + codeUnit.toString(16) + ' ';
- continue;
- }
+ // Escape if first character is a digit
+ if (i === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) {
+ result += '\\' + codeUnit.toString(16) + ' ';
+ continue;
+ }
- // Escape if first character is a digit
- if (i === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) {
- result += '\\' + string.charAt(i);
- continue;
- }
+ // Escape if second character is a digit and first is a hyphen
+ if (
+ i === 1 &&
+ codeUnit >= 0x0030 &&
+ codeUnit <= 0x0039 &&
+ string.charCodeAt(0) === 0x002d
+ ) {
+ result += '\\' + codeUnit.toString(16) + ' ';
+ continue;
+ }
- // Escape if second character is a digit and first is a hyphen
- if (
- i === 1 &&
- codeUnit >= 0x0030 &&
- codeUnit <= 0x0039 &&
- string.charCodeAt(0) === 0x002d
- ) {
- result += '\\' + string.charAt(i);
- continue;
- }
+ // If the character is the first and is a hyphen followed by end of string, escape it
+ if (i === 0 && length === 1 && codeUnit === 0x002d) {
+ result += '\\' + string.charAt(i);
+ continue;
+ }
- // If the character is the first and is a hyphen followed by end of string, escape it
- if (i === 0 && length === 1 && codeUnit === 0x002d) {
- result += '\\' + string.charAt(i);
- continue;
- }
+ // Characters that are safe to use unescaped
+ if (
+ (codeUnit >= 0x0030 && codeUnit <= 0x0039) || // 0-9
+ (codeUnit >= 0x0041 && codeUnit <= 0x005a) || // A-Z
+ (codeUnit >= 0x0061 && codeUnit <= 0x007a) || // a-z
+ codeUnit === 0x002d || // -
+ codeUnit === 0x005f // _
+ ) {
+ result += string.charAt(i);
+ continue;
+ }
- // Characters that are safe to use unescaped
- if (
- (codeUnit >= 0x0030 && codeUnit <= 0x0039) || // 0-9
- (codeUnit >= 0x0041 && codeUnit <= 0x005a) || // A-Z
- (codeUnit >= 0x0061 && codeUnit <= 0x007a) || // a-z
- codeUnit === 0x002d || // -
- codeUnit === 0x005f // _
- ) {
- result += string.charAt(i);
- continue;
- }
+ // All other characters
+ result += '\\' + string.charAt(i);
+ }
- // All other characters
- result += '\\' + string.charAt(i);
- }
+ return result;
+}
- return result;
- };
+function escapeCssId(id: string) {
+ if (globalThis.CSS && typeof globalThis.CSS.escape === 'function') {
+ return globalThis.CSS.escape(id);
+ }
return cssEscape(id);
}
From 8bf7c1ea4b0c583d364f53ba5931469864ea1d0c Mon Sep 17 00:00:00 2001
From: Aaron
Date: Thu, 16 Apr 2026 16:21:03 +0800
Subject: [PATCH 5/5] fix: handle unicode ids in css escape fallback
---
__tests__/unit/exporter/svg.test.ts | 2 +-
src/exporter/svg.ts | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/__tests__/unit/exporter/svg.test.ts b/__tests__/unit/exporter/svg.test.ts
index 63f8b9196..8c540537d 100644
--- a/__tests__/unit/exporter/svg.test.ts
+++ b/__tests__/unit/exporter/svg.test.ts
@@ -245,7 +245,7 @@ describe('exporter/svg', () => {
expect(exportedRect?.getAttribute('fill')).not.toContain('url(#');
});
- it.each(['1grad', '-1grad'])(
+ it.each(['1grad', '-1grad', '💡grad'])(
'inlines defs references for ids requiring CSS.escape fallback (%s)',
async (gradientId) => {
const originalCSS = globalThis.CSS;
diff --git a/src/exporter/svg.ts b/src/exporter/svg.ts
index ec349ee7d..74b719811 100644
--- a/src/exporter/svg.ts
+++ b/src/exporter/svg.ts
@@ -561,6 +561,7 @@ function cssEscape(value: string): string {
// Characters that are safe to use unescaped
if (
+ codeUnit >= 0x0080 ||
(codeUnit >= 0x0030 && codeUnit <= 0x0039) || // 0-9
(codeUnit >= 0x0041 && codeUnit <= 0x005a) || // A-Z
(codeUnit >= 0x0061 && codeUnit <= 0x007a) || // a-z