Per-chart export: copy/download spec and PNG/SVG from the preview header

This commit is contained in:
2026-06-10 18:30:31 +03:00
parent cad19445b0
commit 1791ee9f8d
15 changed files with 921 additions and 33 deletions
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import {
chartExportFilename,
inlineReferencedDatasets,
MAX_BASENAME_LEN,
referencedDatasetNames,
snippetFileBasename,
} from './chart-export';
import { DatasetNotFoundError, type ResolvableDataset } from './rendering';
describe('snippetFileBasename', () => {
it('keeps the users words and case', () => {
expect(snippetFileBasename('Sales by Region')).toBe('Sales-by-Region');
});
it('swaps whitespace runs for a single dash', () => {
expect(snippetFileBasename(' a b c ')).toBe('a-b-c');
});
it('folds dots into the separator so the extension stays unambiguous', () => {
expect(snippetFileBasename('data.v1.final')).toBe('data-v1-final');
});
it('strips characters illegal on Windows / awkward in URLs', () => {
expect(snippetFileBasename('a/b\\c:d*e?f"g<h>i|j')).toBe('abcdefghij');
});
it('keeps non-latin letters (no ASCII folding — full-script support)', () => {
expect(snippetFileBasename('Продажи по региону')).toBe('Продажи-по-региону');
expect(snippetFileBasename('売上 グラフ')).toBe('売上-グラフ');
});
it('collapses and trims dashes', () => {
expect(snippetFileBasename('--a -- b--')).toBe('a-b');
});
it('falls back to "chart" when nothing usable remains', () => {
expect(snippetFileBasename('')).toBe('chart');
expect(snippetFileBasename(' ')).toBe('chart');
expect(snippetFileBasename('/// \\\\\\')).toBe('chart');
expect(snippetFileBasename('...')).toBe('chart');
});
it('caps the length and never leaves a trailing dash from the cut', () => {
const long = snippetFileBasename('x'.repeat(200));
expect(long.length).toBe(MAX_BASENAME_LEN);
// A name whose cap boundary lands on a dash must not end in one.
const dashy = snippetFileBasename('a'.repeat(MAX_BASENAME_LEN - 1) + ' bbbb');
expect(dashy.endsWith('-')).toBe(false);
});
it('drops control characters', () => {
// Build the control byte from a code point so no literal control char is in source.
expect(snippetFileBasename(`a${String.fromCharCode(1)}bcd`)).toBe('abcd');
});
});
describe('chartExportFilename', () => {
it('appends the format as the extension', () => {
expect(chartExportFilename('Sales by Region', 'png')).toBe('Sales-by-Region.png');
expect(chartExportFilename('Sales by Region', 'svg')).toBe('Sales-by-Region.svg');
expect(chartExportFilename('Sales by Region', 'vl.json')).toBe('Sales-by-Region.vl.json');
});
it('uses the "chart" fallback for an unusable name', () => {
expect(chartExportFilename('', 'png')).toBe('chart.png');
});
});
describe('referencedDatasetNames', () => {
it('returns the saved-dataset names a spec references, deduped', () => {
const spec = JSON.stringify({
layer: [
{ data: { name: 'sales' } },
{ data: { name: 'sales' } },
{ data: { name: 'costs' } },
],
});
expect(referencedDatasetNames(spec).sort()).toEqual(['costs', 'sales']);
});
it('excludes names the spec defines for itself via top-level datasets', () => {
const spec = JSON.stringify({
datasets: { local: [{ a: 1 }] },
data: { name: 'local' },
});
expect(referencedDatasetNames(spec)).toEqual([]);
});
it('returns [] for an inline-data spec (no references)', () => {
const spec = JSON.stringify({ data: { values: [{ a: 1 }] }, mark: 'bar' });
expect(referencedDatasetNames(spec)).toEqual([]);
});
it('returns [] for unparseable text rather than throwing', () => {
expect(referencedDatasetNames('{ not json')).toEqual([]);
});
});
describe('inlineReferencedDatasets', () => {
const sales: ResolvableDataset = {
name: 'sales',
data: [{ region: 'N', value: 10 }],
format: 'json',
source: 'inline',
};
const parse = (text: string) => JSON.parse(text) as { data?: unknown; width?: unknown };
it('replaces a named reference with the datasets inline values', () => {
const spec = JSON.stringify({ data: { name: 'sales' }, mark: 'bar' });
const out = parse(inlineReferencedDatasets(spec, [sales]));
expect(out.data).toEqual({ values: [{ region: 'N', value: 10 }] });
});
it('resolves the name case-insensitively (mirrors reference resolution)', () => {
const spec = JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' });
const out = parse(inlineReferencedDatasets(spec, [sales]));
expect(out.data).toEqual({ values: [{ region: 'N', value: 10 }] });
});
it('leaves sizing as authored — no fit-mode applied', () => {
const spec = JSON.stringify({ data: { name: 'sales' }, width: 300, mark: 'bar' });
const out = parse(inlineReferencedDatasets(spec, [sales]));
expect(out.width).toBe(300);
});
it('throws DatasetNotFoundError when a referenced dataset is missing', () => {
const spec = JSON.stringify({ data: { name: 'missing' }, mark: 'bar' });
expect(() => inlineReferencedDatasets(spec, [sales])).toThrow(DatasetNotFoundError);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* 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 { 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<ResolvableDataset>,
): string {
const parsed: unknown = JSON.parse(specText);
const resolved = prepareSpecForRender(parsed, { datasets, fitMode: 'default' });
return JSON.stringify(resolved, null, 2);
}