diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3ce19d..a0218fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,9 +39,18 @@ jobs: run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh - name: Install dependencies run: npm install + - name: Build Shared package + working-directory: packages/shared + run: npm run build - name: Build React package working-directory: packages/react run: npm run build + - name: Build Svelte package + working-directory: packages/svelte + run: npm run build + - name: Check Svelte package (svelte-check) + working-directory: packages/svelte + run: npm run check - name: Build Templates working-directory: packages/templates run: npm run build @@ -65,6 +74,11 @@ jobs: - name: Test React working-directory: packages/react run: npm test + - name: Test Svelte + # After Build Core: the WASM smoke + renderDocument tests render + # through @formepdf/core (devDependency). + working-directory: packages/svelte + run: npm test - name: Test Core working-directory: packages/core run: npm test diff --git a/.gitignore b/.gitignore index a488c4b..9e3df84 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ packages/vscode/pkg/ # Node node_modules/ dist/ +.svelte-kit/ # OS .DS_Store @@ -44,3 +45,6 @@ test-*.tsx test-*.json /tmp/ *.pdf + +# Generated preview HTML embed (packages/svelte build step) +packages/svelte/src/preview/preview-html.generated.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5faefeb..edf6558 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,13 +48,31 @@ forme/ │ ├── grid-dashboard.tsx # Multi-feature showcase (grid, charts, i18n, RTL) │ └── grid-dashboard-data.json └── packages/ + ├── shared/ # Framework-neutral core shared by authoring adapters + │ └── src/ + │ ├── index.ts # Public exports + │ ├── types.ts # Document-model (Forme*) types + Style + │ ├── style.ts # mapStyle + CSS shorthand/color/edge/grid/transform parsing + │ ├── font.ts # Font.register() store + font merging + │ ├── canvas.ts # CanvasContext recorder for draw callbacks + │ ├── charts.ts # Chart kind builders (camelCase to snake_case prop mapping) + │ └── semantics.ts # Heading/inline-formatting defaults + list marker mapping + ├── svelte/ # Svelte 5 adapter: same components authored as .svelte files + │ └── src/ + │ ├── index.ts # Public exports + │ ├── components/ # One .svelte file per component, emitting placeholder tags + │ ├── serialize.ts # SSR render + parse entry points (serialize/render) + │ ├── parser.ts # Placeholder markup to Forme document model + │ ├── encode.ts # Props JSON round-trip (byte markers for Uint8Array) + │ ├── render-document.ts # One-call PDF rendering over optional @formepdf/core + │ └── preview/ # formePreview() SvelteKit route helper ├── react/ # JSX component library: , , , etc. │ └── src/ │ ├── index.ts # Public exports │ ├── components.tsx # Component definitions │ ├── charts.tsx # BarChart, LineChart, PieChart - │ ├── font.ts # Font.register() static API + global font store - │ ├── serialize.ts # JSX → JSON document tree + font merging + │ ├── font.ts # Re-exports the Font store from @formepdf/shared + │ ├── serialize.ts # JSX → JSON document tree │ ├── template-proxy.ts # Recording proxy for template compilation │ └── expr.ts # Expression helpers for templates ├── core/ # WASM bridge: compiles engine to WebAssembly @@ -90,7 +108,7 @@ forme/ ``` ### Renderer Package (`@formepdf/renderer`) -Shared render pipeline extracted from the CLI dev server so that VS Code (and future integrations) reuse the same bundling, font/image resolution, and WASM rendering code. Key exports: `bundle()` (esbuild TSX → JS), `resolveFonts()` / `resolveImages()` (file paths → base64), `renderPdf()` / `renderLayout()` (JS → WASM → bytes/JSON). The preview HTML (`src/preview/index.html`) supports dual mode: standalone for CLI dev server, and VS Code webview (receives messages instead of fetching endpoints). Build order: `react` → `core` → `renderer` → `cli` / `vscode`. +Shared render pipeline extracted from the CLI dev server so that VS Code (and future integrations) reuse the same bundling, font/image resolution, and WASM rendering code. Key exports: `bundle()` (esbuild TSX → JS), `resolveFonts()` / `resolveImages()` (file paths → base64), `renderPdf()` / `renderLayout()` (JS → WASM → bytes/JSON). The preview HTML (`src/preview/index.html`) supports dual mode: standalone for CLI dev server, and VS Code webview (receives messages instead of fetching endpoints). Build order: `shared` → `react` → `core` → `renderer` → `cli` / `vscode`. ### VS Code Extension (`forme-pdf`) Live PDF preview inside VS Code. Architecture: `LayoutStore` is the central event-emitting store — preview panel, component tree, and inspector all subscribe to it, staying decoupled from each other. The preview panel uses the same `index.html` from `@formepdf/renderer` (VS Code mode). Component tree (`TreeView` sidebar) shows the element hierarchy with hover-to-highlight. Inspector (`WebviewView` sidebar) shows box model visualization, computed styles, "Open in Editor" (maps element source locations to editor), and "Copy Style". The extension watches `.tsx` files and re-renders on save. @@ -106,9 +124,10 @@ cd engine && cargo fmt && cargo clippy -- -W clippy::all **TypeScript (if any `packages/` files changed):** ```bash -# Build affected packages (build order: react → core → cli) +# Build affected packages (build order: shared → react → core → cli) # Run tsc for each changed package, e.g.: cd packages/react && npm run build +cd packages/svelte && npm run build cd packages/core && npm run build cd packages/mcp && npm run build ``` @@ -221,7 +240,7 @@ Users register custom TrueType fonts via `Font.register()` (global, react-pdf co 2. **Rendering layer** (`core/index.ts` or `cli/dev.ts`): Resolves font sources to base64 before passing JSON to WASM. File paths are read from disk; `Uint8Array` is base64-encoded; data URIs pass through as-is. In the CLI dev server, file paths resolve relative to the template directory. 3. **Engine** (`lib.rs`): `register_document_fonts()` decodes base64 from each `FontEntry` and calls `FontContext.registry_mut().register()` before layout. The existing `FontRegistry`, `CustomFontMetrics`, and PDF subsetting handle everything from there. -Key files: `packages/react/src/font.ts`, `packages/react/src/serialize.ts` (mergeFonts), `packages/core/src/index.ts` (resolveFonts), `packages/cli/src/dev.ts` (resolveFontPaths), `engine/src/lib.rs` (register_document_fonts), `engine/src/model/mod.rs` (FontEntry). +Key files: `packages/shared/src/font.ts` (Font store, mergeFonts), `packages/react/src/serialize.ts`, `packages/core/src/index.ts` (resolveFonts), `packages/cli/src/dev.ts` (resolveFontPaths), `engine/src/lib.rs` (register_document_fonts), `engine/src/model/mod.rs` (FontEntry). Merge strategy: fonts are keyed by `family:weight:italic`. Document fonts override global fonts on conflict. @@ -236,8 +255,8 @@ Templates enable a hosted API workflow: store template JSON + dynamic data → p Key files: `engine/src/template.rs`, `engine/src/lib.rs` (render_template), `engine/src/wasm.rs` (render_template_pdf), `packages/react/src/template-proxy.ts`, `packages/react/src/expr.ts`, `packages/react/src/serialize.ts` (serializeTemplate), `packages/core/src/index.ts` (renderTemplate), `packages/cli/src/template-build.ts` (buildTemplate), `packages/cli/src/index.ts` (--template flag). -### CSS String Shorthands (React layer only) -Parsed in `mapStyle()` in `serialize.ts` — no engine changes needed. Three capabilities: +### CSS String Shorthands (TypeScript layer only) +Parsed in `mapStyle()` in `packages/shared/src/style.ts` - no engine changes needed. Three capabilities: 1. **Border shorthand**: `border: "1px solid #000"` → parses into `borderWidth` + `borderColor`. Per-side variants: `borderTop: "2px solid #f00"` or `borderBottom: 3` (number = width only). `parseBorderString()` tokenizes by whitespace, recognizes CSS border-style keywords (ignored), numeric tokens (width), and color tokens. 2. **Edge strings**: `padding: "8 16"` or `margin: "8 16 24 32"` → CSS 1-4 value shorthand. Optional `px` suffix stripped. `parseCSSEdges()` handles the parsing. @@ -277,7 +296,7 @@ Backward-compatible: a single family name (no comma) behaves identically to the Standard font `char_width()` in `font/metrics.rs` maps Unicode codepoints through `unicode_to_winansi()` before looking up glyph widths. Characters like em-dash (U+2014), en-dash (U+2013), smart quotes, ellipsis, etc. have Unicode code points above 255 but their widths are stored at WinAnsi positions (0x80–0x9F). The shared `unicode_to_winansi()` function in `font/metrics.rs` is also used by `PdfSerializer` for PDF text encoding — single source of truth for the Windows-1252 mapping. ### Grid repeat() Syntax -React-layer only. `expandRepeat()` in `serialize.ts` pre-processes grid template strings, expanding `repeat(N, tracks)` before the existing split-on-whitespace logic. Example: `repeat(3, 1fr)` → `1fr 1fr 1fr`. Supports mixed: `200 repeat(2, 1fr) 200` → `200 1fr 1fr 200`. +TypeScript-layer only. `expandRepeat()` in `packages/shared/src/style.ts` pre-processes grid template strings, expanding `repeat(N, tracks)` before the existing split-on-whitespace logic. Example: `repeat(3, 1fr)` → `1fr 1fr 1fr`. Supports mixed: `200 repeat(2, 1fr) 200` → `200 1fr 1fr 200`. ### Alt Text, Document Language, Clickable Images/SVGs - **Alt text**: `alt` prop on `` and `` flows through `Node.alt` → `LayoutElement.alt`. Carried through the data model for future tagged PDF support (actual `/Alt` emission requires structure elements — follow-up scope). diff --git a/docs/docs.json b/docs/docs.json index f8a7028..840e430 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -28,7 +28,7 @@ }, { "group": "Reference", - "pages": ["components", "styles", "tailwind", "fonts", "page-breaks", "charts", "templates", "embedded-data", "self-hosting"] + "pages": ["components", "styles", "tailwind", "svelte", "fonts", "page-breaks", "charts", "templates", "embedded-data", "self-hosting"] }, { "group": "API Reference", diff --git a/docs/svelte.mdx b/docs/svelte.mdx new file mode 100644 index 0000000..cf2381f --- /dev/null +++ b/docs/svelte.mdx @@ -0,0 +1,289 @@ +--- +title: Svelte +sidebarTitle: Svelte +description: Author Forme PDFs as Svelte components. The full component set in .svelte files, one-call rendering in SvelteKit endpoints, and a live preview route helper. +--- + +`@formepdf/svelte` is the Svelte adapter for Forme. +It ships the same components with the same props as `@formepdf/react`, authored as ordinary `.svelte` files, and serializes to the identical document model. +`{#each}`, `{#if}`, snippets, and text interpolation just work - templates are plain Svelte 5 components evaluated on the server, with no special template language. + +## Install + +```bash +npm install @formepdf/svelte @formepdf/core +``` + +The adapter requires Svelte 5 (`^5.30.0`) as a peer dependency. +`@formepdf/core` is an *optional* peer: it is only needed to render PDF bytes locally (`renderDocument`, the preview helper). +If you serialize templates and POST the JSON to the hosted API, skip it - `serialize` works with zero WASM. + +## Quickstart + +Create a template as a normal Svelte component, `src/lib/Invoice.svelte`: + +```svelte + + + + + Invoice + + + + Bill To + {customer} + + Invoice #{invoiceNo} + + + + {#each items as item} + + {item.name} + ${item.price.toFixed(2)} + + {/each} + + + + Total: ${total.toFixed(2)} + + + +``` + +Serve it as a PDF from a SvelteKit endpoint, `src/routes/invoice/+server.ts`: + +```ts +import { renderDocument } from '@formepdf/svelte'; +import Invoice from '$lib/Invoice.svelte'; + +export async function GET() { + const pdf = await renderDocument(Invoice, { + props: { invoiceNo: '001', customer: 'Jane Smith' }, + }); + return new Response(pdf, { headers: { 'Content-Type': 'application/pdf' } }); +} +``` + +That is the whole route. +`renderDocument` serializes the template and renders it through the WASM engine in one call. + +Using the hosted API instead? +Serialize without rendering - no `@formepdf/core` install needed: + +```ts +import { serialize } from '@formepdf/svelte'; +import Invoice from '$lib/Invoice.svelte'; + +const doc = await serialize(Invoice, { props: { invoiceNo: '001' } }); +// POST `doc` to the hosted API as JSON +``` + +## Component parity + +The adapter is 1:1 with `@formepdf/react`: the same components with the same props. + +- **Layout**: `Document`, `Page`, `View`, `Text`, `Image`, `Fixed`, `PageBreak` +- **Semantics**: `H1`-`H6`, `OrderedList`, `UnorderedList`, `ListItem`, `Strong`, `Em`, `Code`, `Link` +- **Tables**: `Table`, `Row`, `Cell` +- **Graphics**: `Svg`, `QrCode`, `Barcode`, `Canvas`, `Watermark` +- **Charts**: `BarChart`, `LineChart`, `PieChart`, `AreaChart`, `DotPlot` +- **Form fields**: `TextField`, `Checkbox`, `Dropdown`, `RadioButton` + +Everything in the [components reference](/components) and [styles reference](/styles) applies verbatim - document-level props (`metadata`, `lang`, `pdfUa`, `pdfa`, `certification`, `fonts`), CSS string shorthands (`border: "1px solid #000"`, `padding: "8 16"`), `StyleSheet.create()`, and the `Style` type are identical. +Only the syntax around the components changes. + +Nested `` spans become styled text runs, so mixed-style lines work exactly as in react: + +```svelte + + + + + Was $56.00 ${price}.00 due now + + +``` + + +Compiled templates (`forme build --template`, the `$ref`/`$each`/`$if` expression system for [rendering without a JavaScript runtime](/templates)) are **TSX-only today**. +Svelte templates always serialize by evaluating the component server-side; to use stored templates with the hosted API, author them in TSX. + + +## Page numbers + +The engine substitutes the placeholders `{{pageNumber}}` and `{{totalPages}}` in text at render time. +In JSX the braces can be typed as a string literal (`{'{{pageNumber}}'}`), but in a Svelte template they cannot - Svelte parses `{{pageNumber}}` as an expression containing an object literal, not as text. +Interpolate the exported `PAGE_NUMBER` and `TOTAL_PAGES` constants instead: + +```svelte + + + + + + Page {PAGE_NUMBER} of {TOTAL_PAGES} + + Body content + + +``` + +## Fonts + +`Font.register()` has the same API as react and feeds the same process-wide store. +Register in a `. Wrap it:
...
', + }, + Cell: { + allowed: ['Row'], + suggestion: ' must be inside a . Wrap it: ...', + }, +}; + +function validateNesting(componentName: string, parent: ParentContext): void { + const rule = VALID_PARENTS[componentName]; + if (!rule) return; + if (parent !== null && !rule.allowed.includes(parent)) { + throw new Error( + `Invalid nesting: <${componentName}> found inside <${parent}>. ${rule.suggestion}` + ); + } +} + +// ─── Unknown elements ──────────────────────────────────────────────── + +const HTML_SUGGESTIONS: Record = { + div: 'View', span: 'Text', p: 'Text', h1: 'H1', h2: 'H2', + h3: 'H3', h4: 'H4', h5: 'H5', h6: 'H6', img: 'Image', + table: 'Table', tr: 'Row', td: 'Cell', ol: 'OrderedList', + ul: 'UnorderedList', li: 'ListItem', strong: 'Strong', b: 'Strong', + em: 'Em', i: 'Em', code: 'Code', a: 'Link', +}; + +function unknownElementError(tagName: string): Error { + const suggestion = HTML_SUGGESTIONS[tagName]; + if (suggestion) { + return new Error(`HTML element <${tagName}> is not supported. Use <${suggestion}> instead.`); + } + return new Error(`Unsupported element <${tagName}> in a Forme template.`); +} + +function parseChildren(nodes: P5Node[], parent: ParentContext): FormeNode[] { + const children: FormeNode[] = []; + for (const node of nodes) { + const child = parseChild(node, parent); + if (child) children.push(child); + } + return children; +} + +// ─── Page ──────────────────────────────────────────────────────────── + +interface PageProps { + size?: string | { width: number; height: number }; + margin?: number | string | number[] | Edges; + style?: Style; + backgroundImage?: string; + backgroundOpacity?: number; + backgroundSize?: 'fill' | 'cover' | 'contain'; + backgroundPosition?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +} + +function parsePage(element: P5Element): FormeNode { + const props = decodeProps(element, 'Page') as PageProps; + + let size: FormePageSize = 'A4'; + if (props.size !== undefined) { + if (typeof props.size === 'string') { + size = props.size as FormePageSize; + } else { + size = { Custom: { width: props.size.width, height: props.size.height } }; + } + } + + let margin: FormeEdges = { top: 54, right: 54, bottom: 54, left: 54 }; + if (props.margin !== undefined) { + margin = expandEdges(props.margin); + } + + const config: FormePageConfig = { size, margin, wrap: true }; + if (props.backgroundImage !== undefined) config.backgroundImage = props.backgroundImage; + if (props.backgroundOpacity !== undefined) config.backgroundOpacity = props.backgroundOpacity; + if (props.backgroundSize !== undefined) config.backgroundSize = props.backgroundSize; + if (props.backgroundPosition !== undefined) config.backgroundPosition = props.backgroundPosition; + + return { + kind: { type: 'Page', config }, + style: props.style ? mapStyle(props.style) : {}, + children: parseChildren(element.childNodes, 'Page'), + }; +} + +// ─── View ──────────────────────────────────────────────────────────── + +interface ViewProps { + style?: Style; + wrap?: boolean; + bookmark?: string; + href?: string; +} + +function parseView(element: P5Element): FormeNode { + const props = decodeProps(element, 'View') as ViewProps; + const style = mapStyle(props.style); + if (props.wrap !== undefined) { + style.wrap = props.wrap; + } + + const node: FormeNode = { + kind: { type: 'View' }, + style, + children: parseChildren(element.childNodes, 'View'), + }; + if (props.bookmark) node.bookmark = props.bookmark; + if (props.href) node.href = props.href; + + return node; +} + +// ─── Text ──────────────────────────────────────────────────────────── + +interface TextProps { + style?: Style; + href?: string; + bookmark?: string; +} + +function parseText(element: P5Element): FormeNode { + const props = decodeProps(element, 'Text') as TextProps; + + const kind: FormeNode['kind'] = { type: 'Text', content: '' }; + const runs = textRunsOf(element.childNodes); + if (runs) { + kind.runs = runs; + } else { + kind.content = textContentOf(element.childNodes); + } + if (props.href) kind.href = props.href; + + const node: FormeNode = { + kind, + style: mapStyle(props.style), + children: [], + }; + if (props.bookmark) node.bookmark = props.bookmark; + + return node; +} + +/** Inline formatting placeholder tags and their component-default + * styles. `forme-text` carries no defaults beyond the user's style - + * same table the react adapter keeps in `inlineDefaults()`. */ +const INLINE_DEFAULTS: Record = { + 'forme-text': {}, + 'forme-strong': STRONG_DEFAULTS, + 'forme-em': EM_DEFAULTS, + 'forme-code': CODE_DEFAULTS, + 'forme-link': LINK_DEFAULTS, +}; + +const INLINE_COMPONENT_NAMES: Record = { + 'forme-strong': 'Strong', + 'forme-em': 'Em', + 'forme-code': 'Code', + 'forme-link': 'Link', +}; + +/** + * Build styled text runs from a Text (or heading) element's children, + * mirroring the react adapter's `buildTextRuns`: only used when at + * least one direct child is an inline element (``, ``, + * ``, ``, ``) - returns null otherwise. Descent + * accumulates style and href: the outer accumulated style is overlaid + * by each inline component's defaults, which are overlaid by the + * user-supplied `style` on that element, so user style wins at every + * level. `both` produces a single run with + * bold + italic. Elements other than inline components are skipped but + * still split the surrounding text into separate runs, as react's + * per-child loop does. + */ +function textRunsOf(nodes: P5Node[]): TextRun[] | null { + if (!nodes.some(n => isElement(n) && n.tagName in INLINE_DEFAULTS)) return null; + return buildRuns(nodes, {}, undefined); +} + +function buildRuns(nodes: P5Node[], accStyle: Style, accHref: string | undefined): TextRun[] { + const runs: TextRun[] = []; + let buffer = ''; + const flush = () => { + const content = cleanJsxText(buffer); + buffer = ''; + // JSX drops whitespace-only literals spanning lines but keeps + // same-line spaces between spans; cleanJsxText reproduces that, + // leaving only genuinely empty chunks to discard. + if (content === '') return; + const run: TextRun = { content }; + if (Object.keys(accStyle).length > 0) run.style = mapStyle(accStyle); + if (accHref) run.href = accHref; + runs.push(run); + }; + + for (const node of nodes) { + if (isText(node)) { + buffer += node.value; + } else if (isElement(node)) { + flush(); + const defaults = INLINE_DEFAULTS[node.tagName]; + if (defaults === undefined) continue; // unknown element inside - skip + const props = decodeProps(node, INLINE_COMPONENT_NAMES[node.tagName] ?? 'Text') as TextProps; + // Cascade: outer accumulated -> this component's defaults -> user + // style. Later spreads win, so user style overrides defaults and + // outer. + const nextStyle: Style = { ...accStyle, ...defaults, ...(props.style || {}) }; + const nextHref = props.href ?? accHref; + runs.push(...buildRuns(node.childNodes, nextStyle, nextHref)); + } + // Comments (SSR block anchors) fall through: the text around them + // stays one contiguous chunk. + } + flush(); + return runs; +} + +// ─── Headings ──────────────────────────────────────────────────────── + +interface HeadingProps { + style?: Style; + href?: string; + bookmark?: string; +} + +/** + * Parse an H1-H6 placeholder. Mirrors parseText for the children + * machinery (mixed strings + inline-formatting components produce + * runs) but emits the engine's `Heading { level }` node kind so the + * tagged-PDF builder can pick up the semantic role. Default styles per + * level are merged BEFORE the user's `style` prop, so user values win. + */ +function parseHeading(element: P5Element, level: 1 | 2 | 3 | 4 | 5 | 6): FormeNode { + const props = decodeProps(element, `H${level}`) as HeadingProps; + + const kind: FormeNode['kind'] = { type: 'Heading', level, content: '' }; + const runs = textRunsOf(element.childNodes); + if (runs) { + kind.runs = runs; + } else { + kind.content = textContentOf(element.childNodes); + } + if (props.href) kind.href = props.href; + + // Defaults underlay; user style wins on conflicting keys. + const mergedStyle: Style = { ...HEADING_DEFAULTS[level], ...(props.style || {}) }; + + const node: FormeNode = { + kind, + style: mapStyle(mergedStyle), + children: [], + }; + if (props.bookmark) node.bookmark = props.bookmark; + + return node; +} + +// ─── Lists ─────────────────────────────────────────────────────────── + +interface OrderedListProps { + type?: string; + start?: number; + style?: Style; + bookmark?: string; +} + +interface UnorderedListProps { + marker?: string; + style?: Style; + bookmark?: string; +} + +interface ListItemProps { + style?: Style; +} + +function parseList(element: P5Element, ordered: boolean): FormeNode { + const props = decodeProps( + element, + ordered ? 'OrderedList' : 'UnorderedList' + ) as OrderedListProps & UnorderedListProps; + + const markerType = ordered + ? mapListMarker(props.type, 'decimal') + : mapListMarker(props.marker, 'disc'); + + const start = typeof props.start === 'number' && props.start >= 1 ? props.start : 1; + + // Children must be ListItems - anything else is silently dropped to + // keep the serializer tolerant, matching the react adapter. (Loose + // whitespace and `{#if}` anchors between items are too common to be + // a real error.) + const children = element.childNodes + .filter((c): c is P5Element => isElement(c) && c.tagName === 'forme-list-item') + .map(c => parseListItem(c)); + + const node: FormeNode = { + kind: { type: 'List', ordered, marker_type: markerType, start }, + style: mapStyle(props.style), + children, + }; + if (props.bookmark) node.bookmark = props.bookmark; + return node; +} + +function parseListItem(element: P5Element): FormeNode { + const props = decodeProps(element, 'ListItem') as ListItemProps; + + // ListItem content is laid out by the engine using layout_children, + // so whatever the user put inside serializes as the node's children. + // Loose strings auto-wrap in a Text node via parseChild, matching + // the react adapter's convention. + return { + kind: { type: 'ListItem' }, + style: mapStyle(props.style), + children: parseChildren(element.childNodes, null), + }; +} + +/** + * Extract the merged text content of a Text element's children. + * Comments (Svelte SSR block anchors) vanish and the text around them + * is treated as one contiguous chunk, so `{#if}`/`{#each}` boundaries + * inside a `` never introduce breaks. Element children + * contribute their own descendant text (react's flattenTextContent + * recurses the same way). + */ +function textContentOf(nodes: P5Node[]): string { + return cleanJsxText(rawTextOf(nodes)); +} + +function rawTextOf(nodes: P5Node[]): string { + let merged = ''; + for (const node of nodes) { + if (isText(node)) merged += node.value; + else if (isElement(node)) merged += rawTextOf(node.childNodes); + } + return merged; +} + +// ─── Table ─────────────────────────────────────────────────────────── + +interface TableProps { + columns?: ColumnDef[]; + style?: Style; +} + +function parseTable(element: P5Element): FormeNode { + const props = decodeProps(element, 'Table') as TableProps; + const columns: FormeColumnDef[] = (props.columns ?? []).map(col => ({ + width: mapColumnWidth(col.width), + })); + + return { + kind: { type: 'Table', columns }, + style: mapStyle(props.style), + children: parseChildren(element.childNodes, 'Table'), + }; +} + +interface RowProps { + header?: boolean; + style?: Style; +} + +function parseRow(element: P5Element): FormeNode { + const props = decodeProps(element, 'Row') as RowProps; + + return { + kind: { type: 'TableRow', is_header: props.header ?? false }, + style: mapStyle(props.style), + children: parseChildren(element.childNodes, 'Row'), + }; +} + +interface CellProps { + colSpan?: number; + rowSpan?: number; + style?: Style; +} + +function parseCell(element: P5Element): FormeNode { + const props = decodeProps(element, 'Cell') as CellProps; + + return { + kind: { type: 'TableCell', col_span: props.colSpan ?? 1, row_span: props.rowSpan ?? 1 }, + style: mapStyle(props.style), + children: parseChildren(element.childNodes, 'Cell'), + }; +} + +// ─── Fixed ─────────────────────────────────────────────────────────── + +interface FixedProps { + position?: 'header' | 'footer'; + style?: Style; + bookmark?: string; +} + +function parseFixed(element: P5Element): FormeNode { + const props = decodeProps(element, 'Fixed') as FixedProps; + // Mirrors react: anything other than 'header' falls back to Footer. + const position = props.position === 'header' ? ('Header' as const) : ('Footer' as const); + + const node: FormeNode = { + kind: { type: 'Fixed', position }, + style: mapStyle(props.style), + children: parseChildren(element.childNodes, 'Fixed'), + }; + if (props.bookmark) node.bookmark = props.bookmark; + + return node; +} + +// ─── Media leaves ──────────────────────────────────────────────────── + +interface ImageProps { + src: string; + width?: number; + height?: number; + style?: Style; + href?: string; + alt?: string; +} + +function parseImage(element: P5Element): FormeNode { + const props = decodeProps(element, 'Image') as ImageProps; + + const kind: FormeNodeKind = { type: 'Image', src: props.src }; + if (props.width !== undefined) kind.width = props.width; + if (props.height !== undefined) kind.height = props.height; + + const node: FormeNode = { + kind, + style: mapStyle(props.style), + children: [], + }; + if (props.href) node.href = props.href; + if (props.alt) node.alt = props.alt; + return node; +} + +interface SvgProps { + width: number; + height: number; + viewBox?: string; + content?: string; + style?: Style; + href?: string; + alt?: string; +} + +function parseSvg(element: P5Element): FormeNode { + const props = decodeProps(element, 'Svg') as SvgProps; + + const kind: FormeNodeKind = { + type: 'Svg', + width: props.width, + height: props.height, + content: props.content ?? '', + }; + if (props.viewBox) kind.view_box = props.viewBox; + + const node: FormeNode = { + kind, + style: mapStyle(props.style), + children: [], + }; + if (props.href) node.href = props.href; + if (props.alt) node.alt = props.alt; + return node; +} + +interface QrCodeProps { + data: string; + size?: number; + color?: string; + style?: Style; +} + +function parseQrCode(element: P5Element): FormeNode { + const props = decodeProps(element, 'QrCode') as QrCodeProps; + + const kind: FormeNodeKind = { type: 'QrCode', data: props.data }; + if (props.size !== undefined) kind.size = props.size; + + const style = mapStyle(props.style); + if (props.color) style.color = parseColor(props.color); + + return { kind, style, children: [] }; +} + +interface BarcodeProps { + data: string; + format?: BarcodeFormat; + width?: number; + height?: number; + color?: string; + style?: Style; +} + +function parseBarcode(element: P5Element): FormeNode { + const props = decodeProps(element, 'Barcode') as BarcodeProps; + + const kind: FormeNodeKind = { + type: 'Barcode', + data: props.data, + format: props.format ?? 'Code128', + height: props.height ?? 60, + }; + if (props.width !== undefined) kind.width = props.width; + + const style = mapStyle(props.style); + if (props.color) style.color = parseColor(props.color); + + return { kind, style, children: [] }; +} + +// ─── Vector extras ─────────────────────────────────────────────────── + +interface CanvasProps { + width: number; + height: number; + /** Recorded by the emitting component, which executes the user's + * `draw` callback at emit time (the callback itself cannot survive + * the attribute round-trip). */ + operations: CanvasOp[]; + style?: Style; +} + +function parseCanvas(element: P5Element): FormeNode { + const props = decodeProps(element, 'Canvas') as CanvasProps; + + return { + kind: { type: 'Canvas', width: props.width, height: props.height, operations: props.operations }, + style: mapStyle(props.style), + children: [], + }; +} + +interface WatermarkProps { + text: string; + fontSize?: number; + color?: string; + angle?: number; + style?: Style; +} + +function parseWatermark(element: P5Element): FormeNode { + const props = decodeProps(element, 'Watermark') as WatermarkProps; + const fontSize = props.fontSize ?? 60; + const angle = props.angle ?? -45; + + // Mirrors react: the color's alpha channel becomes opacity (multiplied + // with any style opacity); the stored color itself is fully opaque. + const parsed = parseColor(props.color ?? 'rgba(0,0,0,0.1)'); + const style = mapStyle(props.style); + style.color = { r: parsed.r, g: parsed.g, b: parsed.b, a: 1 }; + style.opacity = parsed.a * (style.opacity ?? 1); + style.fontSize = fontSize; + + return { + kind: { type: 'Watermark', text: props.text, font_size: fontSize, angle }, + style, + children: [], + }; +} + +// ─── Form fields ───────────────────────────────────────────────────── + +interface TextFieldProps { + name: string; + value?: string; + placeholder?: string; + width: number; + height?: number; + multiline?: boolean; + password?: boolean; + readOnly?: boolean; + maxLength?: number; + fontSize?: number; + style?: Style; +} + +function parseTextField(element: P5Element): FormeNode { + const props = decodeProps(element, 'TextField') as TextFieldProps; + + const kind: FormeNodeKind = { + type: 'TextField', + name: props.name, + width: props.width, + height: props.height ?? 24, + multiline: props.multiline ?? false, + password: props.password ?? false, + read_only: props.readOnly ?? false, + font_size: props.fontSize ?? 12, + }; + if (props.value !== undefined) kind.value = props.value; + if (props.placeholder !== undefined) kind.placeholder = props.placeholder; + if (props.maxLength !== undefined) kind.max_length = props.maxLength; + + return { kind, style: mapStyle(props.style), children: [] }; +} + +interface CheckboxProps { + name: string; + checked?: boolean; + width?: number; + height?: number; + readOnly?: boolean; + style?: Style; +} + +function parseCheckbox(element: P5Element): FormeNode { + const props = decodeProps(element, 'Checkbox') as CheckboxProps; + + return { + kind: { + type: 'Checkbox', + name: props.name, + checked: props.checked ?? false, + width: props.width ?? 14, + height: props.height ?? 14, + read_only: props.readOnly ?? false, + }, + style: mapStyle(props.style), + children: [], + }; +} + +interface DropdownProps { + name: string; + options: string[]; + value?: string; + width: number; + height?: number; + readOnly?: boolean; + fontSize?: number; + style?: Style; +} + +function parseDropdown(element: P5Element): FormeNode { + const props = decodeProps(element, 'Dropdown') as DropdownProps; + + const kind: FormeNodeKind = { + type: 'Dropdown', + name: props.name, + options: props.options, + width: props.width, + height: props.height ?? 24, + read_only: props.readOnly ?? false, + font_size: props.fontSize ?? 12, + }; + if (props.value !== undefined) kind.value = props.value; + + return { kind, style: mapStyle(props.style), children: [] }; +} + +interface RadioButtonProps { + name: string; + value: string; + checked?: boolean; + width?: number; + height?: number; + readOnly?: boolean; + style?: Style; +} + +function parseRadioButton(element: P5Element): FormeNode { + const props = decodeProps(element, 'RadioButton') as RadioButtonProps; + + return { + kind: { + type: 'RadioButton', + name: props.name, + value: props.value, + checked: props.checked ?? false, + width: props.width ?? 14, + height: props.height ?? 14, + read_only: props.readOnly ?? false, + }, + style: mapStyle(props.style), + children: [], + }; +} + +// ─── Charts ────────────────────────────────────────────────────────── + +/** + * Parse one chart placeholder. The camelCase-to-snake_case prop + * mapping (and its defaults) lives in the shared kind builders, the + * same functions the react adapter serializes with, so the two + * adapters cannot drift. + */ +function parseChart

( + element: P5Element, + component: string, + buildKind: (props: P) => FormeNodeKind +): FormeNode { + const props = decodeProps(element, component) as P; + + return { + kind: buildKind(props), + style: mapStyle(props.style), + children: [], + }; +} + +// ─── Whitespace normalization ──────────────────────────────────────── + +/** + * Normalize template text to JSX-equivalent semantics - the same + * algorithm Babel, TypeScript, and esbuild apply to JSX text literals: + * + * - lines are trimmed (leading whitespace on all but the first line, + * trailing whitespace on all but the last line) and joined with a + * single space; whitespace-only lines are dropped + * - tabs count as spaces + * - only ASCII space/tab are trimmed, so non-breaking spaces survive + */ +function cleanJsxText(text: string): string { + const lines = text.split(/\r\n|\n|\r/); + let lastNonEmptyLine = 0; + for (let i = 0; i < lines.length; i++) { + if (/[^ \t]/.test(lines[i])) lastNonEmptyLine = i; + } + + let str = ''; + for (let i = 0; i < lines.length; i++) { + const isFirstLine = i === 0; + const isLastLine = i === lines.length - 1; + const isLastNonEmptyLine = i === lastNonEmptyLine; + + let trimmed = lines[i].replace(/\t/g, ' '); + if (!isFirstLine) trimmed = trimmed.replace(/^ +/, ''); + if (!isLastLine) trimmed = trimmed.replace(/ +$/, ''); + + if (trimmed) { + if (!isLastNonEmptyLine) trimmed += ' '; + str += trimmed; + } + } + return str; +} + +function findDocumentRoot(nodes: P5Node[]): P5Element { + for (const node of nodes) { + if (isElement(node) && node.tagName === 'forme-document') { + return node; + } + } + throw new Error('Top-level element must be '); +} + +/** Decode the JSON `props` attribute of a placeholder element. */ +function decodeProps(element: P5Element, component: string): unknown { + const attr = element.attrs.find(a => a.name === 'props'); + if (attr === undefined) return {}; + try { + return JSON.parse(attr.value, reviveBytesMarker) as unknown; + } catch (err) { + throw new Error( + `[Forme] <${component}>: failed to decode props attribute: ${err instanceof Error ? err.message : String(err)}` + ); + } +} diff --git a/packages/svelte/src/preview/index.ts b/packages/svelte/src/preview/index.ts new file mode 100644 index 0000000..3b5229d --- /dev/null +++ b/packages/svelte/src/preview/index.ts @@ -0,0 +1,319 @@ +/** + * SvelteKit preview route helper. + * + * `formePreview(Template)` returns a GET handler for a catch-all route + * (`src/routes//[...forme]/+server.ts`) that serves the same + * preview UI (layout overlays, click-to-inspect) the CLI dev server + * gives react users. The HTML asset is copied from @formepdf/renderer + * at build time (see scripts/embed-preview-html.mjs); serving it from a + * route instead of a dedicated server needs two serve-time adjustments: + * + * 1. The asset fetches absolute `/pdf` and `/layout`; those are + * rewritten to resolve under the mount prefix. + * 2. The asset's reload channel is a WebSocket, which a SvelteKit + * `+server.ts` cannot provide; its `connectWs` is swapped for a + * `/version` polling loop that reuses the page's own `reload()`. + * + * Rendering goes through `renderDocumentWithLayout`, so the helper adds + * zero dependencies beyond the already-optional `@formepdf/core` peer. + * The render result is cached per handler instance: under the Vite dev + * server, saving the template invalidates the module that called + * `formePreview`, so the next request builds a fresh handler (and thus + * a fresh render) while polls against an unchanged template stay cheap. + */ + +import type { Component } from 'svelte'; +import type { LayoutInfo } from '@formepdf/core'; +import { renderDocumentWithLayout } from '../render-document.js'; +import { PREVIEW_HTML } from './preview-html.generated.js'; + +/** Options for {@link formePreview}. */ +export interface FormePreviewOptions> { + /** Props passed to the template component. */ + props?: Props; + /** + * Reload poll interval in milliseconds; `0` disables polling. + * Defaults to 1000 in dev and 0 when `NODE_ENV` is `production`. + */ + pollMs?: number; +} + +/** + * The slice of SvelteKit's `RequestEvent` the handler reads. Structural, + * so the helper needs no dependency on @sveltejs/kit types. + */ +export interface PreviewRequestEvent { + url: URL; + params?: Partial>; +} + +/** Rest paths the catch-all route serves besides the preview page. */ +const RESOURCE_ENDPOINTS = ['pdf', 'layout', 'version'] as const; + +/** Endpoints the catch-all route dispatches to. */ +export type PreviewEndpoint = (typeof RESOURCE_ENDPOINTS)[number] | 'page' | 'unknown'; + +interface Rendered { + /** ArrayBuffer-backed so it satisfies `BodyInit` for `Response`. */ + pdf: Uint8Array; + layout: LayoutInfo; + version: string; +} + +/** + * Create a GET handler serving the Forme preview UI from a SvelteKit + * catch-all route. Mount it at any prefix: + * + * ```ts + * // src/routes/dev/pdf/[...forme]/+server.ts + * import { formePreview } from '@formepdf/svelte/preview'; + * import Invoice from '$lib/Invoice.svelte'; + * + * export const GET = formePreview(Invoice, { props: { ... } }); + * ``` + */ +export function formePreview>( + template: Component, + options: FormePreviewOptions = {}, +): (event: PreviewRequestEvent) => Promise { + const isProduction = + typeof process !== 'undefined' && process.env?.NODE_ENV === 'production'; + const pollMs = options.pollMs ?? (isProduction ? 0 : 1000); + + let cache: Rendered | null = null; + let inFlight: Promise | null = null; + + async function renderOnce(): Promise { + if (cache) return cache; + if (!inFlight) { + inFlight = renderDocumentWithLayout(template, { props: options.props }) + .then((result) => { + cache = { + pdf: result.pdf, + layout: result.layout, + version: hashBytes(result.pdf), + }; + return cache; + }) + .finally(() => { + inFlight = null; + }); + } + return inFlight; + } + + return async (event) => { + const { endpoint, prefix } = dispatchPreviewPath(event.url.pathname, event.params); + + if (endpoint === 'page') { + return previewResponse( + rewritePreviewHtml(PREVIEW_HTML, prefix, pollMs), + 'text/html; charset=utf-8', + ); + } + + if (endpoint === 'unknown') { + return previewResponse('Not found', 'text/plain; charset=utf-8', 404); + } + + let result: Rendered; + try { + result = await renderOnce(); + } catch (err) { + // The error message IS the dev UX (the preview page shows template + // errors in the browser), but in production a mounted preview route + // must not leak render internals. + const message = isProduction + ? 'PDF render failed' + : err instanceof Error + ? err.message + : String(err); + return previewResponse(message, 'text/plain; charset=utf-8', 503); + } + + switch (endpoint) { + case 'pdf': + return previewResponse(result.pdf, 'application/pdf'); + case 'layout': + return previewResponse(JSON.stringify(result.layout), 'application/json'); + case 'version': + return previewResponse(result.version, 'text/plain; charset=utf-8'); + } + }; +} + +/** + * Resolve a request path to a preview endpoint plus the mount prefix + * the HTML's endpoint fetches must resolve under. + * + * The catch-all param value (the only param SvelteKit can bind to an + * empty string or a multi-segment value) pins down where the mount + * prefix ends; matching it against the pathname tail keeps the helper + * agnostic to the param's name. Without params (or when no value + * matches, e.g. percent-encoded segments), known endpoint suffixes are + * sniffed instead - ambiguous only for a mount prefix itself ending in + * `/pdf`, `/layout`, or `/version`, which the param path disambiguates. + */ +export function dispatchPreviewPath( + pathname: string, + params?: Partial>, +): { endpoint: PreviewEndpoint; prefix: string } { + const path = normalizePath(pathname); + + const rest = restFromParams(path, params); + if (rest !== null) { + const prefix = rest === '' ? path : path.slice(0, path.length - rest.length - 1); + return { endpoint: endpointForRest(rest), prefix: normalizePath(prefix) }; + } + + for (const known of RESOURCE_ENDPOINTS) { + if (path.endsWith(`/${known}`)) { + return { + endpoint: known, + prefix: normalizePath(path.slice(0, path.length - known.length - 1)), + }; + } + } + return { endpoint: 'page', prefix: path }; +} + +/** Strip trailing slashes; the root path becomes the empty prefix. */ +function normalizePath(pathname: string): string { + return pathname.replace(/\/+$/, ''); +} + +/** + * Find the catch-all rest path among the route params: an empty value + * is unambiguously the catch-all (other param kinds can't be empty), + * otherwise the longest value that is a suffix of the pathname wins + * (the catch-all spans the whole tail; a shorter match would be a + * parent-segment param that happens to equal the last segment). + */ +function restFromParams( + path: string, + params?: Partial>, +): string | null { + if (!params) return null; + let best: string | null = null; + for (const value of Object.values(params)) { + if (value === undefined) continue; + if (value === '') return ''; + if (path === `/${value}` || path.endsWith(`/${value}`)) { + if (best === null || value.length > best.length) best = value; + } + } + return best; +} + +function endpointForRest(rest: string): PreviewEndpoint { + if (rest === '') return 'page'; + const known = RESOURCE_ENDPOINTS.find((endpoint) => endpoint === rest); + return known ?? 'unknown'; +} + +/** + * Serve-time adjustments to the copied preview HTML: endpoint + * fetches become prefix-relative, and the WebSocket reload channel is + * replaced by a polling loop. The polling function keeps the `connectWs` + * name so the asset's init call site stays untouched; the original body + * is parked on a never-called function to keep braces balanced. Each + * marker must occur exactly once - a miss means the renderer asset + * changed shape, and failing loudly here (covered by unit tests against + * the real asset) beats serving a preview with a dead reload channel. + */ +export function rewritePreviewHtml(html: string, prefix: string, pollMs: number): string { + const replacements: Array<[needle: string, replacement: string]> = [ + [`fetch('/pdf')`, `fetch(${JSON.stringify(`${prefix}/pdf`)})`], + [`fetch('/layout')`, `fetch(${JSON.stringify(`${prefix}/layout`)})`], + [ + `function connectWs() {`, + `${pollingScript(prefix, pollMs)}\n function __formeUnusedConnectWs() {`, + ], + ]; + + for (const [needle, replacement] of replacements) { + const first = html.indexOf(needle); + if (first === -1 || html.indexOf(needle, first + needle.length) !== -1) { + throw new Error( + `@formepdf/svelte preview: expected exactly one occurrence of ${JSON.stringify( + needle, + )} in the preview HTML asset; the copy from @formepdf/renderer may have drifted`, + ); + } + html = html.slice(0, first) + replacement + html.slice(first + needle.length); + } + return html; +} + +/** + * The polling replacement for the WebSocket channel. Runs inside the + * asset's module script, so it reuses the in-scope `reload()`, + * `zoomToFit`, `statusDot`, and `errorEl`. Version mismatches trigger a + * reload; render failures surface through the error overlay exactly + * like the WebSocket `error` message did, and the first healthy poll + * after a failure reloads to clear it. + */ +function pollingScript(prefix: string, pollMs: number): string { + if (pollMs <= 0) { + return `function connectWs() { + statusDot.className = 'status-dot connected'; + }`; + } + const versionUrl = JSON.stringify(`${prefix}/version`); + return `function connectWs() { + statusDot.className = 'status-dot connected'; + let lastVersion = null; + let hadError = false; + const showError = (message) => { + errorEl.querySelector('.error-dismiss')?.nextSibling?.remove(); + errorEl.appendChild(document.createTextNode(message)); + errorEl.style.display = 'block'; + }; + const tick = async () => { + try { + const resp = await fetch(${versionUrl}, { cache: 'no-store' }); + if (resp.ok) { + const version = await resp.text(); + statusDot.className = 'status-dot connected'; + if (hadError || (lastVersion !== null && version !== lastVersion)) { + await reload(); + setTimeout(zoomToFit, 300); + } + hadError = false; + lastVersion = version; + } else { + hadError = true; + showError(await resp.text()); + } + } catch { + statusDot.className = 'status-dot disconnected'; + } + setTimeout(tick, ${pollMs}); + }; + tick(); + }`; +} + +/** + * FNV-1a over the PDF bytes. Cheap change detection for the polling + * loop - not a content address, so the length is appended to keep + * accidental 32-bit collisions from suppressing a reload. + */ +function hashBytes(bytes: Uint8Array): string { + let hash = 0x811c9dc5; + for (let i = 0; i < bytes.length; i++) { + hash ^= bytes[i]; + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return `${hash.toString(16)}-${bytes.length.toString(16)}`; +} + +function previewResponse(body: BodyInit, contentType: string, status = 200): Response { + return new Response(body, { + status, + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/packages/svelte/src/preview/preview-html.generated.ts b/packages/svelte/src/preview/preview-html.generated.ts new file mode 100644 index 0000000..a20d04f --- /dev/null +++ b/packages/svelte/src/preview/preview-html.generated.ts @@ -0,0 +1,5 @@ +/** + * Auto-generated by scripts/embed-preview-html.mjs - do not edit. + * Source: packages/renderer/src/preview/index.html + */ +export const PREVIEW_HTML: string = "\n\n\n\n\nForme Preview\n\n\n\n\n\n

\n
\n
\n
\n
forme preview
\n
\n\n
\n\n \n\n
\n\n
\n \n \n \n \n
\n\n
\n\n
\n \n
\n
\n\n
\n
\n \n
\n \n x\n \n pt\n
\n
\n\n
\n\n
\n 0ms\n
\n
\n 0 pages\n
\n\n
\n\n
\n \n 100%\n \n \n
\n\n
\n\n \n
\n
\n\n
\n \n
\n\n
\n
\n \n \n
\n
\n
\n
\n
\n
\n
\n \n \n
\n \n
\n
\n
\n
\n\n
\n
\n
\n\n
\n\n
\n
\n
\n
\n
View
\n
0 x 0 at (0, 0)
\n
\n
\n \n
\n\n
\n
Box Model
\n
\n
\n 0\n 0\n 0\n 0\n
\n
\n 0\n 0\n 0\n 0\n
\n
\n 0\n 0\n 0\n 0\n
\n
0 x 0
\n
\n
\n\n
\n
\n
\n\n\n\n\n"; diff --git a/packages/svelte/src/render-document.ts b/packages/svelte/src/render-document.ts new file mode 100644 index 0000000..610a270 --- /dev/null +++ b/packages/svelte/src/render-document.ts @@ -0,0 +1,81 @@ +/** + * One-call rendering convenience: template in, PDF bytes out. + * + * `@formepdf/core` (the WASM engine bridge) is an *optional* peer + * dependency: `serialize`/`render` work without it, and + * hosted-API users who POST JSON never download WASM. The import is a + * bare dynamic `import('@formepdf/core')` so core's runtime-conditional + * exports (node/browser/worker) pick the right entry - no runtime + * sniffing here. Render options are forwarded to core untouched, so + * their semantics (embedData JSON-stringified onto the document, + * flattenForms, and any future options) stay identical to the react + * path. + */ + +import type { Component } from 'svelte'; +import type { + RenderDocumentOptions as CoreRenderDocumentOptions, + RenderWithLayoutResult, +} from '@formepdf/core'; +import { serialize } from './serialize.js'; +import type { SerializeOptions } from './serialize.js'; + +/** Serialization props plus `@formepdf/core`'s render options. */ +export type RenderDocumentOptions> = SerializeOptions & + CoreRenderDocumentOptions; + +/** + * `renderDocumentWithLayout` result with the PDF bytes narrowed to an + * `ArrayBuffer`-backed view, so `new Response(result.pdf)` typechecks + * against the DOM `BodyInit` in SvelteKit endpoints. + */ +export type RenderDocumentWithLayoutResult = Omit & { + pdf: Uint8Array; +}; + +async function importCore(): Promise { + try { + return await import('@formepdf/core'); + } catch (cause) { + throw new Error( + 'renderDocument requires @formepdf/core, an optional peer dependency of ' + + '@formepdf/svelte. Install it with: npm install @formepdf/core', + { cause }, + ); + } +} + +/** + * Serialize a Svelte template and render it to PDF bytes. + * The template's top-level element must be a ``. + */ +export async function renderDocument>( + template: Component, + options?: RenderDocumentOptions, +): Promise> { + const { props, ...renderOptions } = options ?? ({} as RenderDocumentOptions); + const doc = await serialize(template, { props }); + const core = await importCore(); + const pdf = await core.renderSerializedDoc(doc as unknown as Record, renderOptions); + // The WASM bridge always returns an ArrayBuffer-backed view, never a + // SharedArrayBuffer one - narrow so `new Response(pdf)` typechecks. + return pdf as Uint8Array; +} + +/** + * Like `renderDocument` but also returns layout info for overlays, + * mirroring core's react-facing `renderDocumentWithLayout`. + */ +export async function renderDocumentWithLayout>( + template: Component, + options?: RenderDocumentOptions, +): Promise { + const { props, ...renderOptions } = options ?? ({} as RenderDocumentOptions); + const doc = await serialize(template, { props }); + const core = await importCore(); + const result = await core.renderSerializedDocWithLayout( + doc as unknown as Record, + renderOptions, + ); + return result as RenderDocumentWithLayoutResult; +} diff --git a/packages/svelte/src/serialize.ts b/packages/svelte/src/serialize.ts new file mode 100644 index 0000000..b8e02b4 --- /dev/null +++ b/packages/svelte/src/serialize.ts @@ -0,0 +1,59 @@ +/** + * Serializer API: user-authored .svelte template in, document model out. + * + * Serialization runs the template through Svelte's server renderer + * (`render` from `svelte/server`, a public, stable API) and parses the + * placeholder markup the Forme components emit (see `parser.ts`). + * `{#each}`, `{#if}`, snippets, and text interpolation are evaluated + * by Svelte itself - nothing is reimplemented here. + * + * All functions are async-first: Svelte's render is synchronous today, + * but the experimental async SSR can be adopted later without a + * breaking change. Adapters serialize; only the engine renders PDFs - + * the `render` export name mirrors the react adapter's historical API + * and returns a JSON string, not PDF bytes. + */ + +import { render as renderSvelteMarkup } from 'svelte/server'; +import type { Component } from 'svelte'; +import type { FormeDocument } from '@formepdf/shared'; +import { parseMarkup } from './parser.js'; + +export interface SerializeOptions> { + /** Props passed to the template component. */ + props?: Props; +} + +/** + * Serialize a Svelte template into a Forme JSON document object. + * The template's top-level element must be a ``. + */ +export async function serialize>( + template: Component, + options?: SerializeOptions +): Promise { + const { body } = renderSvelteMarkup(template, { props: (options?.props ?? {}) as Props }); + return parseMarkup(body); +} + +/** + * Serialize a Svelte template to a Forme JSON string. + * The template's top-level element must be a ``. + */ +export async function render>( + template: Component, + options?: SerializeOptions +): Promise { + return JSON.stringify(await serialize(template, options)); +} + +/** + * Serialize a Svelte template to a Forme document object. + * The template's top-level element must be a ``. + */ +export async function renderToObject>( + template: Component, + options?: SerializeOptions +): Promise { + return serialize(template, options); +} diff --git a/packages/svelte/src/stylesheet.ts b/packages/svelte/src/stylesheet.ts new file mode 100644 index 0000000..4fb6334 --- /dev/null +++ b/packages/svelte/src/stylesheet.ts @@ -0,0 +1,7 @@ +import type { Style } from '@formepdf/shared'; + +export const StyleSheet = { + create>(styles: T): T { + return styles; + }, +}; diff --git a/packages/svelte/tests/docs-samples.test.ts b/packages/svelte/tests/docs-samples.test.ts new file mode 100644 index 0000000..e735f21 --- /dev/null +++ b/packages/svelte/tests/docs-samples.test.ts @@ -0,0 +1,128 @@ +/** + * Every code sample in docs/svelte.mdx and the package README must + * serialize - and, where the docs promise PDF bytes, render - when + * pasted into a fixture. The fixtures under tests/fixtures/docs/ are + * verbatim copies of the samples (same '@formepdf/svelte' imports, + * resolved to the source tree via the vitest alias); keep them in sync + * when the docs change. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { serialize, renderDocument, Font } from '@formepdf/svelte'; +import { formePreview } from '@formepdf/svelte/preview'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import Invoice from './fixtures/docs/invoice.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import TextRuns from './fixtures/docs/text-runs.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import PageNumbers from './fixtures/docs/page-numbers.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import Tailwind from './fixtures/docs/tailwind.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import CanvasDoc from './fixtures/docs/canvas.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import ReadmeHello from './fixtures/docs/readme-hello.svelte'; + +afterEach(() => { + Font.clear(); +}); + +function expectPdf(bytes: Uint8Array) { + expect(bytes).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe('%PDF-'); +} + +describe('docs/svelte.mdx samples', () => { + it('quickstart: Invoice.svelte serializes and the endpoint renderDocument call produces a PDF response', async () => { + const doc = await serialize(Invoice, { props: { invoiceNo: '001' } }); + expect(doc.metadata).toEqual({ title: 'Invoice #001' }); + const page = doc.children[0]; + const texts = JSON.stringify(page); + expect(texts).toContain('Website Redesign'); + expect(texts).toContain('$3500.00'); + expect(texts).toContain('Total: $4100.00'); + + // The +server.ts sample body, minus the $lib import. + const pdf = await renderDocument(Invoice, { + props: { invoiceNo: '001', customer: 'Jane Smith' }, + }); + expectPdf(pdf); + const response = new Response(pdf, { headers: { 'Content-Type': 'application/pdf' } }); + expect(response.status).toBe(200); + expect(response.headers.get('Content-Type')).toBe('application/pdf'); + }); + + it('component parity: nested spans serialize to styled text runs', async () => { + const doc = await serialize(TextRuns, { props: { price: 42 } }); + const text = doc.children[0].children[0]; + expect(text.kind.type).toBe('Text'); + const runs = (text.kind as { runs?: Array<{ content: string }> }).runs!; + expect(runs.map(r => r.content).join('')).toBe('Was $56.00 $42.00 due now'); + expect(runs.some(r => JSON.stringify(r).includes('LineThrough'))).toBe(true); + }); + + it('page numbers: PAGE_NUMBER / TOTAL_PAGES interpolate as engine placeholders', async () => { + const doc = await serialize(PageNumbers); + const json = JSON.stringify(doc); + expect(json).toContain('Page {{pageNumber}} of {{totalPages}}'); + }); + + it('fonts: + + + {}}>oops + diff --git a/packages/svelte/tests/fixtures/charts.svelte b/packages/svelte/tests/fixtures/charts.svelte new file mode 100644 index 0000000..9d95ee9 --- /dev/null +++ b/packages/svelte/tests/fixtures/charts.svelte @@ -0,0 +1,108 @@ + + + + + Quarterly dashboard + + + + + + + + + + + + + + + + diff --git a/packages/svelte/tests/fixtures/charts.tsx b/packages/svelte/tests/fixtures/charts.tsx new file mode 100644 index 0000000..ee67177 --- /dev/null +++ b/packages/svelte/tests/fixtures/charts.tsx @@ -0,0 +1,109 @@ +import type { ChartDataPoint, ChartSeries, DotPlotGroup } from '@formepdf/react'; +import { + Document, + Page, + View, + Text, + BarChart, + LineChart, + PieChart, + AreaChart, + DotPlot, +} from '@formepdf/react'; + +export interface Props { + highlight?: string; +} + +/** TSX twin of charts.svelte for cross-adapter parity tests. */ +export default function ChartsFixture({ highlight = '#ef4444' }: Props) { + const revenue: ChartDataPoint[] = [ + { label: 'Q1', value: 120 }, + { label: 'Q2', value: 80, color: highlight }, + { label: 'Q3', value: 145 }, + { label: 'Q4', value: 90 }, + ]; + const traffic: ChartDataPoint[] = [ + { label: 'Direct', value: 55, color: '#1a365d' }, + { label: 'Referral', value: 30, color: '#f59e0b' }, + { label: 'Social', value: 15, color: highlight }, + ]; + const actives: ChartSeries[] = [ + { name: '2025', data: [40, 55, 48, 70, 62, 80] }, + { name: '2026', data: [52, 60, 75, 84, 91, 108], color: '#10b981' }, + ]; + const load: ChartSeries[] = [ + { name: 'API', data: [12, 28, 19, 42, 31, 25] }, + { name: 'Web', data: [8, 14, 22, 18, 27, 33], color: '#8b5cf6' }, + ]; + const doseResponse: DotPlotGroup[] = [ + { name: 'Control', data: [[1, 4], [3, 7], [5, 9], [8, 14]] }, + { name: 'Variant', color: highlight, data: [[2, 6], [4, 11], [6, 15]] }, + ]; + const regions: ChartDataPoint[] = [ + { label: 'North', value: 34 }, + { label: 'South', value: 21 }, + ]; + const minimalGroup: DotPlotGroup[] = [{ name: 'G', data: [[1, 1], [2, 3]] }]; + + return ( + + + Quarterly dashboard + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/svelte/tests/fixtures/docs/canvas.svelte b/packages/svelte/tests/fixtures/docs/canvas.svelte new file mode 100644 index 0000000..e8ee5be --- /dev/null +++ b/packages/svelte/tests/fixtures/docs/canvas.svelte @@ -0,0 +1,16 @@ + + + + + + + diff --git a/packages/svelte/tests/fixtures/docs/fonts.svelte b/packages/svelte/tests/fixtures/docs/fonts.svelte new file mode 100644 index 0000000..e8c2852 --- /dev/null +++ b/packages/svelte/tests/fixtures/docs/fonts.svelte @@ -0,0 +1,17 @@ + + + + + + + Custom fonts + Registered once, used anywhere. + + diff --git a/packages/svelte/tests/fixtures/docs/invoice.svelte b/packages/svelte/tests/fixtures/docs/invoice.svelte new file mode 100644 index 0000000..f2fb284 --- /dev/null +++ b/packages/svelte/tests/fixtures/docs/invoice.svelte @@ -0,0 +1,52 @@ + + + + + Invoice + + + + Bill To + {customer} + + Invoice #{invoiceNo} + + + + {#each items as item} + + {item.name} + ${item.price.toFixed(2)} + + {/each} + + + + Total: ${total.toFixed(2)} + + + diff --git a/packages/svelte/tests/fixtures/docs/page-numbers.svelte b/packages/svelte/tests/fixtures/docs/page-numbers.svelte new file mode 100644 index 0000000..ab4f635 --- /dev/null +++ b/packages/svelte/tests/fixtures/docs/page-numbers.svelte @@ -0,0 +1,12 @@ + + + + + + Page {PAGE_NUMBER} of {TOTAL_PAGES} + + Body content + + diff --git a/packages/svelte/tests/fixtures/docs/readme-hello.svelte b/packages/svelte/tests/fixtures/docs/readme-hello.svelte new file mode 100644 index 0000000..d2ae2b3 --- /dev/null +++ b/packages/svelte/tests/fixtures/docs/readme-hello.svelte @@ -0,0 +1,12 @@ + + + + + Hello {name} + Page breaks that actually work. + + diff --git a/packages/svelte/tests/fixtures/docs/tailwind.svelte b/packages/svelte/tests/fixtures/docs/tailwind.svelte new file mode 100644 index 0000000..52fc44e --- /dev/null +++ b/packages/svelte/tests/fixtures/docs/tailwind.svelte @@ -0,0 +1,13 @@ + + + + + + Invoice #001 + March 2026 + + + diff --git a/packages/svelte/tests/fixtures/docs/text-runs.svelte b/packages/svelte/tests/fixtures/docs/text-runs.svelte new file mode 100644 index 0000000..a58d972 --- /dev/null +++ b/packages/svelte/tests/fixtures/docs/text-runs.svelte @@ -0,0 +1,11 @@ + + + + + Was $56.00 ${price}.00 due now + + diff --git a/packages/svelte/tests/fixtures/fixed-page-numbers.svelte b/packages/svelte/tests/fixtures/fixed-page-numbers.svelte new file mode 100644 index 0000000..3c8c8b5 --- /dev/null +++ b/packages/svelte/tests/fixtures/fixed-page-numbers.svelte @@ -0,0 +1,23 @@ + + + + + + Quarterly Report + + + Page {PAGE_NUMBER} of {TOTAL_PAGES} + + {#each Array.from({ length: paragraphs }) as _unused, i (i)} + Paragraph {i + 1}: repeated body copy that fills the page so a long document breaks across multiple pages and the footer repeats. + {/each} + + diff --git a/packages/svelte/tests/fixtures/fixed-page-numbers.tsx b/packages/svelte/tests/fixtures/fixed-page-numbers.tsx new file mode 100644 index 0000000..f5a0080 --- /dev/null +++ b/packages/svelte/tests/fixtures/fixed-page-numbers.tsx @@ -0,0 +1,24 @@ +import { Document, Page, Text, Fixed } from '@formepdf/react'; + +export interface Props { + paragraphs?: number; +} + +/** TSX twin of fixed-page-numbers.svelte for cross-adapter parity tests. */ +export default function FixedPageNumbers({ paragraphs = 5 }: Props) { + return ( + + + + Quarterly Report + + + Page {'{{pageNumber}}'} of {'{{totalPages}}'} + + {Array.from({ length: paragraphs }, (_unused, i) => ( + Paragraph {i + 1}: repeated body copy that fills the page so a long document breaks across multiple pages and the footer repeats. + ))} + + + ); +} diff --git a/packages/svelte/tests/fixtures/fonts.svelte b/packages/svelte/tests/fixtures/fonts.svelte new file mode 100644 index 0000000..d34f7d1 --- /dev/null +++ b/packages/svelte/tests/fixtures/fonts.svelte @@ -0,0 +1,29 @@ + + + + + + Custom fonts + Body text in the registered family. + Italic custom face. + + + diff --git a/packages/svelte/tests/fixtures/fonts.tsx b/packages/svelte/tests/fixtures/fonts.tsx new file mode 100644 index 0000000..c1b9c65 --- /dev/null +++ b/packages/svelte/tests/fixtures/fonts.tsx @@ -0,0 +1,30 @@ +import { Document, Page, Text, View } from '@formepdf/react'; +import type { FontRegistration } from '@formepdf/react'; + +export interface Props { + fonts?: FontRegistration[]; + bodyFamily?: string; +} + +/** TSX twin of fonts.svelte for cross-adapter parity tests. */ +export default function Fonts({ + fonts = [ + { family: 'Inter', src: 'fonts/Inter-Regular.ttf' }, + { family: 'Inter', src: 'fonts/Inter-Bold.ttf', fontWeight: 700 }, + { family: 'Custom', src: 'data:font/ttf;base64,AAAA', fontStyle: 'italic' }, + { family: 'Bytes', src: new Uint8Array([0x00, 0x01, 0x02, 0x80, 0xfe, 0xff]) }, + ], + bodyFamily = 'Inter', +}: Props) { + return ( + + + + Custom fonts + Body text in the registered family. + Italic custom face. + + + + ); +} diff --git a/packages/svelte/tests/fixtures/form-fields.svelte b/packages/svelte/tests/fixtures/form-fields.svelte new file mode 100644 index 0000000..09ea0ae --- /dev/null +++ b/packages/svelte/tests/fixtures/form-fields.svelte @@ -0,0 +1,58 @@ + + + + + Registration + + Full name + + + Bio + + + + + + + I agree to the terms + + + + Subscribe to the newsletter + + + Country + + + + Plan + + + + + + + diff --git a/packages/svelte/tests/fixtures/form-fields.tsx b/packages/svelte/tests/fixtures/form-fields.tsx new file mode 100644 index 0000000..1f9198a --- /dev/null +++ b/packages/svelte/tests/fixtures/form-fields.tsx @@ -0,0 +1,59 @@ +import { Document, Page, View, Text, TextField, Checkbox, Dropdown, RadioButton } from '@formepdf/react'; + +export interface Props { + plan?: string; +} + +/** TSX twin of form-fields.svelte for cross-adapter parity tests. */ +export default function FormFieldsFixture({ plan = 'pro' }: Props) { + return ( + + + Registration + + Full name + + + Bio + + + + + + + I agree to the terms + + + + Subscribe to the newsletter + + + Country + + + + Plan + + + + + + + + ); +} diff --git a/packages/svelte/tests/fixtures/hello-world.svelte b/packages/svelte/tests/fixtures/hello-world.svelte new file mode 100644 index 0000000..32c4a00 --- /dev/null +++ b/packages/svelte/tests/fixtures/hello-world.svelte @@ -0,0 +1,25 @@ + + + + + + Hello {name}! + {#each items as item} + Item: {item} + {/each} + {#if showFooter} + The footer + {/if} + + + diff --git a/packages/svelte/tests/fixtures/hello-world.tsx b/packages/svelte/tests/fixtures/hello-world.tsx new file mode 100644 index 0000000..cd96a57 --- /dev/null +++ b/packages/svelte/tests/fixtures/hello-world.tsx @@ -0,0 +1,24 @@ +import { Document, Page, View, Text } from '@formepdf/react'; + +export interface Props { + name?: string; + items?: string[]; + showFooter?: boolean; +} + +/** TSX twin of hello-world.svelte for cross-adapter parity tests. */ +export default function HelloWorld({ name = 'World', items = [], showFooter = false }: Props) { + return ( + + + + Hello {name}! + {items.map(item => ( + Item: {item} + ))} + {showFooter && The footer} + + + + ); +} diff --git a/packages/svelte/tests/fixtures/kitchen-sink.svelte b/packages/svelte/tests/fixtures/kitchen-sink.svelte new file mode 100644 index 0000000..b558c32 --- /dev/null +++ b/packages/svelte/tests/fixtures/kitchen-sink.svelte @@ -0,0 +1,32 @@ + + + + + + + A paragraph that spans + multiple source lines with interior spacing + and a {discount}% interpolation. + + link text + + + diff --git a/packages/svelte/tests/fixtures/kitchen-sink.tsx b/packages/svelte/tests/fixtures/kitchen-sink.tsx new file mode 100644 index 0000000..6f20923 --- /dev/null +++ b/packages/svelte/tests/fixtures/kitchen-sink.tsx @@ -0,0 +1,33 @@ +import { Document, Page, View, Text } from '@formepdf/react'; + +export interface Props { + discount?: number; +} + +/** TSX twin of kitchen-sink.svelte for cross-adapter parity tests. */ +export default function KitchenSink({ discount = 10 }: Props) { + return ( + + + + + A paragraph that spans + multiple source lines with interior spacing + and a {discount}% interpolation. + + link text + + + + ); +} diff --git a/packages/svelte/tests/fixtures/media.svelte b/packages/svelte/tests/fixtures/media.svelte new file mode 100644 index 0000000..0ce422c --- /dev/null +++ b/packages/svelte/tests/fixtures/media.svelte @@ -0,0 +1,49 @@ + + + + + + Company logo + + + + + '} + alt="Diagonal line over a square" + href="https://formepdf.com/svg" + style={{ marginBottom: 16 }} + /> + + + + + + + + + + Ticket {ticketId} + + diff --git a/packages/svelte/tests/fixtures/media.tsx b/packages/svelte/tests/fixtures/media.tsx new file mode 100644 index 0000000..8801842 --- /dev/null +++ b/packages/svelte/tests/fixtures/media.tsx @@ -0,0 +1,50 @@ +import { Document, Page, View, Text, Image, Svg, QrCode, Barcode } from '@formepdf/react'; + +export interface Props { + ticketId?: string; +} + +const logo = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +/** TSX twin of media.svelte for cross-adapter parity tests. */ +export default function MediaFixture({ ticketId = 'TKT-0042' }: Props) { + return ( + + + + Company logo + + + + + '} + alt="Diagonal line over a square" + href="https://formepdf.com/svg" + style={{ marginBottom: 16 }} + /> + + + + + + + + + + Ticket {ticketId} + + + ); +} diff --git a/packages/svelte/tests/fixtures/preview-route/[...forme]/+server.ts b/packages/svelte/tests/fixtures/preview-route/[...forme]/+server.ts new file mode 100644 index 0000000..a7c2a51 --- /dev/null +++ b/packages/svelte/tests/fixtures/preview-route/[...forme]/+server.ts @@ -0,0 +1,14 @@ +/** + * Demo SvelteKit catch-all route for the preview helper, written + * exactly as a user would mount it (mounted at /dev/pdf-preview in the + * E2E test). Tests import GET and call it with RequestEvent-shaped + * objects instead of booting a Vite server. + */ +import { formePreview } from '../../../../src/preview/index.js'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import HelloWorld from '../../hello-world.svelte'; + +export const GET = formePreview(HelloWorld, { + props: { name: 'Svelte', items: ['alpha', 'beta'], showFooter: true }, + pollMs: 500, +}); diff --git a/packages/svelte/tests/fixtures/semantics.svelte b/packages/svelte/tests/fixtures/semantics.svelte new file mode 100644 index 0000000..1c90889 --- /dev/null +++ b/packages/svelte/tests/fixtures/semantics.svelte @@ -0,0 +1,36 @@ + + + + +

Getting started with {productName}

+

Install the @formepdf/svelte package

+

Read the full docs

+
Fine print
+ Mix bold, italic, both, styled code, and a bold link in one paragraph. + + Third step + Fourth step with nested runs + + Step with a sublist + + Nested bullet + Another bullet + + + + + Default disc bullet + + + Transformed stamp + +
+
diff --git a/packages/svelte/tests/fixtures/semantics.tsx b/packages/svelte/tests/fixtures/semantics.tsx new file mode 100644 index 0000000..6166cc7 --- /dev/null +++ b/packages/svelte/tests/fixtures/semantics.tsx @@ -0,0 +1,37 @@ +import { Document, Page, H1, H2, H3, H6, OrderedList, UnorderedList, ListItem, Strong, Em, Code, Link, Text, View } from '@formepdf/react'; + +export interface Props { + productName?: string; +} + +/** TSX twin of semantics.svelte for cross-adapter parity tests. */ +export default function Semantics({ productName = 'Forme' }: Props) { + return ( + + +

Getting started with {productName}

+

Install the @formepdf/svelte package

+

Read the full docs

+
Fine print
+ Mix bold, italic, both, styled code, and a bold link in one paragraph. + + Third step + Fourth step with nested runs + + Step with a sublist + + Nested bullet + Another bullet + + + + + Default disc bullet + + + Transformed stamp + +
+
+ ); +} diff --git a/packages/svelte/tests/fixtures/table.svelte b/packages/svelte/tests/fixtures/table.svelte new file mode 100644 index 0000000..8e37533 --- /dev/null +++ b/packages/svelte/tests/fixtures/table.svelte @@ -0,0 +1,59 @@ + + + + + + + SKU + Product + Qty + Price + + {#each rows as row} + + {row.sku} + + + {row.name} + {#if row.qty > 5} + (bulk) + {/if} + + + {row.qty} + ${row.price.toFixed(2)} + + {/each} + + Total + ${total.toFixed(2)} + +
+
+
diff --git a/packages/svelte/tests/fixtures/table.tsx b/packages/svelte/tests/fixtures/table.tsx new file mode 100644 index 0000000..6fe3dc6 --- /dev/null +++ b/packages/svelte/tests/fixtures/table.tsx @@ -0,0 +1,56 @@ +import { Document, Page, View, Text, Table, Row, Cell } from '@formepdf/react'; + +export interface Props { + rowCount?: number; +} + +/** TSX twin of table.svelte for cross-adapter parity tests. */ +export default function TableFixture({ rowCount = 50 }: Props) { + const rows = Array.from({ length: rowCount }, (_, i) => ({ + sku: `SKU-${String(i + 1).padStart(3, '0')}`, + name: `Item ${i + 1}`, + qty: (i % 7) + 1, + price: ((i % 7) + 1) * 4.25, + })); + const total = rows.reduce((sum, row) => sum + row.price, 0); + + return ( + + + + + SKU + Product + Qty + Price + + {rows.map(row => ( + + {row.sku} + + + {row.name} + {row.qty > 5 && (bulk)} + + + {row.qty} + ${row.price.toFixed(2)} + + ))} + + Total + ${total.toFixed(2)} + +
+
+
+ ); +} diff --git a/packages/svelte/tests/fixtures/tailwind.svelte b/packages/svelte/tests/fixtures/tailwind.svelte new file mode 100644 index 0000000..180a268 --- /dev/null +++ b/packages/svelte/tests/fixtures/tailwind.svelte @@ -0,0 +1,26 @@ + + + + + + Utility-class styling + + Total due + {total} + + + Every style on this page comes from tw() utility classes, proving the + tailwind package needs no adapter-specific work. + + + + diff --git a/packages/svelte/tests/fixtures/tailwind.tsx b/packages/svelte/tests/fixtures/tailwind.tsx new file mode 100644 index 0000000..ec4403a --- /dev/null +++ b/packages/svelte/tests/fixtures/tailwind.tsx @@ -0,0 +1,27 @@ +import { Document, Page, Text, View } from '@formepdf/react'; +import { tw } from '@formepdf/tailwind'; + +export interface Props { + total?: string; +} + +/** TSX twin of tailwind.svelte for cross-adapter parity tests. */ +export default function Tailwind({ total = '$1,280.00' }: Props) { + return ( + + + + Utility-class styling + + Total due + {total} + + + Every style on this page comes from tw() utility classes, proving the + tailwind package needs no adapter-specific work. + + + + + ); +} diff --git a/packages/svelte/tests/fixtures/text-runs.svelte b/packages/svelte/tests/fixtures/text-runs.svelte new file mode 100644 index 0000000..980421f --- /dev/null +++ b/packages/svelte/tests/fixtures/text-runs.svelte @@ -0,0 +1,16 @@ + + + + + Was $56.00 ${price}.00 due now + Visit our site for details + + diff --git a/packages/svelte/tests/fixtures/text-runs.tsx b/packages/svelte/tests/fixtures/text-runs.tsx new file mode 100644 index 0000000..e45d368 --- /dev/null +++ b/packages/svelte/tests/fixtures/text-runs.tsx @@ -0,0 +1,17 @@ +import { Document, Page, Text } from '@formepdf/react'; + +export interface Props { + price?: number; +} + +/** TSX twin of text-runs.svelte for cross-adapter parity tests. */ +export default function TextRuns({ price = 42 }: Props) { + return ( + + + Was $56.00 ${price}.00 due now + Visit our site for details + + + ); +} diff --git a/packages/svelte/tests/fixtures/vector-extras.svelte b/packages/svelte/tests/fixtures/vector-extras.svelte new file mode 100644 index 0000000..f860fec --- /dev/null +++ b/packages/svelte/tests/fixtures/vector-extras.svelte @@ -0,0 +1,56 @@ + + + + + + + First page + + + + Second page + + + diff --git a/packages/svelte/tests/fixtures/vector-extras.tsx b/packages/svelte/tests/fixtures/vector-extras.tsx new file mode 100644 index 0000000..1d42b65 --- /dev/null +++ b/packages/svelte/tests/fixtures/vector-extras.tsx @@ -0,0 +1,57 @@ +import type { CanvasContext } from '@formepdf/react'; +import { Document, Page, View, Text, Canvas, Watermark, PageBreak } from '@formepdf/react'; + +export interface Props { + accent?: [number, number, number]; +} + +/** TSX twin of vector-extras.svelte for cross-adapter parity tests. */ +export default function VectorExtrasFixture({ accent = [59, 130, 246] }: Props) { + const draw = (ctx: CanvasContext) => { + // Fills + ctx.setFillColor(accent[0], accent[1], accent[2]); + ctx.rect(10, 10, 80, 40); + ctx.fill(); + ctx.setFillColor(16, 185, 129); + ctx.circle(150, 30, 14); + ctx.fillAndStroke(); + ctx.ellipse(150, 75, 24, 10); + ctx.fill(); + + // Paths + ctx.save(); + ctx.setStrokeColor(220, 38, 38); + ctx.setLineWidth(2); + ctx.setLineCap(1); + ctx.setLineJoin(1); + ctx.moveTo(10, 70); + ctx.bezierCurveTo(30, 40, 60, 100, 90, 70); + ctx.quadraticCurveTo(100, 60, 110, 70); + ctx.lineTo(120, 90); + ctx.closePath(); + ctx.stroke(); + ctx.restore(); + ctx.line(0, 100, 200, 100); + + // Arcs + ctx.arc(60, 60, 25, 0, Math.PI * 1.5); + ctx.stroke(); + ctx.arc(60, 60, 18, Math.PI / 4, Math.PI, true); + ctx.stroke(); + }; + + return ( + + + + + First page + + + + Second page + + + + ); +} diff --git a/packages/svelte/tests/parity.test.tsx b/packages/svelte/tests/parity.test.tsx new file mode 100644 index 0000000..f58ff6d --- /dev/null +++ b/packages/svelte/tests/parity.test.tsx @@ -0,0 +1,223 @@ +/** + * Cross-adapter parity: equivalent documents authored in TSX and + * .svelte must serialize to deep-equal document-model JSON. This is + * the drift alarm between @formepdf/react and @formepdf/svelte. + */ +import { describe, it, expect } from 'vitest'; +import { serialize as serializeReact } from '@formepdf/react'; +import { serialize, Font } from '../src/index.js'; +import HelloWorldReact from './fixtures/hello-world'; +import KitchenSinkReact from './fixtures/kitchen-sink'; +import TextRunsReact from './fixtures/text-runs'; +import FixedPageNumbersReact from './fixtures/fixed-page-numbers'; +import TableReact from './fixtures/table'; +import MediaReact from './fixtures/media'; +import VectorExtrasReact from './fixtures/vector-extras'; +import ChartsReact from './fixtures/charts'; +import FormFieldsReact from './fixtures/form-fields'; +import FontsReact from './fixtures/fonts'; +import TailwindReact from './fixtures/tailwind'; +import SemanticsReact from './fixtures/semantics'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import HelloWorldSvelte from './fixtures/hello-world.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import KitchenSinkSvelte from './fixtures/kitchen-sink.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import TextRunsSvelte from './fixtures/text-runs.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import FixedPageNumbersSvelte from './fixtures/fixed-page-numbers.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import TableSvelte from './fixtures/table.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import MediaSvelte from './fixtures/media.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import VectorExtrasSvelte from './fixtures/vector-extras.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import ChartsSvelte from './fixtures/charts.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import FormFieldsSvelte from './fixtures/form-fields.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import FontsSvelte from './fixtures/fonts.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import TailwindSvelte from './fixtures/tailwind.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import SemanticsSvelte from './fixtures/semantics.svelte'; + +/** + * Merge adjacent text runs whose style + href are identical, recursively + * through the node tree. React splits runs at JSX child boundaries + * (`${'$'}{price}.00` is three string children, so three + * runs); Svelte's SSR output merges contiguous text before the parser + * ever sees it, so those boundaries cannot be reproduced. Adjacent runs + * with equal style render identically, so parity is compared on the + * normalized form. Runs with DIFFERENT styles are never merged - real + * style drift still fails. + */ +function normalizeRuns(doc: unknown): unknown { + return JSON.parse(JSON.stringify(doc, (_key, value) => { + if ( + value !== null && typeof value === 'object' && !Array.isArray(value) && + Array.isArray((value as { runs?: unknown }).runs) + ) { + const node = value as { runs: { content: string; style?: unknown; href?: string }[] }; + const merged: typeof node.runs = []; + for (const run of node.runs) { + const prev = merged[merged.length - 1]; + if ( + prev && + JSON.stringify(prev.style) === JSON.stringify(run.style) && + prev.href === run.href + ) { + prev.content += run.content; + } else { + merged.push({ ...run }); + } + } + return { ...node, runs: merged }; + } + return value; + })); +} + +function expectParity(svelteDoc: unknown, reactDoc: unknown): void { + expect(normalizeRuns(svelteDoc)).toEqual(normalizeRuns(reactDoc)); +} + +describe('cross-adapter parity', () => { + it('hello-world: interpolation, #each/#if vs map/&&', async () => { + const props = { name: 'Svelte', items: ['alpha', 'beta'], showFooter: true }; + const svelteDoc = await serialize(HelloWorldSvelte, { props }); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('hello-world with default props', async () => { + const svelteDoc = await serialize(HelloWorldSvelte); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('kitchen-sink: document props, CSS shorthands, multi-line text', async () => { + const svelteDoc = await serialize(KitchenSinkSvelte, { props: { discount: 25 } }); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('text-runs: nested spans, run styles, boundary whitespace', async () => { + const svelteDoc = await serialize(TextRunsSvelte, { props: { price: 42 } }); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('table: 50-row #each, header row, column widths, spans, view cells', async () => { + const svelteDoc = await serialize(TableSvelte); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('media: Image src pass-through, Svg content, QrCode, Barcode defaults', async () => { + const svelteDoc = await serialize(MediaSvelte, { props: { ticketId: 'TKT-7777' } }); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('media with default props', async () => { + const svelteDoc = await serialize(MediaSvelte); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('vector-extras: Canvas draw recording, Watermark rgba/defaults, PageBreak', async () => { + const props = { accent: [239, 68, 68] as [number, number, number] }; + const svelteDoc = await serialize(VectorExtrasSvelte, { props }); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('vector-extras with default props', async () => { + const svelteDoc = await serialize(VectorExtrasSvelte); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('charts: all five types, multi-series, groups, per-datum color overrides', async () => { + const props = { highlight: '#dc2626' }; + const svelteDoc = await serialize(ChartsSvelte, { props }); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('charts with default props (defaulted chart options included)', async () => { + const svelteDoc = await serialize(ChartsSvelte); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('form-fields: TextField flags, Dropdown options, radio group', async () => { + const svelteDoc = await serialize(FormFieldsSvelte, { props: { plan: 'team' } }); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('form-fields with default props (radio group default selection)', async () => { + const svelteDoc = await serialize(FormFieldsSvelte); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('fixed-page-numbers: header/footer, placeholder constants', async () => { + const svelteDoc = await serialize(FixedPageNumbersSvelte, { props: { paragraphs: 5 } }); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('fonts: Font.register globals merge with document fonts, byte srcs intact', async () => { + // The Font store is the shared singleton: one registration is + // visible to both adapters' serializers. + Font.register({ family: 'Global', src: 'global.ttf', fontWeight: 'bold' }); + Font.register({ family: 'Inter', src: 'global-inter.ttf' }); + try { + const svelteDoc = await serialize(FontsSvelte); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + // Document fonts win the family:weight:italic conflict with globals. + const inter = svelteDoc.fonts!.find(f => f.family === 'Inter' && f.weight === 400); + expect(inter!.src).toBe('fonts/Inter-Regular.ttf'); + // Byte sources survive the markup round-trip as real byte arrays. + const bytes = svelteDoc.fonts!.find(f => f.family === 'Bytes'); + expect(bytes!.src).toBeInstanceOf(Uint8Array); + } finally { + Font.clear(); + } + }); + + it('fonts with default props (no globals registered)', async () => { + const svelteDoc = await serialize(FontsSvelte); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('tailwind: templates styled entirely with tw() classes', async () => { + const svelteDoc = await serialize(TailwindSvelte, { props: { total: '$99.00' } }); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('tailwind with default props', async () => { + const svelteDoc = await serialize(TailwindSvelte); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('semantics: headings, lists, inline formatting, transform styles', async () => { + const svelteDoc = await serialize(SemanticsSvelte, { props: { productName: 'Forme PDF' } }); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); + + it('semantics with default props', async () => { + const svelteDoc = await serialize(SemanticsSvelte); + const reactDoc = serializeReact(); + expectParity(svelteDoc, reactDoc); + }); +}); diff --git a/packages/svelte/tests/parser.test.ts b/packages/svelte/tests/parser.test.ts new file mode 100644 index 0000000..b55dd9b --- /dev/null +++ b/packages/svelte/tests/parser.test.ts @@ -0,0 +1,1278 @@ +import { describe, it, expect } from 'vitest'; +import { parseColor } from '@formepdf/shared'; +import { parseMarkup } from '../src/parser.js'; + +/** Wrap markup in a document root and parse. */ +function parseIn(inner: string) { + return parseMarkup(`${inner}`); +} + +/** Build a props attribute value from an object (as Svelte would emit it). */ +function attr(props: Record): string { + return JSON.stringify(props).replace(/&/g, '&').replace(/"/g, '"'); +} + +const DEFAULT_PAGE = { + size: 'A4', + margin: { top: 54, right: 54, bottom: 54, left: 54 }, + wrap: true, +}; + +describe('document', () => { + it('parses an empty document to the FormeDocument skeleton', () => { + const doc = parseMarkup(''); + expect(doc).toEqual({ + children: [], + metadata: {}, + defaultPage: DEFAULT_PAGE, + }); + }); + + it('tolerates SSR block anchors and whitespace around the root', () => { + const doc = parseMarkup(' \n\n'); + expect(doc.children).toEqual([]); + }); + + it('throws when the root is not a Document', () => { + expect(() => parseMarkup('')).toThrow( + 'Top-level element must be ' + ); + expect(() => parseMarkup('just text')).toThrow('Top-level element must be '); + }); +}); + +describe('page', () => { + it('parses an empty page with the default config', () => { + const doc = parseMarkup( + '' + ); + expect(doc.children).toEqual([ + { + kind: { type: 'Page', config: DEFAULT_PAGE }, + style: {}, + children: [], + }, + ]); + }); + + it('maps size string, custom size object, and margin shorthand', () => { + const props = JSON.stringify({ size: 'Letter', margin: 20 }); + const doc = parseMarkup( + `` + ); + expect(doc.children[0].kind).toEqual({ + type: 'Page', + config: { size: 'Letter', margin: { top: 20, right: 20, bottom: 20, left: 20 }, wrap: true }, + }); + + const custom = JSON.stringify({ size: { width: 400, height: 600 }, margin: [10, 20] }); + const doc2 = parseMarkup( + `` + ); + expect(doc2.children[0].kind).toEqual({ + type: 'Page', + config: { + size: { Custom: { width: 400, height: 600 } }, + margin: { top: 10, right: 20, bottom: 10, left: 20 }, + wrap: true, + }, + }); + }); + + it('passes page background props and style through', () => { + const props = JSON.stringify({ + backgroundImage: 'bg.png', + backgroundOpacity: 0.5, + backgroundSize: 'cover', + backgroundPosition: 'center', + style: { padding: 8 }, + }); + const doc = parseMarkup( + `` + ); + const kind = doc.children[0].kind as { type: 'Page'; config: Record }; + expect(kind.config.backgroundImage).toBe('bg.png'); + expect(kind.config.backgroundOpacity).toBe(0.5); + expect(kind.config.backgroundSize).toBe('cover'); + expect(kind.config.backgroundPosition).toBe('center'); + expect(doc.children[0].style).toEqual({ + padding: { top: 8, right: 8, bottom: 8, left: 8 }, + }); + }); +}); + +describe('view', () => { + it('parses a view with mapped style and nested children', () => { + const doc = parseIn( + `` + + `` + + `` + ); + expect(doc.children).toEqual([ + { + kind: { type: 'View' }, + style: { flexDirection: 'Row', gap: 8 }, + children: [{ kind: { type: 'View' }, style: {}, children: [] }], + }, + ]); + }); + + it('maps wrap into style and passes bookmark/href through', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0]).toEqual({ + kind: { type: 'View' }, + style: { wrap: false }, + children: [], + bookmark: 'Intro', + href: 'https://x.dev', + }); + }); + + it('parses CSS string shorthands in styles through mapStyle', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0].style).toEqual({ + borderWidth: { top: 1, right: 1, bottom: 1, left: 1 }, + borderColor: { + top: { r: 0, g: 0, b: 0, a: 1 }, + right: { r: 0, g: 0, b: 0, a: 1 }, + bottom: { r: 0, g: 0, b: 0, a: 1 }, + left: { r: 0, g: 0, b: 0, a: 1 }, + }, + padding: { top: 8, right: 16, bottom: 8, left: 16 }, + }); + }); +}); + +describe('text content whitespace', () => { + function textContent(inner: string): string { + const doc = parseIn(`${inner}`); + const kind = doc.children[0].kind; + if (kind.type !== 'Text') throw new Error('expected Text node'); + return kind.content; + } + + it('parses simple text content', () => { + expect(textContent('hello')).toBe('hello'); + }); + + it('joins lines with a single space (JSX semantics)', () => { + expect(textContent('Hello\nworld')).toBe('Hello world'); + expect(textContent('line one\nline two\nline three')).toBe('line one line two line three'); + }); + + it('strips line indentation when joining (interpolations split across lines)', () => { + expect(textContent('A\n B')).toBe('A B'); + }); + + it('preserves interior spaces on the same line', () => { + expect(textContent('same-line spaces kept')).toBe('same-line spaces kept'); + expect(textContent('Hello World!')).toBe('Hello World!'); + }); + + it('produces empty content for an empty element', () => { + expect(textContent('')).toBe(''); + }); + + it('drops trailing/leading whitespace-only lines', () => { + // Raw markup case (Svelte usually pre-trims fragment edges, but the + // parser owns the rule for markup it is handed directly) + expect(textContent('\n indented\n')).toBe('indented'); + expect(textContent(' \n a\n b\n ')).toBe('a b'); + }); + + it('decodes HTML entities and preserves non-breaking spaces', () => { + expect(textContent('a&b <tag>')).toBe('a&b '); + expect(textContent('x  y')).toBe('x  y'); + }); + + it('merges text across SSR block anchor comments', () => { + expect(textContent('Hello World!')).toBe('Hello World!'); + expect(textContent('AB')).toBe('AB'); + }); +}); + +describe('text node', () => { + it('maps style onto the node and puts href on the kind', () => { + const doc = parseIn( + `go` + ); + expect(doc.children[0]).toEqual({ + kind: { type: 'Text', content: 'go', href: 'https://x.dev' }, + style: { fontSize: 12 }, + children: [], + bookmark: 'B', + }); + }); +}); + +describe('text runs', () => { + function textKind(inner: string) { + const doc = parseIn(`${inner}`); + const kind = doc.children[0].kind; + if (kind.type !== 'Text') throw new Error('expected Text node'); + return kind; + } + + it('leaves runs absent for plain text content', () => { + expect(textKind('hello').runs).toBeUndefined(); + }); + + it('builds runs from nested Text spans, content stays empty', () => { + const styled = attr({ style: { textDecoration: 'line-through' } }); + expect(textKind(`Was $56.00 due now`)).toEqual({ + type: 'Text', + content: '', + runs: [ + { content: 'Was ' }, + { content: '$56.00', style: { textDecoration: 'LineThrough' } }, + { content: ' due now' }, + ], + }); + }); + + it('omits style on unstyled runs and passes href per run', () => { + const linked = attr({ href: 'https://x.dev' }); + expect( + textKind(`See plain or link`).runs + ).toEqual([ + { content: 'See ' }, + { content: 'plain' }, + { content: ' or ' }, + { content: 'link', href: 'https://x.dev' }, + ]); + }); + + it('keeps a same-line space between two spans as its own run', () => { + expect( + textKind(`a b`).runs + ).toEqual([{ content: 'a' }, { content: ' ' }, { content: 'b' }]); + }); + + it('drops whitespace-only chunks that span lines (JSX semantics)', () => { + expect( + textKind(`a\n b`).runs + ).toEqual([{ content: 'a' }, { content: 'b' }]); + }); + + it('deeper nesting produces separate runs with accumulated styles', () => { + expect( + textKind( + `x abc` + ).runs + ).toEqual([ + { content: 'x ' }, + { content: 'a' }, + { content: 'b', style: { fontWeight: 700 } }, + { content: 'c' }, + ]); + }); + + it('merges text across SSR block anchor comments within a chunk', () => { + expect( + textKind(`Total due: now`).runs + ).toEqual([{ content: 'Total due: ' }, { content: 'now' }]); + }); +}); + +describe('inline formatting', () => { + function textKind(inner: string) { + const doc = parseIn(`${inner}`); + const kind = doc.children[0].kind; + if (kind.type !== 'Text') throw new Error('expected Text kind'); + return kind; + } + + it('Strong / Em / Code carry their component defaults', () => { + expect( + textKind( + `bic` + ).runs + ).toEqual([ + { content: 'b', style: { fontWeight: 700 } }, + { content: 'i', style: { fontStyle: 'Italic' } }, + { content: 'c', style: { fontFamily: 'Courier', backgroundColor: parseColor('#F4F4F5') } }, + ]); + }); + + it('Link produces href + blue underline defaults', () => { + expect( + textKind(`go`).runs + ).toEqual([ + { + content: 'go', + style: { color: parseColor('#2563EB'), textDecoration: 'Underline' }, + href: 'https://forme.dev', + }, + ]); + }); + + it('nesting composes styles; user style wins over defaults', () => { + expect( + textKind( + `x` + ).runs + ).toEqual([{ content: 'x', style: { fontWeight: 400, fontStyle: 'Italic' } }]); + }); + + it('Link href survives an outer Strong and styles compose', () => { + expect( + textKind( + `see here` + ).runs + ).toEqual([ + { content: 'see ', style: { fontWeight: 700 } }, + { + content: 'here', + style: { fontWeight: 700, color: parseColor('#2563EB'), textDecoration: 'Underline' }, + href: 'https://x.dev', + }, + ]); + }); + + it('throws a helpful error for inline components outside ', () => { + expect(() => parseIn(`loose`)).toThrow( + ' is an inline formatting component' + ); + }); +}); + +describe('headings', () => { + it('applies level defaults under the user style', () => { + const doc = parseIn( + `Title` + ); + expect(doc.children[0]).toEqual({ + kind: { type: 'Heading', level: 1, content: 'Title' }, + style: { fontSize: 40, fontWeight: 700, margin: { top: 24, right: 0, bottom: 16, left: 0 } }, + children: [], + }); + }); + + it('each level emits its number and default font size', () => { + const sizes = [32, 24, 20, 18, 16, 14]; + for (let level = 1; level <= 6; level++) { + const doc = parseIn(`t`); + const kind = doc.children[0].kind; + if (kind.type !== 'Heading') throw new Error('expected Heading kind'); + expect(kind.level).toBe(level); + expect(doc.children[0].style.fontSize).toBe(sizes[level - 1]); + } + }); + + it('supports inline runs, href, and bookmark', () => { + const doc = parseIn( + `a b` + ); + const kind = doc.children[0].kind; + if (kind.type !== 'Heading') throw new Error('expected Heading kind'); + expect(kind.href).toBe('https://x.dev'); + expect(kind.runs).toEqual([{ content: 'a ' }, { content: 'b', style: { fontWeight: 700 } }]); + expect(doc.children[0].bookmark).toBe('B'); + }); +}); + +describe('lists', () => { + it('OrderedList defaults: ordered, decimal, start 1', () => { + const doc = parseIn( + `a` + ); + expect(doc.children[0].kind).toEqual({ + type: 'List', + ordered: true, + marker_type: 'decimal', + start: 1, + }); + expect(doc.children[0].children).toEqual([ + { + kind: { type: 'ListItem' }, + style: {}, + children: [{ kind: { type: 'Text', content: 'a' }, style: {}, children: [] }], + }, + ]); + }); + + it('maps kebab-case marker types to the engine wire enum', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0].kind).toEqual({ + type: 'List', + ordered: true, + marker_type: 'lowerRoman', + start: 3, + }); + }); + + it('UnorderedList defaults to disc and accepts marker overrides', () => { + const disc = parseIn(``); + expect(disc.children[0].kind).toEqual({ + type: 'List', + ordered: false, + marker_type: 'disc', + start: 1, + }); + const square = parseIn( + `` + ); + const kind = square.children[0].kind; + if (kind.type !== 'List') throw new Error('expected List kind'); + expect(kind.marker_type).toBe('square'); + }); + + it('drops non-ListItem children of a list silently', () => { + const doc = parseIn( + `straykept` + ); + expect(doc.children[0].children).toHaveLength(1); + }); + + it('ListItem accepts mixed content including nested lists', () => { + const doc = parseIn( + `tn` + ); + const item = doc.children[0].children[0]; + expect(item.children).toHaveLength(2); + expect(item.children[0].kind.type).toBe('Text'); + expect(item.children[1].kind.type).toBe('List'); + }); +}); + +describe('fixed', () => { + it('maps header and footer positions', () => { + const header = parseIn(``); + expect(header.children[0].kind).toEqual({ type: 'Fixed', position: 'Header' }); + + const footer = parseIn(``); + expect(footer.children[0].kind).toEqual({ type: 'Fixed', position: 'Footer' }); + }); + + it('defaults to footer when position is missing', () => { + const doc = parseIn(''); + expect(doc.children[0].kind).toEqual({ type: 'Fixed', position: 'Footer' }); + }); + + it('carries style, bookmark, and children', () => { + const doc = parseIn( + `` + + `Page {{pageNumber}} of {{totalPages}}` + + `` + ); + expect(doc.children[0]).toEqual({ + kind: { type: 'Fixed', position: 'Footer' }, + style: { padding: { top: 8, right: 0, bottom: 0, left: 0 } }, + children: [ + { + kind: { type: 'Text', content: 'Page {{pageNumber}} of {{totalPages}}' }, + style: {}, + children: [], + }, + ], + bookmark: 'F', + }); + }); +}); + +describe('element-context text', () => { + it('wraps loose text in an anonymous Text node', () => { + const doc = parseIn('loose text'); + expect(doc.children[0].children).toEqual([ + { kind: { type: 'Text', content: 'loose text' }, style: {}, children: [] }, + ]); + }); + + it('trims collapsed edge whitespace around loose text', () => { + // The Svelte compiler collapses newline+indent to a single space + // before the parser sees it + const doc = parseIn('a loose'); + expect(doc.children[0].children).toEqual([ + { kind: { type: 'Text', content: 'a' }, style: {}, children: [] }, + { kind: { type: 'Text', content: 'loose' }, style: {}, children: [] }, + ]); + }); + + it('drops whitespace-only text between elements', () => { + const doc = parseIn( + 'a b' + ); + expect(doc.children[0].children).toHaveLength(2); + }); + + it('drops loose whitespace and comments in the document context', () => { + const doc = parseMarkup( + ' ' + ); + expect(doc.children).toEqual([{ kind: { type: 'View' }, style: {}, children: [] }]); + }); +}); + +describe('document-level props', () => { + it('maps metadata props', () => { + const doc = parseMarkup( + `` + ); + expect(doc.metadata).toEqual({ title: 'T', author: 'A', subject: 'S', creator: 'C', lang: 'en-US' }); + }); + + it('maps style to defaultStyle and passes compliance flags through', () => { + const doc = parseMarkup( + `` + ); + expect(doc.defaultStyle).toEqual({ fontFamily: 'Helvetica' }); + expect(doc.tagged).toBe(true); + expect(doc.pdfa).toBe('2b'); + expect(doc.pdfUa).toBe(true); + }); + + it('omits absent document-level keys entirely', () => { + const doc = parseMarkup(''); + expect('defaultStyle' in doc).toBe(false); + expect('tagged' in doc).toBe(false); + expect('pdfa' in doc).toBe(false); + expect('pdfUa' in doc).toBe(false); + expect('certification' in doc).toBe(false); + }); + + it('passes certification through, and accepts deprecated signature with a warning', () => { + const cert = { certificatePem: 'CERT', privateKeyPem: 'KEY', reason: 'Approved' }; + const doc = parseMarkup(``); + expect(doc.certification).toEqual(cert); + + const warnings: unknown[] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => void warnings.push(args.join(' ')); + try { + const doc2 = parseMarkup(``); + expect(doc2.certification).toEqual(cert); + expect(warnings.join('\n')).toContain('signature'); + } finally { + console.warn = original; + } + }); + + it('appends loose content nodes to the last page', () => { + const doc = parseIn( + '' + + '' + + '' + ); + expect(doc.children).toHaveLength(2); + expect(doc.children[1].children).toEqual([{ kind: { type: 'View' }, style: {}, children: [] }]); + }); +}); + +describe('table', () => { + it('maps fraction, fixed, and auto column widths', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0].kind).toEqual({ + type: 'Table', + columns: [{ width: { Fraction: 0.5 } }, { width: { Fixed: 120 } }, { width: 'Auto' }], + }); + }); + + it('defaults to no columns', () => { + const doc = parseIn(''); + expect(doc.children[0].kind).toEqual({ type: 'Table', columns: [] }); + }); + + it('maps the Row header flag, defaulting to false', () => { + const doc = parseIn( + `` + + `` + + `` + + `` + ); + expect(doc.children[0].children.map(r => r.kind)).toEqual([ + { type: 'TableRow', is_header: true }, + { type: 'TableRow', is_header: false }, + ]); + }); + + it('maps Cell spans, defaulting to 1', () => { + const doc = parseIn( + `` + + `` + + `` + + `` + ); + expect(doc.children[0].children[0].children.map(c => c.kind)).toEqual([ + { type: 'TableCell', col_span: 3, row_span: 2 }, + { type: 'TableCell', col_span: 1, row_span: 1 }, + ]); + }); + + it('carries table, row, and cell styles', () => { + const doc = parseIn( + `` + + `` + + `` + + `` + ); + const table = doc.children[0]; + expect(table.style.margin).toEqual({ top: 12, right: 0, bottom: 0, left: 0 }); + expect(table.children[0].style.backgroundColor).toEqual({ r: 0.2, g: 0.2, b: 0.2, a: 1 }); + expect(table.children[0].children[0].style.padding).toEqual({ + top: 4, + right: 4, + bottom: 4, + left: 4, + }); + }); + + it('nests non-text cell content (views, nested text)', () => { + const doc = parseIn( + `` + + `` + + `badge` + + `` + + `` + ); + const cell = doc.children[0].children[0].children[0]; + expect(cell.kind).toEqual({ type: 'TableCell', col_span: 1, row_span: 1 }); + expect(cell.children).toEqual([ + { + kind: { type: 'View' }, + style: { flexDirection: 'Row' }, + children: [{ kind: { type: 'Text', content: 'badge' }, style: {}, children: [] }], + }, + ]); + }); +}); + +describe('image', () => { + it('parses a full image with dimensions, style, href, and alt', () => { + const doc = parseIn( + `` + ); + const node = doc.children[0]; + expect(node.kind).toEqual({ + type: 'Image', + src: 'data:image/png;base64,iVBORw0KGgo=', + width: 64, + height: 48, + }); + expect(node.style.margin).toEqual({ top: 8, right: 0, bottom: 0, left: 0 }); + expect(node.href).toBe('https://formepdf.com'); + expect(node.alt).toBe('Company logo'); + }); + + it('omits width/height when unset and keeps path srcs unresolved', () => { + const doc = parseIn(``); + expect(doc.children[0]).toEqual({ + kind: { type: 'Image', src: './assets/photo.jpg' }, + style: {}, + children: [], + }); + }); + + it('passes src through byte-identical, including JSON- and HTML-hostile characters', () => { + const src = 'data:image/png;base64,AB+/cd==?"quoted"&\\backé'; + const doc = parseIn(``); + expect((doc.children[0].kind as { src: string }).src).toBe(src); + }); +}); + +describe('svg', () => { + it('parses dimensions, view_box, style, href, and alt', () => { + const doc = parseIn( + `', + style: { marginBottom: 4 }, + href: 'https://formepdf.com/svg', + alt: 'A circle', + })}'>` + ); + const node = doc.children[0]; + expect(node.kind).toEqual({ + type: 'Svg', + width: 100, + height: 80, + view_box: '0 0 100 80', + content: '', + }); + expect(node.style.margin).toEqual({ top: 0, right: 0, bottom: 4, left: 0 }); + expect(node.href).toBe('https://formepdf.com/svg'); + expect(node.alt).toBe('A circle'); + }); + + it('round-trips content with quotes, angle brackets, and ampersands intact', () => { + const content = + 'a & b < c > "d"'; + const doc = parseIn(``); + expect((doc.children[0].kind as { content: string }).content).toBe(content); + }); + + it('defaults content to an empty string and omits view_box when unset', () => { + const doc = parseIn(``); + expect(doc.children[0].kind).toEqual({ type: 'Svg', width: 20, height: 20, content: '' }); + }); +}); + +describe('qrcode', () => { + it('parses data with optional size and maps color into style', () => { + const doc = parseIn( + `` + ); + const node = doc.children[0]; + expect(node.kind).toEqual({ type: 'QrCode', data: 'https://formepdf.com', size: 96 }); + expect(node.style.color).toEqual(parseColor('#1a365d')); + expect(node.children).toEqual([]); + }); + + it('omits size and color when unset', () => { + const doc = parseIn(``); + expect(doc.children[0]).toEqual({ + kind: { type: 'QrCode', data: 'plain' }, + style: {}, + children: [], + }); + }); +}); + +describe('barcode', () => { + it('parses explicit format, dimensions, and color', () => { + const doc = parseIn( + `` + ); + const node = doc.children[0]; + expect(node.kind).toEqual({ + type: 'Barcode', + data: 'TKT-0042', + format: 'Code39', + width: 220, + height: 50, + }); + expect(node.style.color).toEqual(parseColor('#333333')); + }); + + it('defaults format to Code128 and height to 60, omitting width', () => { + const doc = parseIn(``); + expect(doc.children[0]).toEqual({ + kind: { type: 'Barcode', data: 'ABC-123', format: 'Code128', height: 60 }, + style: {}, + children: [], + }); + }); +}); + +describe('canvas', () => { + it('parses dimensions, the recorded operation list, and style', () => { + const operations = [ + { op: 'SetFillColor', r: 59, g: 130, b: 246 }, + { op: 'Rect', x: 10, y: 10, width: 80, height: 40 }, + { op: 'Fill' }, + { op: 'Arc', cx: 60, cy: 60, r: 25, start_angle: 0, end_angle: 4.71, counterclockwise: false }, + { op: 'Stroke' }, + ]; + const doc = parseIn( + `` + ); + const node = doc.children[0]; + expect(node.kind).toEqual({ type: 'Canvas', width: 200, height: 110, operations }); + expect(node.style.margin).toEqual({ top: 0, right: 0, bottom: 8, left: 0 }); + expect(node.children).toEqual([]); + }); + + it('keeps an empty operation list', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0]).toEqual({ + kind: { type: 'Canvas', width: 50, height: 50, operations: [] }, + style: {}, + children: [], + }); + }); +}); + +describe('watermark', () => { + it('parses text with explicit fontSize, rgba color, and angle', () => { + const doc = parseIn( + `` + ); + const node = doc.children[0]; + expect(node.kind).toEqual({ type: 'Watermark', text: 'DRAFT', font_size: 72, angle: -30 }); + expect(node.style.color).toEqual({ r: 200 / 255, g: 30 / 255, b: 30 / 255, a: 1 }); + expect(node.style.opacity).toBeCloseTo(0.15); + expect(node.style.fontSize).toBe(72); + }); + + it('defaults fontSize 60, angle -45, color rgba(0,0,0,0.1)', () => { + const doc = parseIn(``); + const node = doc.children[0]; + expect(node.kind).toEqual({ type: 'Watermark', text: 'CONFIDENTIAL', font_size: 60, angle: -45 }); + expect(node.style.color).toEqual({ r: 0, g: 0, b: 0, a: 1 }); + expect(node.style.opacity).toBeCloseTo(0.1); + expect(node.style.fontSize).toBe(60); + }); + + it('multiplies the color alpha with an explicit style opacity', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0].style.opacity).toBeCloseTo(0.25); + }); +}); + +describe('page break', () => { + it('parses to a bare PageBreak node', () => { + const doc = parseIn(''); + expect(doc.children).toEqual([{ kind: { type: 'PageBreak' }, style: {}, children: [] }]); + }); +}); + +describe('charts', () => { + it('maps bar chart props to snake_case, keeping per-datum colors', () => { + const doc = parseIn( + `` + ); + const node = doc.children[0]; + expect(node.kind).toEqual({ + type: 'BarChart', + data: [ + { label: 'Q1', value: 120 }, + { label: 'Q2', value: 80, color: '#ef4444' }, + ], + width: 300, + height: 180, + show_labels: true, + show_values: true, + show_grid: true, + color: '#1a365d', + title: 'Revenue', + }); + expect(node.style.margin).toEqual({ top: 0, right: 0, bottom: 12, left: 0 }); + expect(node.children).toEqual([]); + }); + + it('applies bar chart defaults and omits absent optionals', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0]).toEqual({ + kind: { + type: 'BarChart', + data: [{ label: 'A', value: 1 }], + width: 200, + height: 100, + show_labels: true, + show_values: false, + show_grid: false, + }, + style: {}, + children: [], + }); + }); + + it('maps a multi-series line chart with labels and defaults', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0].kind).toEqual({ + type: 'LineChart', + series: [ + { name: '2025', data: [10, 20, 30] }, + { name: '2026', data: [15, 25, 35], color: '#10b981' }, + ], + labels: ['Jan', 'Feb', 'Mar'], + width: 400, + height: 200, + show_points: true, + show_grid: false, + }); + }); + + it('applies line chart defaults (show_points, show_grid) and omits title', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0]).toEqual({ + kind: { + type: 'LineChart', + series: [{ name: 'S', data: [1, 2] }], + labels: ['a', 'b'], + width: 200, + height: 100, + show_points: false, + show_grid: false, + }, + style: {}, + children: [], + }); + }); + + it('maps a donut pie chart with legend and title', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0].kind).toEqual({ + type: 'PieChart', + data: [ + { label: 'Direct', value: 55, color: '#1a365d' }, + { label: 'Referral', value: 45, color: '#f59e0b' }, + ], + width: 220, + height: 220, + donut: true, + show_legend: true, + title: 'Traffic', + }); + }); + + it('applies pie chart defaults (donut, show_legend) and omits title', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0]).toEqual({ + kind: { + type: 'PieChart', + data: [{ label: 'A', value: 1 }], + width: 120, + height: 120, + donut: false, + show_legend: false, + }, + style: {}, + children: [], + }); + }); + + it('maps a multi-series area chart and defaults show_grid', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0].kind).toEqual({ + type: 'AreaChart', + series: [{ name: 'Load', data: [1, 4, 2, 8] }], + labels: ['a', 'b', 'c', 'd'], + width: 400, + height: 160, + show_grid: false, + }); + }); + + it('maps grouped dot plot data with axis bounds and labels', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0].kind).toEqual({ + type: 'DotPlot', + groups: [ + { name: 'Control', data: [[1, 2], [3, 4]] }, + { name: 'Variant', color: '#ef4444', data: [[2, 3]] }, + ], + width: 300, + height: 240, + show_legend: true, + dot_size: 6, + x_min: 0, + x_max: 10, + y_min: 0, + y_max: 20, + x_label: 'Dose', + y_label: 'Response', + }); + }); + + it('applies dot plot defaults and omits absent bounds', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0]).toEqual({ + kind: { + type: 'DotPlot', + groups: [{ name: 'G', data: [[0, 0]] }], + width: 100, + height: 100, + show_legend: false, + dot_size: 4, + }, + style: {}, + children: [], + }); + }); +}); + +describe('form fields', () => { + it('parses a full text field, mapping camelCase props to snake_case', () => { + const doc = parseIn( + `` + ); + const node = doc.children[0]; + expect(node.kind).toEqual({ + type: 'TextField', + name: 'bio', + value: 'Hello!', + placeholder: 'Tell us about yourself', + width: 400, + height: 80, + multiline: true, + password: false, + read_only: true, + max_length: 500, + font_size: 10, + }); + expect(node.style).toEqual({ margin: { top: 0, right: 0, bottom: 8, left: 0 } }); + expect(node.children).toEqual([]); + }); + + it('applies text field defaults and omits absent optionals', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0]).toEqual({ + kind: { + type: 'TextField', + name: 'full_name', + width: 220, + height: 24, + multiline: false, + password: false, + read_only: false, + font_size: 12, + }, + style: {}, + children: [], + }); + }); + + it('parses a checkbox with explicit props and applies defaults', () => { + const explicit = parseIn( + `` + ); + expect(explicit.children[0].kind).toEqual({ + type: 'Checkbox', + name: 'newsletter', + checked: true, + width: 18, + height: 18, + read_only: true, + }); + + const defaulted = parseIn(``); + expect(defaulted.children[0]).toEqual({ + kind: { type: 'Checkbox', name: 'agree', checked: false, width: 14, height: 14, read_only: false }, + style: {}, + children: [], + }); + }); + + it('parses dropdown options and applies defaults', () => { + const doc = parseIn( + `` + ); + expect(doc.children[0].kind).toEqual({ + type: 'Dropdown', + name: 'country', + options: ['US', 'UK', 'CA'], + value: 'UK', + width: 200, + height: 24, + read_only: false, + font_size: 11, + }); + + const defaulted = parseIn( + `` + ); + expect(defaulted.children[0].kind).toEqual({ + type: 'Dropdown', + name: 'plan', + options: [], + width: 160, + height: 24, + read_only: false, + font_size: 12, + }); + }); + + it('parses a radio group sharing a name with one checked button', () => { + const doc = parseIn( + `` + + `` + + `` + ); + expect(doc.children.map(c => c.kind)).toEqual([ + { type: 'RadioButton', name: 'plan', value: 'free', checked: false, width: 14, height: 14, read_only: false }, + { type: 'RadioButton', name: 'plan', value: 'pro', checked: true, width: 14, height: 14, read_only: false }, + { type: 'RadioButton', name: 'plan', value: 'team', checked: false, width: 16, height: 16, read_only: false }, + ]); + }); +}); + +describe('errors', () => { + it('rejects a Page nested outside Document', () => { + expect(() => + parseIn('') + ).toThrow('Invalid nesting: found inside . must be a direct child of .'); + }); + + it('rejects a Row outside a Table and a Cell outside a Row', () => { + expect(() => + parseIn('') + ).toThrow( + 'Invalid nesting: found inside . must be inside a . Wrap it:
...
' + ); + expect(() => + parseIn('') + ).toThrow( + 'Invalid nesting: found inside . must be inside a . Wrap it: ...' + ); + }); + + it('suggests the Forme component for known HTML elements', () => { + expect(() => parseIn('
x
')).toThrow( + 'HTML element
is not supported. Use instead.' + ); + expect(() => parseIn('x')).toThrow( + 'HTML element is not supported. Use instead.' + ); + }); + + it('rejects unknown elements', () => { + expect(() => parseIn('x')).toThrow( + 'Unsupported element in a Forme template.' + ); + }); + + it('names the component when the props attribute is not valid JSON', () => { + expect(() => parseIn('x')).toThrow( + /\[Forme\] : failed to decode props/ + ); + }); +}); diff --git a/packages/svelte/tests/pdf-streams.ts b/packages/svelte/tests/pdf-streams.ts new file mode 100644 index 0000000..cdbd78a --- /dev/null +++ b/packages/svelte/tests/pdf-streams.ts @@ -0,0 +1,30 @@ +import { inflateSync } from 'node:zlib'; + +/** + * Concatenate every stream object in the PDF, inflating the + * FlateDecode-compressed ones, so text-showing operators like + * `(Page 1 of 2) Tj` become searchable. + */ +export function decompressedStreams(pdf: Uint8Array): string { + const buf = Buffer.from(pdf); + let out = ''; + let pos = 0; + for (;;) { + const start = buf.indexOf('stream', pos); + if (start === -1) break; + let dataStart = start + 'stream'.length; + if (buf[dataStart] === 0x0d) dataStart++; + if (buf[dataStart] === 0x0a) dataStart++; + let end = buf.indexOf('endstream', dataStart); + if (end === -1) break; + while (end > dataStart && (buf[end - 1] === 0x0a || buf[end - 1] === 0x0d)) end--; + const raw = buf.subarray(dataStart, end); + try { + out += inflateSync(raw).toString('latin1'); + } catch { + out += raw.toString('latin1'); + } + pos = end + 'endstream'.length; + } + return out; +} diff --git a/packages/svelte/tests/preview.test.ts b/packages/svelte/tests/preview.test.ts new file mode 100644 index 0000000..2ca823b --- /dev/null +++ b/packages/svelte/tests/preview.test.ts @@ -0,0 +1,203 @@ +/** + * Preview route helper: dispatch and HTML rewriting are unit-tested + * without a browser against the real copied asset, and the demo + * fixture route ([...forme]/+server.ts) exercises the full flow - + * template in, PDF bytes / layout JSON / version polling out - through + * @formepdf/core (devDependency, same as the WASM smoke tests). + */ +import { describe, it, expect } from 'vitest'; +import { + dispatchPreviewPath, + formePreview, + rewritePreviewHtml, +} from '../src/preview/index.js'; +import { PREVIEW_HTML } from '../src/preview/preview-html.generated.js'; +import { GET } from './fixtures/preview-route/[...forme]/+server.js'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import HelloWorld from './fixtures/hello-world.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import BadProp from './fixtures/bad-prop.svelte'; + +const MOUNT = '/dev/pdf-preview'; + +function eventFor(rest: string, base = MOUNT) { + const pathname = rest === '' ? base || '/' : `${base}/${rest}`; + return { url: new URL(`http://localhost:5173${pathname}`), params: { forme: rest } }; +} + +describe('dispatchPreviewPath', () => { + it('serves the page at the mount prefix and keeps endpoint fetches under it', () => { + expect(dispatchPreviewPath('/dev/pdf-preview', { forme: '' })).toEqual({ + endpoint: 'page', + prefix: '/dev/pdf-preview', + }); + expect(dispatchPreviewPath('/dev/pdf-preview/pdf', { forme: 'pdf' })).toEqual({ + endpoint: 'pdf', + prefix: '/dev/pdf-preview', + }); + expect(dispatchPreviewPath('/dev/pdf-preview/layout', { forme: 'layout' })).toEqual({ + endpoint: 'layout', + prefix: '/dev/pdf-preview', + }); + expect(dispatchPreviewPath('/dev/pdf-preview/version', { forme: 'version' })).toEqual({ + endpoint: 'version', + prefix: '/dev/pdf-preview', + }); + }); + + it('handles a root mount and trailing slashes', () => { + expect(dispatchPreviewPath('/', { forme: '' })).toEqual({ endpoint: 'page', prefix: '' }); + expect(dispatchPreviewPath('/pdf', { forme: 'pdf' })).toEqual({ + endpoint: 'pdf', + prefix: '', + }); + expect(dispatchPreviewPath('/dev/pdf-preview/', { forme: '' })).toEqual({ + endpoint: 'page', + prefix: '/dev/pdf-preview', + }); + }); + + it('disambiguates a mount prefix that itself ends in an endpoint name', () => { + // Mounted at /pdf/[...forme]: the page URL is /pdf, not the bytes. + expect(dispatchPreviewPath('/pdf', { forme: '' })).toEqual({ + endpoint: 'page', + prefix: '/pdf', + }); + expect(dispatchPreviewPath('/pdf/pdf', { forme: 'pdf' })).toEqual({ + endpoint: 'pdf', + prefix: '/pdf', + }); + }); + + it('resolves the catch-all among multiple route params', () => { + // /[lang]/preview/[...rest] + expect(dispatchPreviewPath('/en/preview/layout', { lang: 'en', rest: 'layout' })).toEqual({ + endpoint: 'layout', + prefix: '/en/preview', + }); + expect(dispatchPreviewPath('/en/preview', { lang: 'en', rest: '' })).toEqual({ + endpoint: 'page', + prefix: '/en/preview', + }); + }); + + it('falls back to suffix sniffing when params are absent', () => { + expect(dispatchPreviewPath('/x/pdf')).toEqual({ endpoint: 'pdf', prefix: '/x' }); + expect(dispatchPreviewPath('/x/layout')).toEqual({ endpoint: 'layout', prefix: '/x' }); + expect(dispatchPreviewPath('/x')).toEqual({ endpoint: 'page', prefix: '/x' }); + }); + + it('rejects unknown rest paths', () => { + expect(dispatchPreviewPath('/dev/pdf-preview/foo/bar', { forme: 'foo/bar' }).endpoint).toBe( + 'unknown', + ); + }); +}); + +describe('rewritePreviewHtml', () => { + it('rewrites endpoint fetches to be prefix-relative', () => { + const html = rewritePreviewHtml(PREVIEW_HTML, MOUNT, 1000); + expect(html).toContain(`fetch("${MOUNT}/pdf")`); + expect(html).toContain(`fetch("${MOUNT}/layout")`); + expect(html).not.toContain(`fetch('/pdf')`); + expect(html).not.toContain(`fetch('/layout')`); + }); + + it('replaces the WebSocket channel with version polling, keeping the init call site', () => { + const html = rewritePreviewHtml(PREVIEW_HTML, MOUNT, 500); + expect(html).toContain(`fetch("${MOUNT}/version", { cache: 'no-store' })`); + expect(html).toContain('setTimeout(tick, 500)'); + // The original body is parked, and the asset's connectWs() call + // now reaches the polling implementation. + expect(html).toContain('function __formeUnusedConnectWs() {'); + expect(html).toContain('connectWs();'); + expect(html).toMatch(/function connectWs\(\) \{\s*statusDot\.className = 'status-dot connected';/); + }); + + it('disables polling when pollMs is 0 but still neutralizes the WebSocket', () => { + const html = rewritePreviewHtml(PREVIEW_HTML, MOUNT, 0); + expect(html).not.toContain('/version'); + expect(html).toContain('function __formeUnusedConnectWs() {'); + }); + + it('fails loudly when the asset drifts and a marker disappears', () => { + expect(() => rewritePreviewHtml('', MOUNT, 1000)).toThrow( + /exactly one occurrence/, + ); + }); + + it('produces a syntactically valid module script (the connectWs swap keeps braces balanced)', async () => { + const { transformWithEsbuild } = await import('vite'); + for (const pollMs of [1000, 0]) { + const html = rewritePreviewHtml(PREVIEW_HTML, MOUNT, pollMs); + const match = html.match(/