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..8c540537d 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', '💡grad'])( + '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/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..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, @@ -55,8 +58,8 @@ export const ChartLine: ComponentType = (props) => { 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,6 @@ export const ChartLine: ComponentType = (props) => { {areaStops} - {gridElements} diff --git a/src/exporter/svg.ts b/src/exporter/svg.ts index 2e3afeb21..74b719811 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 (width > 0 && height > 0) { + if ( + !Number.isNaN(width) && + width > 0 && + !Number.isNaN(height) && + height > 0 + ) { return { x: 0, y: 0, width, height }; } @@ -253,7 +258,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)); + } } }); } @@ -487,6 +494,7 @@ function collectDefElements(svg: SVGSVGElement, ids: Set) { while (queue.length) { const id = queue.shift()!; + if (!id) continue; if (visited.has(id)) continue; visited.add(id); @@ -503,11 +511,80 @@ function collectDefElements(svg: SVGSVGElement, ids: Set) { return collected; } +// 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 ''; + } + + 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 += '\\' + 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 += '\\' + codeUnit.toString(16) + ' '; + 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 >= 0x0080 || + (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; +} + function escapeCssId(id: string) { if (globalThis.CSS && typeof globalThis.CSS.escape === 'function') { return globalThis.CSS.escape(id); } - return id.replace(/([!"#$%&'()*+,./:;<=>?@[\]^`{|}~])/g, '\\$1'); + + 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;