From 5f5838a0451ccadaa3aedb642c9ba17aebd46e62 Mon Sep 17 00:00:00 2001 From: bsevern Date: Thu, 28 May 2026 12:41:48 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20client-side=20export=20=E2=80=94=20toSv?= =?UTF-8?q?gString=20/=20toPng=20/=20downloadChart=20/=20copyToClipboard?= =?UTF-8?q?=20(#123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser-side rasterisation and download helpers, plus an `svgRef` prop on `` so consumers can grab the inner `` cleanly. - new src/render/clientExport.ts: toSvgString, toPng, downloadChart, copyToClipboard, chartSvgFrom (container helper), svgPixelSize - Surface accepts `svgRef?: Ref` and threads it to the inner - exported from goldenchart main entry; no new deps; tree-shakeable - no font embedding (matches what the user sees; for fully standalone SVGs use goldenchart/server's renderToSVGString) - pure helpers unit-tested (project convention: no jsdom — see InteractiveChart.test.ts) Bundle 50 KB gzipped (budget 75 KB). All 412 existing tests pass plus 10 new. --- src/components/Surface.tsx | 6 +- src/index.ts | 13 +++ src/render/clientExport.test.ts | 119 ++++++++++++++++++++++++ src/render/clientExport.ts | 156 ++++++++++++++++++++++++++++++++ 4 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 src/render/clientExport.test.ts create mode 100644 src/render/clientExport.ts diff --git a/src/components/Surface.tsx b/src/components/Surface.tsx index 151b9df..a5a1d06 100644 --- a/src/components/Surface.tsx +++ b/src/components/Surface.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import type { CSSProperties, ReactNode } from 'react'; +import type { CSSProperties, ReactNode, Ref } from 'react'; import type { VibeConfig } from '../types/vibe'; import type { BrandConfig, ResolvedBrandLogo } from '../types/brand'; import type { DataTableModel } from '../types/charts'; @@ -37,6 +37,8 @@ export interface SurfaceProps { * `renderToSVGString` path and the MCP server. */ bare?: boolean; + /** Attach a ref to the inner `` — handy for client-side export. */ + svgRef?: Ref; } const DRAW_ON_CLASS = 'gc-draw-on'; @@ -93,6 +95,7 @@ export function Surface({ style, children, bare = false, + svgRef, }: SurfaceProps) { const resolvedBrand = useMemo(() => resolveBrand(brand), [brand]); const resolved = resolveVibe(vibe, resolvedBrand.vibeOverrides); @@ -104,6 +107,7 @@ export function Surface({ const svg = ( = {}; + if (opts.width) attrs.width = opts.width; + if (opts.height) attrs.height = opts.height; + if (opts.viewBox) attrs.viewBox = opts.viewBox; + if (opts.xmlns) attrs.xmlns = opts.xmlns; + const svg = { + tagName: 'svg', + getAttribute: (n: string) => attrs[n] ?? null, + setAttribute: (n: string, v: string) => { + attrs[n] = v; + }, + cloneNode: () => svg, + getBoundingClientRect: () => opts.rect ?? { width: 0, height: 0 }, + }; + return svg as unknown as SVGSVGElement; +} + +describe('clientExport pure helpers', () => { + it('extensionFor returns the format name', () => { + expect(extensionFor('svg')).toBe('svg'); + expect(extensionFor('png')).toBe('png'); + }); + + it('mimeFor returns the canonical MIME type', () => { + expect(mimeFor('svg')).toBe('image/svg+xml'); + expect(mimeFor('png')).toBe('image/png'); + }); + + it('svgPixelSize prefers explicit width/height attrs', () => { + expect(svgPixelSize(fakeSvg({ width: '300', height: '150' }))).toEqual({ + width: 300, + height: 150, + }); + }); + + it('svgPixelSize falls back to viewBox when width/height are missing', () => { + expect(svgPixelSize(fakeSvg({ viewBox: '0 0 480 270' }))).toEqual({ + width: 480, + height: 270, + }); + }); + + it('svgPixelSize falls back to getBoundingClientRect as a last resort', () => { + expect(svgPixelSize(fakeSvg({ rect: { width: 99, height: 33 } }))).toEqual({ + width: 99, + height: 33, + }); + }); + + it('toSvgString sets xmlns when missing', () => { + let serialised = ''; + const original = (globalThis as { XMLSerializer?: unknown }).XMLSerializer; + class FakeSerializer { + serializeToString(node: SVGSVGElement) { + const xmlns = node.getAttribute('xmlns') ?? ''; + serialised = ``; + return serialised; + } + } + (globalThis as { XMLSerializer: typeof FakeSerializer }).XMLSerializer = FakeSerializer; + try { + const out = toSvgString(fakeSvg({})); + expect(out).toBe(''); + } finally { + (globalThis as { XMLSerializer?: unknown }).XMLSerializer = original; + } + }); + + it('toSvgString keeps an existing xmlns', () => { + let captured = ''; + const original = (globalThis as { XMLSerializer?: unknown }).XMLSerializer; + class FakeSerializer { + serializeToString(node: SVGSVGElement) { + captured = node.getAttribute('xmlns') ?? ''; + return ``; + } + } + (globalThis as { XMLSerializer: typeof FakeSerializer }).XMLSerializer = FakeSerializer; + try { + toSvgString(fakeSvg({ xmlns: 'http://example.test' })); + expect(captured).toBe('http://example.test'); + } finally { + (globalThis as { XMLSerializer?: unknown }).XMLSerializer = original; + } + }); + + it('chartSvgFrom returns the svg itself when passed one directly', () => { + const svg = fakeSvg({ width: '10', height: '10' }); + expect(chartSvgFrom(svg as unknown as Element)).toBe(svg); + }); + + it('chartSvgFrom queries an inner from a container', () => { + const found = { tagName: 'svg' } as unknown as SVGSVGElement; + const container = { + tagName: 'div', + querySelector: (sel: string) => (sel === 'svg' ? found : null), + } as unknown as Element; + expect(chartSvgFrom(container)).toBe(found); + }); + + it('chartSvgFrom returns null for null input', () => { + expect(chartSvgFrom(null)).toBeNull(); + }); +}); diff --git a/src/render/clientExport.ts b/src/render/clientExport.ts new file mode 100644 index 0000000..ad936f5 --- /dev/null +++ b/src/render/clientExport.ts @@ -0,0 +1,156 @@ +/** + * Browser-side export helpers: serialize a live `` to a string, rasterize + * it to a PNG blob, trigger a download, or copy it to the clipboard. + * + * No font embedding: the browser is already painting the chart, so any font + * the consumer has loaded via CSS will be present. For standalone SVGs that + * must render with no font installed, use `goldenchart/server`'s + * `renderToSVGString` instead — that path embeds `@font-face` rules. + */ + +export type ExportFormat = 'svg' | 'png'; + +export interface ToPngOptions { + /** Pixel-density multiplier for the rasterised image. Default 2. */ + scale?: number; + /** Override the output width (px). Defaults to the SVG's viewport width. */ + width?: number; + /** Override the output height (px). Defaults to the SVG's viewport height. */ + height?: number; + /** Solid background colour painted under the SVG. Default: transparent. */ + background?: string; +} + +export interface DownloadOptions extends ToPngOptions { + /** File name *without* extension; the format's extension is appended. */ + filename: string; + format: ExportFormat; +} + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +/** Returns the SVG string for a live `` element (no font embedding). */ +export function toSvgString(svg: SVGSVGElement): string { + const clone = svg.cloneNode(true) as SVGSVGElement; + if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', SVG_NS); + return new XMLSerializer().serializeToString(clone); +} + +/** Default size for a rasterised SVG; honours `width`/`height` attrs or viewBox. */ +export function svgPixelSize(svg: SVGSVGElement): { width: number; height: number } { + const widthAttr = svg.getAttribute('width'); + const heightAttr = svg.getAttribute('height'); + const w = widthAttr ? Number.parseFloat(widthAttr) : NaN; + const h = heightAttr ? Number.parseFloat(heightAttr) : NaN; + if (Number.isFinite(w) && Number.isFinite(h)) return { width: w, height: h }; + const vb = svg.getAttribute('viewBox'); + if (vb) { + const parts = vb.split(/\s+/).map(Number); + if (parts.length === 4 && parts.every(Number.isFinite)) { + return { width: parts[2], height: parts[3] }; + } + } + const bbox = svg.getBoundingClientRect(); + return { width: bbox.width, height: bbox.height }; +} + +/** File extension for an export format (no leading dot). */ +export function extensionFor(format: ExportFormat): string { + return format; +} + +/** MIME type for an export format. */ +export function mimeFor(format: ExportFormat): string { + return format === 'svg' ? 'image/svg+xml' : 'image/png'; +} + +/** Rasterises an SVG to a PNG blob via an off-DOM ``. */ +export async function toPng(svg: SVGSVGElement, opts: ToPngOptions = {}): Promise { + const { scale = 2, background } = opts; + const { width: nativeWidth, height: nativeHeight } = svgPixelSize(svg); + const width = opts.width ?? nativeWidth; + const height = opts.height ?? nativeHeight; + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error('toPng: could not determine SVG pixel size'); + } + + const svgBlob = new Blob([toSvgString(svg)], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(svgBlob); + try { + const image = await loadImage(url); + const canvas = document.createElement('canvas'); + canvas.width = Math.round(width * scale); + canvas.height = Math.round(height * scale); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('toPng: 2d canvas context unavailable'); + if (background) { + ctx.fillStyle = background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + ctx.drawImage(image, 0, 0, canvas.width, canvas.height); + return await new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) resolve(blob); + else reject(new Error('toPng: canvas.toBlob returned null')); + }, 'image/png'); + }); + } finally { + URL.revokeObjectURL(url); + } +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('toPng: SVG failed to load into an Image')); + image.src = src; + }); +} + +/** Triggers a browser download of the chart in the chosen format. */ +export async function downloadChart(svg: SVGSVGElement, opts: DownloadOptions): Promise { + const blob = + opts.format === 'svg' + ? new Blob([toSvgString(svg)], { type: mimeFor('svg') }) + : await toPng(svg, opts); + const url = URL.createObjectURL(blob); + try { + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${opts.filename}.${extensionFor(opts.format)}`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } finally { + URL.revokeObjectURL(url); + } +} + +/** Best-effort: writes the chart to the clipboard. Requires HTTPS + user gesture. */ +export async function copyToClipboard( + svg: SVGSVGElement, + format: ExportFormat = 'svg', +): Promise { + const clipboard = (globalThis as { navigator?: { clipboard?: Clipboard } }).navigator?.clipboard; + if (!clipboard) throw new Error('copyToClipboard: navigator.clipboard unavailable'); + if (format === 'svg') { + if (typeof clipboard.writeText !== 'function') { + throw new Error('copyToClipboard: writeText unavailable'); + } + await clipboard.writeText(toSvgString(svg)); + return; + } + if (typeof ClipboardItem === 'undefined' || typeof clipboard.write !== 'function') { + throw new Error('copyToClipboard: ClipboardItem unavailable; cannot copy PNG'); + } + const blob = await toPng(svg); + await clipboard.write([new ClipboardItem({ [mimeFor('png')]: blob })]); +} + +/** Convenience: find the `` inside a chart container (e.g. a ref's `current`). */ +export function chartSvgFrom(container: Element | null): SVGSVGElement | null { + if (!container) return null; + if (container.tagName?.toLowerCase() === 'svg') return container as SVGSVGElement; + return container.querySelector('svg'); +}