/** * Chart export — pure helpers for the single-chart export affordance * (spec §08 → Per-chart export). Distinct from the workspace export envelope * (`export-envelope.ts`): that backs up the whole library as one JSON file; this * turns *one* snippet into a shareable artifact — its spec as `.vl.json`, or its * rendered image as PNG/SVG. * * The naming is deterministic, and so is producing a **self-contained** spec — * one with every saved-dataset reference replaced by its inline data, so the * exported file renders without Astrolabe. Both live here and are tested. The * clipboard write, the file download, and the image rasterization are browser-side * and stay in `infrastructure/file-transfer` and the chart renderer. */ import { collectFontFamilies } from './custom-theme'; import { fontDataUri, primaryFamilyName, variableFontDescriptors, type FontAsset, type FontFormat, } from './font-asset'; import { prepareSpecForRender, type ResolvableDataset } from './rendering'; import { extractDatasetRefs } from './spec-refs'; /** The formats a single chart can be exported as (file extension == the value). */ export type ChartExportFormat = 'vl.json' | 'png' | 'svg'; /** Cap on the derived base name so a very long snippet title can't blow up the * filename (filesystems and download shelves both balk past ~255 chars). */ export const MAX_BASENAME_LEN = 60; /** C0 control characters + DEL, built from a string so no literal control byte * ever lands in this source file. Stripped from filenames. */ // eslint-disable-next-line no-control-regex -- intentional: scrub control chars from names const CONTROL_CHARS = new RegExp('[\\u0000-\\u001f\\u007f]', 'g'); /** Characters illegal on Windows or awkward across filesystems and URLs. */ const ILLEGAL_CHARS = /[\\/:*?"<>|]/g; /** * Turn a snippet's display name into a filesystem- and URL-safe base name. * * Keeps the user's words and **case**, and keeps letters of *any* script (a * Cyrillic or CJK title stays itself — we never ASCII-fold, matching the app's * full-script support). Swaps whitespace and dot runs for single dashes (so the * extension stays unambiguous), drops control + illegal characters, collapses and * trims dashes, and caps the length. Falls back to `"chart"` when nothing usable * remains (a name of only punctuation or whitespace). */ export function snippetFileBasename(name: string): string { const cleaned = name .normalize('NFC') .replace(CONTROL_CHARS, '') .replace(ILLEGAL_CHARS, '') .replace(/[.\s]+/g, '-') // dot and whitespace runs → one dash boundary .replace(/-+/g, '-') .replace(/^-+|-+$/g, ''); const capped = cleaned.slice(0, MAX_BASENAME_LEN).replace(/-+$/g, ''); return capped || 'chart'; } /** * Download filename for a single chart export, e.g. `sales-by-region.png`. The * base is derived from the snippet name; `format` is both the extension and the * artifact kind. No date or `astrolabe-` prefix (unlike the workspace export) — * the user is exporting *one named chart* and wants its name on the file. */ export function chartExportFilename(name: string, format: ChartExportFormat): string { return `${snippetFileBasename(name)}.${format}`; } /** * The saved-dataset names a spec references via `{ data: { name } }` (deduped). * A name the spec defines for itself via top-level `datasets` is excluded — those * are already self-contained. Returns `[]` for spec text that doesn't parse. Drives * whether the export offers an "inline referenced data" option at all. */ export function referencedDatasetNames(specText: string): string[] { // extractDatasetRefs already safe-parses a string (→ [] on bad JSON) and dedups. return extractDatasetRefs(specText); } /** * Re-serialize a spec with every saved-dataset reference replaced by its inline * data, so the exported file renders standalone (outside Astrolabe). Sizing is * left exactly as authored — unlike the preview, no fit-mode is applied. Throws * `DatasetNotFoundError` (from `prepareSpecForRender`) if the spec references a * name not present in `datasets`; the caller surfaces that. `specText` must be * valid JSON (it is the spec the editor is showing). */ export function inlineReferencedDatasets( specText: string, datasets: ReadonlyArray, ): string { const parsed: unknown = JSON.parse(specText); const resolved = prepareSpecForRender(parsed, { datasets, fitMode: 'default' }); return JSON.stringify(resolved, null, 2); } // --- SVG font embedding (spec §08; scope doc §4, item 7) -------------------- // // `view.toSVG()` writes only the `font-family` name, so an exported SVG falls // back to a system font wherever the user's uploaded face isn't installed. We // embed the referenced uploaded faces as base64 `@font-face` rules so the SVG // renders the right type anywhere. Only uploaded faces are embedded — the // roster/system stacks are decoration with their own fallbacks, and we don't // hold their bytes here. /** CSS `format()` keyword for an `@font-face` src — distinct from the file extension. */ const CSS_FORMAT: Record = { woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype', }; /** * The uploaded fonts a chart actually references — its spec + the active chart * config, matched by primary family against the font library. Returns the * matched `FontAsset`s (the bytes to embed); `[]` when none are referenced or * the library is empty. Case-insensitive, like the naming helpers. */ export function referencedUploadedFonts( specText: string, config: unknown, fonts: ReadonlyArray, ): FontAsset[] { if (fonts.length === 0) return []; let spec: unknown; try { spec = JSON.parse(specText); } catch { spec = null; // an unparseable spec still has a config that may name a font } const stacks = new Set([...collectFontFamilies(spec), ...collectFontFamilies(config)]); const used = new Set([...stacks].map((s) => primaryFamilyName(s).toLowerCase())); return fonts.filter((f) => used.has(f.family.toLowerCase())); } /** Escape a family name for use inside a double-quoted CSS string. */ function cssQuote(family: string): string { return family.replace(/[\\"]/g, '\\$&'); } /** Build one `@font-face` rule embedding a face's bytes as a data-URI `src`. */ function fontFaceRule(font: FontAsset): string { const desc = variableFontDescriptors(font.axes); const weight = desc.weight ? `font-weight:${desc.weight};` : ''; const stretch = desc.stretch ? `font-stretch:${desc.stretch};` : ''; return ( `@font-face{font-family:"${cssQuote(font.family)}";${weight}${stretch}` + `src:url(${fontDataUri(font)}) format("${CSS_FORMAT[font.format]}");}` ); } /** * Inject a ``; return svg.replace(/(]*>)/, `$1${style}`); }