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