diff --git a/__tests__/unit/exporter/svg.test.ts b/__tests__/unit/exporter/svg.test.ts index 6342a251d..a8efc0197 100644 --- a/__tests__/unit/exporter/svg.test.ts +++ b/__tests__/unit/exporter/svg.test.ts @@ -9,6 +9,84 @@ vi.mock('../../../src/exporter/font', () => ({ const svgNS = 'http://www.w3.org/2000/svg'; +function mockRect( + element: Element, + { + left = 0, + top = 0, + width, + height, + }: { + left?: number; + top?: number; + width: number; + height: number; + }, +) { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + x: left, + y: top, + left, + top, + width, + height, + right: left + width, + bottom: top + height, + toJSON: () => ({}), + }) as DOMRect, + }); +} + +function mockSvgCoordinateSpace( + svg: SVGSVGElement, + { scaleX = 1, scaleY = 1 }: { scaleX?: number; scaleY?: number } = {}, +) { + const screenCTM = { + a: scaleX, + d: scaleY, + e: 0, + f: 0, + inverse() { + return { + a: 1 / scaleX, + d: 1 / scaleY, + e: 0, + f: 0, + }; + }, + }; + + Object.defineProperty(svg, 'getScreenCTM', { + configurable: true, + value: () => screenCTM, + }); + + Object.defineProperty(svg, 'createSVGPoint', { + configurable: true, + value: () => { + const point = { + x: 0, + y: 0, + matrixTransform(transform: { + a?: number; + d?: number; + e?: number; + f?: number; + }) { + return { + x: point.x * (transform.a ?? 1) + (transform.e ?? 0), + y: point.y * (transform.d ?? 1) + (transform.f ?? 0), + }; + }, + }; + return point; + }, + }); +} + describe('exporter/svg', () => { beforeEach(() => { document.body.innerHTML = ''; @@ -166,4 +244,79 @@ describe('exporter/svg', () => { expect(exportedRect?.getAttribute('fill')).toContain('data:image/svg+xml'); expect(exportedRect?.getAttribute('fill')).not.toContain('url(#'); }); + + 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'); + mockSvgCoordinateSpace(svg, { scaleX: 2, scaleY: 2 }); + + const foreignObject = document.createElementNS(svgNS, 'foreignObject'); + const span = document.createElement('span'); + span.style.alignItems = 'flex-end'; + Object.defineProperty(span, 'scrollHeight', { + configurable: true, + get: () => 300, + }); + foreignObject.appendChild(span); + svg.appendChild(foreignObject); + + mockRect(foreignObject, { left: 0, top: 0, width: 200, height: 200 }); + + const exported = await exportToSVG(svg); + + expect(exported.getAttribute('viewBox')).toBe('0 -50 100 150'); + expect(exported.getAttribute('width')).toBe('100'); + expect(exported.getAttribute('height')).toBe('150'); + }); + + it('expands exports for root svg elements without an explicit viewBox', async () => { + const svg = document.createElementNS(svgNS, 'svg'); + svg.setAttribute('width', '200'); + svg.setAttribute('height', '100'); + mockSvgCoordinateSpace(svg); + + const foreignObject = document.createElementNS(svgNS, 'foreignObject'); + const span = document.createElement('span'); + span.style.alignItems = 'flex-end'; + Object.defineProperty(span, 'scrollHeight', { + configurable: true, + get: () => 150, + }); + foreignObject.appendChild(span); + svg.appendChild(foreignObject); + + mockRect(foreignObject, { left: 0, top: 0, width: 200, height: 100 }); + + const exported = await exportToSVG(svg); + + expect(exported.getAttribute('viewBox')).toBe('0 -50 200 150'); + expect(exported.getAttribute('width')).toBe('200'); + expect(exported.getAttribute('height')).toBe('150'); + }); + + it('uses rendered bounds when root svg width and height are relative values', async () => { + const svg = document.createElementNS(svgNS, 'svg'); + svg.setAttribute('width', '100%'); + svg.setAttribute('height', '100%'); + mockSvgCoordinateSpace(svg); + mockRect(svg, { left: 0, top: 0, width: 320, height: 180 }); + + const foreignObject = document.createElementNS(svgNS, 'foreignObject'); + const span = document.createElement('span'); + span.style.alignItems = 'flex-end'; + Object.defineProperty(span, 'scrollHeight', { + configurable: true, + get: () => 270, + }); + foreignObject.appendChild(span); + svg.appendChild(foreignObject); + + mockRect(foreignObject, { left: 0, top: 0, width: 320, height: 180 }); + + const exported = await exportToSVG(svg); + + expect(exported.getAttribute('viewBox')).toBe('0 -90 320 270'); + expect(exported.getAttribute('width')).toBe('320'); + expect(exported.getAttribute('height')).toBe('270'); + }); }); diff --git a/dev/src/Infographic.tsx b/dev/src/Infographic.tsx index d09cf0f6f..6c30bd7ad 100644 --- a/dev/src/Infographic.tsx +++ b/dev/src/Infographic.tsx @@ -4,7 +4,7 @@ import { registerResourceLoader, Infographic as Renderer, } from '@antv/infographic'; -import { useEffect, useRef } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; const svgTextCache = new Map(); const pendingRequests = new Map>(); @@ -91,57 +91,90 @@ registerResourceLoader(async (config) => { } }); -export const Infographic = ({ - options, - init, - onError, -}: { +type ExportType = 'png' | 'svg'; + +export interface InfographicHandle { + download: (type: ExportType, filename?: string) => Promise; +} + +type InfographicProps = { options: string | InfographicOptions; init?: Partial; onError?: (error: Error | null) => void; -}) => { - const containerRef = useRef(null); - const instanceRef = useRef(null); - - useEffect(() => { - if (!containerRef.current) return; - if (instanceRef.current) return; - - const instance = new Renderer({ - container: containerRef.current, - svg: { - attributes: { - width: '100%', - height: '100%', +}; + +function downloadDataURL(dataURL: string, filename: string) { + const link = document.createElement('a'); + link.href = dataURL; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); +} + +export const Infographic = forwardRef( + ({ options, init, onError }, ref) => { + const containerRef = useRef(null); + const instanceRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + async download(type, filename = `infographic.${type}`) { + const instance = instanceRef.current; + if (!instance) { + throw new Error('Infographic is not ready yet.'); + } + + const dataURL = await instance.toDataURL({ type }); + downloadDataURL(dataURL, filename); }, - style: { - maxHeight: '80vh', + }), + [], + ); + + useEffect(() => { + if (!containerRef.current) return; + if (instanceRef.current) return; + + const instance = new Renderer({ + container: containerRef.current, + svg: { + attributes: { + width: '100%', + height: '100%', + }, + style: { + maxHeight: '80vh', + }, }, - }, - ...init, - }); - instanceRef.current = instance; - Object.assign(window, { infographic: instance }); - - return () => { - instance.destroy(); - instanceRef.current = null; - }; - }, [init]); - - useEffect(() => { - const instance = instanceRef.current; - if (!instance || !options) return; - - try { - onError?.(null); - instance.render(options); - } catch (err) { - const error = err instanceof Error ? err : new Error(String(err)); - console.error('Dev Infographic render error', error); - onError?.(error); - } - }, [options, onError]); + ...init, + }); + instanceRef.current = instance; + Object.assign(window, { infographic: instance }); - return
; -}; + return () => { + instance.destroy(); + instanceRef.current = null; + }; + }, [init]); + + useEffect(() => { + const instance = instanceRef.current; + if (!instance || !options) return; + + try { + onError?.(null); + instance.render(options); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + console.error('Dev Infographic render error', error); + onError?.(error); + } + }, [options, onError]); + + return
; + }, +); + +Infographic.displayName = 'Infographic'; diff --git a/dev/src/Preview.tsx b/dev/src/Preview.tsx index 570de973b..8cabe7427 100644 --- a/dev/src/Preview.tsx +++ b/dev/src/Preview.tsx @@ -1,16 +1,39 @@ import Editor from '@monaco-editor/react'; -import { Button, Card, Checkbox, ColorPicker, Form, Select } from 'antd'; -import { useCallback } from 'react'; -import { Infographic } from './Infographic'; +import { + Button, + Card, + Checkbox, + ColorPicker, + Form, + Select, + message, +} from 'antd'; +import { useCallback, useRef, useState } from 'react'; +import { Infographic, type InfographicHandle } from './Infographic'; import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'; import { usePreviewData } from './hooks/usePreviewData'; import { usePreviewInteractions } from './hooks/usePreviewInteractions'; import { usePreviewSettings } from './hooks/usePreviewSettings'; +type ExportType = 'png' | 'svg'; + +function getDownloadFilename(template: string, type: ExportType) { + const normalized = + template + .trim() + .replace(/[^a-zA-Z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'infographic'; + return `${normalized}.${type}`; +} + export const Preview = () => { const settings = usePreviewSettings(); const dataState = usePreviewData(settings.isSettingsHydrated); const interactions = usePreviewInteractions(settings, dataState); + const infographicRef = useRef(null); + const [downloadingType, setDownloadingType] = useState( + null, + ); // 键盘导航:上下或左右方向键切换模板 useKeyboardNavigation({ @@ -46,6 +69,33 @@ export const Preview = () => { [dataState.setCustomData], ); + const handleDownload = useCallback( + async (type: ExportType) => { + if (!infographicRef.current) { + message.error('预览尚未准备好'); + return; + } + + try { + setDownloadingType(type); + await infographicRef.current.download( + type, + getDownloadFilename(settings.template, type), + ); + } catch (error) { + const msg = + error instanceof Error + ? error.message + : `下载 ${type.toUpperCase()} 失败`; + console.error(`Failed to download ${type.toUpperCase()}`, error); + message.error(msg); + } finally { + setDownloadingType(null); + } + }, + [settings.template], + ); + if (!interactions.isHydrated) { return null; } @@ -221,8 +271,33 @@ export const Preview = () => { {/* Right Panel - Preview */}
- + + + +
+ } + > = {}, @@ -19,6 +21,190 @@ export async function exportToSVGString( return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(str); } +function getExportViewBox(svg: SVGSVGElement) { + if (svg.hasAttribute('viewBox')) return getViewBox(svg); + + const width = parseAbsoluteLength(svg.getAttribute('width')); + const height = parseAbsoluteLength(svg.getAttribute('height')); + if (width > 0 && height > 0) { + return { x: 0, y: 0, width, height }; + } + + const rect = svg.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + return { x: 0, y: 0, width: rect.width, height: rect.height }; + } + + return null; +} + +function parseAbsoluteLength(value: string | null): number { + if (!value) return Number.NaN; + const trimmed = value.trim(); + if (!trimmed) return Number.NaN; + if (!/^[-+]?(?:\d+\.?\d*|\.\d+)(?:px)?$/.test(trimmed)) return Number.NaN; + return Number.parseFloat(trimmed); +} + +function measureSpanContentHeight(span: HTMLElement): number { + const prevHeight = span.style.height; + const prevOverflow = span.style.overflow; + try { + span.style.height = 'max-content'; + span.style.overflow = 'hidden'; + void span.offsetHeight; // force reflow + return span.scrollHeight; + } finally { + span.style.height = prevHeight; + span.style.overflow = prevOverflow; + } +} + +function measureSpanContentWidth(span: HTMLElement): number { + const prevWidth = span.style.width; + const prevOverflow = span.style.overflow; + try { + span.style.width = 'max-content'; + span.style.overflow = 'hidden'; + void span.offsetWidth; // force reflow + return span.scrollWidth; + } finally { + span.style.width = prevWidth; + span.style.overflow = prevOverflow; + } +} + +// Returns [left, top, right, bottom] in SVG coordinates for a foreignObject, +// accounting for flex alignment: bottom/center-aligned content can overflow, +// and horizontally aligned content can overflow as well. +function getFOContentBoundsInSVG( + fo: SVGForeignObjectElement, + content: HTMLElement, + toSVGCoord: (x: number, y: number) => SVGPoint, +): [number, number, number, number] { + const foRect = fo.getBoundingClientRect(); + const foTopLeft = toSVGCoord(foRect.left, foRect.top); + const foBottomRight = toSVGCoord(foRect.right, foRect.bottom); + + const foLeftSVG = foTopLeft.x; + const foTopSVG = foTopLeft.y; + const foRightSVG = foBottomRight.x; + const foBottomSVG = foBottomRight.y; + + const foWidthSVG = foRightSVG - foLeftSVG; + const foHeightSVG = foBottomSVG - foTopSVG; + + const svgUnitsPerClientPxY = + foRect.height > 0 ? foHeightSVG / foRect.height : 1; + const svgUnitsPerClientPxX = foRect.width > 0 ? foWidthSVG / foRect.width : 1; + + // Measure actual content dimensions + const realScrollHeight = measureSpanContentHeight(content); + const contentHeightSVG = + realScrollHeight > 0 + ? realScrollHeight * svgUnitsPerClientPxY + : foHeightSVG; + + const realScrollWidth = measureSpanContentWidth(content); + const contentWidthSVG = + realScrollWidth > 0 ? realScrollWidth * svgUnitsPerClientPxX : foWidthSVG; + + const computedStyle = window.getComputedStyle(content); + const alignItems = computedStyle.alignItems; + const justifyContent = computedStyle.justifyContent; + + // Calculate vertical bounds + let top: number, bottom: number; + if (alignItems === 'flex-end' || alignItems === 'end') { + top = foBottomSVG - contentHeightSVG; + bottom = foBottomSVG; + } else if (alignItems === 'center') { + const overflowY = contentHeightSVG - foHeightSVG; + top = foTopSVG - overflowY / 2; + bottom = foBottomSVG + overflowY / 2; + } else { + top = foTopSVG; + bottom = foTopSVG + contentHeightSVG; + } + + // Calculate horizontal bounds + let left: number, right: number; + if ( + justifyContent === 'flex-end' || + justifyContent === 'end' || + justifyContent === 'right' + ) { + left = foRightSVG - contentWidthSVG; + right = foRightSVG; + } else if (justifyContent === 'center') { + const overflowX = contentWidthSVG - foWidthSVG; + left = foLeftSVG - overflowX / 2; + right = foRightSVG + overflowX / 2; + } else { + left = foLeftSVG; + right = foLeftSVG + contentWidthSVG; + } + + return [left, top, right, bottom]; +} + +/** + * Computes a viewBox that fully covers all foreignObject text content, + * accounting for overflow caused by flex alignment (bottom/center align + * can push content outside the foreignObject bounds). + */ +function computeFullViewBox(svg: SVGSVGElement): string | null { + const viewBox = getExportViewBox(svg); + if (!viewBox) return null; + + if (typeof svg.getScreenCTM !== 'function') return null; + const screenCTM = svg.getScreenCTM(); + if (!screenCTM) return null; + const inverseCTM = screenCTM.inverse(); + + const toSVGCoord = (clientX: number, clientY: number) => { + const pt = svg.createSVGPoint(); + pt.x = clientX; + pt.y = clientY; + return pt.matrixTransform(inverseCTM); + }; + + let minX = viewBox.x; + let minY = viewBox.y; + let maxX = viewBox.x + viewBox.width; + let maxY = viewBox.y + viewBox.height; + + svg + .querySelectorAll('foreignObject') + .forEach((fo) => { + const content = fo.firstElementChild as HTMLElement; + if (!content) return; + const [left, top, right, bottom] = getFOContentBoundsInSVG( + fo, + content, + toSVGCoord, + ); + minX = Math.min(minX, left); + minY = Math.min(minY, top); + maxX = Math.max(maxX, right); + maxY = Math.max(maxY, bottom); + }); + + const newX = minX; + const newY = minY; + const newWidth = maxX - newX; + const newHeight = maxY - newY; + if ( + newWidth <= viewBox.width + VIEWBOX_CHANGE_TOLERANCE && + newHeight <= viewBox.height + VIEWBOX_CHANGE_TOLERANCE && + newX >= viewBox.x - VIEWBOX_CHANGE_TOLERANCE && + newY >= viewBox.y - VIEWBOX_CHANGE_TOLERANCE + ) + return null; + + return `${newX} ${newY} ${newWidth} ${newHeight}`; +} + export async function exportToSVG( svg: SVGSVGElement, options: Omit = {}, @@ -29,7 +215,15 @@ export async function exportToSVG( removeIds = false, } = options; const clonedSVG = svg.cloneNode(true) as SVGSVGElement; - const { width, height } = getViewBox(svg); + + if (typeof document !== 'undefined') { + const fullViewBox = computeFullViewBox(svg); + if (fullViewBox) { + clonedSVG.setAttribute('viewBox', fullViewBox); + } + } + + const { width, height } = getViewBox(clonedSVG); setAttributes(clonedSVG, { width, height }); if (removeIds) {