Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/Surface.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -37,6 +37,8 @@ export interface SurfaceProps {
* `renderToSVGString` path and the MCP server.
*/
bare?: boolean;
/** Attach a ref to the inner `<svg>` — handy for client-side export. */
svgRef?: Ref<SVGSVGElement>;
}

const DRAW_ON_CLASS = 'gc-draw-on';
Expand Down Expand Up @@ -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);
Expand All @@ -104,6 +107,7 @@ export function Surface({

const svg = (
<svg
ref={svgRef}
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${width} ${height}`}
width={width}
Expand Down
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ export { RoughPath, RoughLine, RoughRectangle, RoughCircle, RoughText } from './
export { getRoughGenerator, drawableToPaths } from './render/roughGenerator';
export type { RoughPathInfo } from './render/roughGenerator';

// Client-side export helpers (SVG/PNG/clipboard). Browser-only.
export {
toSvgString,
toPng,
downloadChart,
copyToClipboard,
chartSvgFrom,
svgPixelSize,
extensionFor,
mimeFor,
} from './render/clientExport';
export type { ExportFormat, ToPngOptions, DownloadOptions } from './render/clientExport';

// High-level components
export {
Surface,
Expand Down
119 changes: 119 additions & 0 deletions src/render/clientExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';
import { chartSvgFrom, extensionFor, mimeFor, svgPixelSize, toSvgString } from './clientExport';

// Project convention is no-jsdom (see src/interactive/InteractiveChart.test.ts).
// The DOM-dependent helpers (toPng / downloadChart / copyToClipboard) are
// integration-only and exercised in the playground; the pure helpers below
// are what carries the test budget.

function fakeSvg(opts: {
width?: string;
height?: string;
viewBox?: string;
xmlns?: string;
rect?: { width: number; height: number };
}): SVGSVGElement {
const attrs: Record<string, string> = {};
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') ?? '<missing>';
serialised = `<svg xmlns="${xmlns}"/>`;
return serialised;
}
}
(globalThis as { XMLSerializer: typeof FakeSerializer }).XMLSerializer = FakeSerializer;
try {
const out = toSvgString(fakeSvg({}));
expect(out).toBe('<svg xmlns="http://www.w3.org/2000/svg"/>');
} 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 `<svg xmlns="${captured}"/>`;
}
}
(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 <svg> 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();
});
});
156 changes: 156 additions & 0 deletions src/render/clientExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Browser-side export helpers: serialize a live `<svg>` 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 `<svg>` 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 `<canvas>`. */
export async function toPng(svg: SVGSVGElement, opts: ToPngOptions = {}): Promise<Blob> {
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<Blob>((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<HTMLImageElement> {
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<void> {
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<void> {
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 `<svg>` 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');
}
Loading