From bcb58bf59ff21efce4933b979b4377438dc6e48a Mon Sep 17 00:00:00 2001 From: Chris Joseph <95969273+cmjoseph07@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:31:15 -0500 Subject: [PATCH 01/18] packages: extract framework-neutral serialization core into @formepdf/shared --- .github/workflows/ci.yml | 3 + CLAUDE.md | 23 +- package-lock.json | 42 +- packages/react/package.json | 3 + packages/react/src/font.ts | 35 +- packages/react/src/index.ts | 3 +- packages/react/src/serialize.ts | 1090 +----------------------------- packages/react/src/types.ts | 643 ++---------------- packages/shared/README.md | 7 + packages/shared/package.json | 30 + packages/shared/src/canvas.ts | 48 ++ packages/shared/src/font.ts | 72 ++ packages/shared/src/index.ts | 65 ++ packages/shared/src/semantics.ts | 53 ++ packages/shared/src/style.ts | 969 ++++++++++++++++++++++++++ packages/shared/src/types.ts | 586 ++++++++++++++++ packages/shared/tsconfig.json | 16 + 17 files changed, 1946 insertions(+), 1742 deletions(-) create mode 100644 packages/shared/README.md create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/canvas.ts create mode 100644 packages/shared/src/font.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/semantics.ts create mode 100644 packages/shared/src/style.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3ce19d..7852b3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ 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 diff --git a/CLAUDE.md b/CLAUDE.md index 5faefeb..a9601d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,13 +48,20 @@ 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 parsing + │ ├── font.ts # Font.register() store + font merging + │ └── canvas.ts # CanvasContext recorder for draw callbacks ├── 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 +97,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,7 +113,7 @@ 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/core && npm run build @@ -221,7 +228,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 +243,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 +284,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/package-lock.json b/package-lock.json index e6d467a..5304ae8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "forme", + "name": "forme-svelte", "lockfileVersion": 3, "requires": true, "packages": { @@ -621,7 +621,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1081,6 +1080,10 @@ "resolved": "packages/sdk", "link": true }, + "node_modules/@formepdf/shared": { + "resolved": "packages/shared", + "link": true + }, "node_modules/@formepdf/tailwind": { "resolved": "packages/tailwind", "link": true @@ -1118,7 +1121,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1141,7 +1143,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1164,7 +1165,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1181,7 +1181,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1198,7 +1197,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1215,7 +1213,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1232,7 +1229,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1249,7 +1245,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1266,7 +1261,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1283,7 +1277,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1300,7 +1293,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1317,7 +1309,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1334,7 +1325,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1357,7 +1347,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1380,7 +1369,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1403,7 +1391,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1426,7 +1413,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1449,7 +1435,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1472,7 +1457,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1495,7 +1479,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1518,7 +1501,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -1538,7 +1520,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -1558,7 +1539,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -1578,7 +1558,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -5948,6 +5927,9 @@ "name": "@formepdf/react", "version": "0.10.5", "license": "MIT", + "dependencies": { + "@formepdf/shared": "0.10.5" + }, "devDependencies": { "@types/react": "^19.0.0", "react": "^19.0.0", @@ -6008,6 +5990,14 @@ "vitest": "^3.0.0" } }, + "packages/shared": { + "name": "@formepdf/shared", + "version": "0.10.5", + "license": "MIT", + "devDependencies": { + "typescript": "^5.7.0" + } + }, "packages/tailwind": { "name": "@formepdf/tailwind", "version": "0.10.5", diff --git a/packages/react/package.json b/packages/react/package.json index e6cdecb..e2b685c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -17,6 +17,9 @@ "test": "vitest run", "test:watch": "vitest" }, + "dependencies": { + "@formepdf/shared": "0.10.5" + }, "peerDependencies": { "react": "^18 || ^19" }, diff --git a/packages/react/src/font.ts b/packages/react/src/font.ts index fe57ca5..224586e 100644 --- a/packages/react/src/font.ts +++ b/packages/react/src/font.ts @@ -1,33 +1,2 @@ -/// Options for registering a custom font. -export interface FontRegistration { - family: string; - src: string | Uint8Array; - fontWeight?: number | 'normal' | 'bold'; - fontStyle?: 'normal' | 'italic' | 'oblique'; -} - -const globalFonts: FontRegistration[] = []; - -function normalizeWeight(w?: number | string): number { - if (w === undefined || w === 'normal') return 400; - if (w === 'bold') return 700; - return typeof w === 'number' ? w : (parseInt(w, 10) || 400); -} - -export const Font = { - register(options: FontRegistration): void { - globalFonts.push({ - ...options, - fontWeight: normalizeWeight(options.fontWeight), - fontStyle: options.fontStyle || 'normal', - }); - }, - - clear(): void { - globalFonts.length = 0; - }, - - getRegistered(): FontRegistration[] { - return [...globalFonts]; - }, -}; +export { Font } from '@formepdf/shared'; +export type { FontRegistration } from '@formepdf/shared'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 405a1ce..8da5b4b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -3,7 +3,8 @@ export { Document, Page, View, Text, H1, H2, H3, H4, H5, H6, OrderedList, Unorde export { LegacyBarChart, LegacyLineChart, LegacyPieChart } from './charts.js'; // Serialization -export { serialize, serializeTemplate, mapStyle, mapDimension, parseColor, expandEdges, expandCorners } from './serialize.js'; +export { serialize, serializeTemplate } from './serialize.js'; +export { mapStyle, mapDimension, parseColor, expandEdges, expandCorners } from '@formepdf/shared'; // StyleSheet export { StyleSheet } from './stylesheet.js'; diff --git a/packages/react/src/serialize.ts b/packages/react/src/serialize.ts index 1c86c32..8f78eec 100644 --- a/packages/react/src/serialize.ts +++ b/packages/react/src/serialize.ts @@ -1,6 +1,7 @@ import { type ReactElement, type ReactNode, isValidElement, Children, Fragment } from 'react'; import { Document, Page, View, Text, H1, H2, H3, H4, H5, H6, OrderedList, UnorderedList, ListItem, Strong, Em, Code, Link, Image, Table, Row, Cell, Fixed, Svg, QrCode, Barcode, Canvas, Watermark, PageBreak, BarChart, LineChart, PieChart, AreaChart, DotPlot, TextField, Checkbox, Dropdown, RadioButton } from './components.js'; -import { Font, type FontRegistration } from './font.js'; +import { Font } from './font.js'; +import { mapStyle, parseColor, expandEdges, mapColumnWidth, mergeFonts, recordCanvasOperations, mapListMarker, HEADING_DEFAULTS, STRONG_DEFAULTS, EM_DEFAULTS, CODE_DEFAULTS, LINK_DEFAULTS } from '@formepdf/shared'; import { isRefMarker, getRefPath, isEachMarker, getEachPath, getEachTemplate, @@ -10,39 +11,20 @@ import { import type { Style, Edges, - Corners, - EdgeColors, ColumnDef, TextRun, - GridTrackSize, DocumentProps, FormeDocument, - FormeFont, FormeNode, FormeNodeKind, - FormeStyle, FormePageConfig, FormePageSize, FormeEdges, - FormeMarginEdges, FormeColumnDef, - FormeColumnWidth, - FormeDimension, - FormeColor, - FormeBoxShadow, - FormeTransformOp, FormeListMarkerType, - FormeBackground, - FormeGradientStop, - FormeEdgeValues, - FormeCornerValues, - FormeGridTrackSize, - FormeGridPlacement, QrCodeProps, BarcodeProps, CanvasProps, - CanvasOp, - CanvasContext, WatermarkProps, BarChartProps, LineChartProps, @@ -471,19 +453,6 @@ function serializeView(element: ReactElement, _parent: ParentContext = null): Fo return node; } -// Inline formatting components produce TextRuns with these default styles -// (merged under any user-supplied style, so user style wins). -const STRONG_DEFAULTS: Style = { fontWeight: 700 }; -const EM_DEFAULTS: Style = { fontStyle: 'italic' }; -const CODE_DEFAULTS: Style = { - fontFamily: 'Courier', - backgroundColor: '#F4F4F5', -}; -const LINK_DEFAULTS: Style = { - color: '#2563EB', - textDecoration: 'underline', -}; - /** Return the component-default style for a recognized inline component, * or `null` if the element isn't a recognized inline component. * `Text` returns `{}` because it carries no defaults beyond the user's style. */ @@ -571,18 +540,6 @@ function serializeText(element: ReactElement): FormeNode { return node; } -// Default style per heading level. Tuned for typical document layout — -// users override individual properties via `style` on the heading element. -// margin top/bottom are in points; font sizes are points. -const HEADING_DEFAULTS: Record<1 | 2 | 3 | 4 | 5 | 6, Style> = { - 1: { fontSize: 32, fontWeight: 700, marginTop: 24, marginBottom: 16 }, - 2: { fontSize: 24, fontWeight: 700, marginTop: 20, marginBottom: 14 }, - 3: { fontSize: 20, fontWeight: 600, marginTop: 16, marginBottom: 12 }, - 4: { fontSize: 18, fontWeight: 600, marginTop: 14, marginBottom: 10 }, - 5: { fontSize: 16, fontWeight: 600, marginTop: 12, marginBottom: 8 }, - 6: { fontSize: 14, fontWeight: 600, marginTop: 10, marginBottom: 6 }, -}; - /** * Serialize an H1-H6 element. Mirrors serializeText for the children * machinery (mixed strings + inline-formatting components produce runs) @@ -631,25 +588,6 @@ function serializeHeading( return node; } -// React `list-style-type`-shaped string → engine wire enum value. -function mapListMarker( - marker: string | undefined, - defaultValue: FormeListMarkerType, -): FormeListMarkerType { - switch (marker) { - case 'disc': return 'disc'; - case 'circle': return 'circle'; - case 'square': return 'square'; - case 'none': return 'none'; - case 'decimal': return 'decimal'; - case 'lower-alpha': return 'lowerAlpha'; - case 'upper-alpha': return 'upperAlpha'; - case 'lower-roman': return 'lowerRoman'; - case 'upper-roman': return 'upperRoman'; - default: return defaultValue; - } -} - function serializeList(element: ReactElement, ordered: boolean): FormeNode { const props = element.props as { type?: string; @@ -975,44 +913,7 @@ function serializeRadioButton(element: ReactElement): FormeNode { function serializeCanvas(element: ReactElement): FormeNode { const props = element.props as CanvasProps; - const operations: CanvasOp[] = []; - - // Create a recording context that captures draw calls as CanvasOp[] - const ctx: CanvasContext = { - moveTo(x, y) { operations.push({ op: 'MoveTo', x, y }); }, - lineTo(x, y) { operations.push({ op: 'LineTo', x, y }); }, - bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) { - operations.push({ op: 'BezierCurveTo', cp1x, cp1y, cp2x, cp2y, x, y }); - }, - quadraticCurveTo(cpx, cpy, x, y) { - operations.push({ op: 'QuadraticCurveTo', cpx, cpy, x, y }); - }, - closePath() { operations.push({ op: 'ClosePath' }); }, - rect(x, y, w, h) { operations.push({ op: 'Rect', x, y, width: w, height: h }); }, - circle(cx, cy, r) { operations.push({ op: 'Circle', cx, cy, r }); }, - ellipse(cx, cy, rx, ry) { operations.push({ op: 'Ellipse', cx, cy, rx, ry }); }, - arc(cx, cy, r, startAngle, endAngle, counterclockwise = false) { - operations.push({ op: 'Arc', cx, cy, r, start_angle: startAngle, end_angle: endAngle, counterclockwise }); - }, - line(x1, y1, x2, y2) { - operations.push({ op: 'MoveTo', x: x1, y: y1 }); - operations.push({ op: 'LineTo', x: x2, y: y2 }); - operations.push({ op: 'Stroke' }); - }, - stroke() { operations.push({ op: 'Stroke' }); }, - fill() { operations.push({ op: 'Fill' }); }, - fillAndStroke() { operations.push({ op: 'FillAndStroke' }); }, - setFillColor(r, g, b) { operations.push({ op: 'SetFillColor', r, g, b }); }, - setStrokeColor(r, g, b) { operations.push({ op: 'SetStrokeColor', r, g, b }); }, - setLineWidth(w) { operations.push({ op: 'SetLineWidth', width: w }); }, - setLineCap(cap) { operations.push({ op: 'SetLineCap', cap }); }, - setLineJoin(join) { operations.push({ op: 'SetLineJoin', join }); }, - save() { operations.push({ op: 'Save' }); }, - restore() { operations.push({ op: 'Restore' }); }, - }; - - // Execute the draw callback to record operations - props.draw(ctx); + const operations = recordCanvasOperations(props.draw); return { kind: { type: 'Canvas', width: props.width, height: props.height, operations }, @@ -1210,991 +1111,6 @@ function flattenTextContent(children: unknown): string { return String(children); } -// ─── Style mapping ────────────────────────────────────────────────── - -const FLEX_DIRECTION_MAP: Record = { - 'row': 'Row', - 'column': 'Column', - 'row-reverse': 'RowReverse', - 'column-reverse': 'ColumnReverse', -}; - -const JUSTIFY_CONTENT_MAP: Record = { - 'flex-start': 'FlexStart', - 'flex-end': 'FlexEnd', - 'center': 'Center', - 'space-between': 'SpaceBetween', - 'space-around': 'SpaceAround', - 'space-evenly': 'SpaceEvenly', -}; - -const ALIGN_ITEMS_MAP: Record = { - 'flex-start': 'FlexStart', - 'flex-end': 'FlexEnd', - 'center': 'Center', - 'stretch': 'Stretch', - 'baseline': 'Baseline', -}; - -const FLEX_WRAP_MAP: Record = { - 'nowrap': 'NoWrap', - 'wrap': 'Wrap', - 'wrap-reverse': 'WrapReverse', -}; - -const ALIGN_CONTENT_MAP: Record = { - 'flex-start': 'FlexStart', - 'flex-end': 'FlexEnd', - 'center': 'Center', - 'space-between': 'SpaceBetween', - 'space-around': 'SpaceAround', - 'space-evenly': 'SpaceEvenly', - 'stretch': 'Stretch', -}; - -const FONT_STYLE_MAP: Record = { - 'normal': 'Normal', - 'italic': 'Italic', - 'oblique': 'Oblique', -}; - -const TEXT_ALIGN_MAP: Record = { - 'left': 'Left', - 'right': 'Right', - 'center': 'Center', - 'justify': 'Justify', -}; - -const TEXT_DECORATION_MAP: Record = { - 'none': 'None', - 'underline': 'Underline', - 'line-through': 'LineThrough', -}; - -const TEXT_TRANSFORM_MAP: Record = { - 'none': 'None', - 'uppercase': 'Uppercase', - 'lowercase': 'Lowercase', - 'capitalize': 'Capitalize', -}; - -const HYPHENS_MAP: Record = { - 'none': 'none', - 'manual': 'manual', - 'auto': 'auto', -}; - -const TEXT_OVERFLOW_MAP: Record = { - 'wrap': 'Wrap', - 'ellipsis': 'Ellipsis', - 'clip': 'Clip', -}; - -const LINE_BREAKING_MAP: Record = { - 'optimal': 'optimal', - 'greedy': 'greedy', -}; - -const OVERFLOW_MAP: Record = { - 'visible': 'Visible', - 'hidden': 'Hidden', -}; - -export function mapStyle(style?: Style): FormeStyle { - if (!style) return {}; - - const result: FormeStyle = {}; - - // Dimensions - if (style.width !== undefined) result.width = mapDimension(style.width); - if (style.height !== undefined) result.height = mapDimension(style.height); - if (style.minWidth !== undefined) result.minWidth = mapDimension(style.minWidth); - if (style.minHeight !== undefined) result.minHeight = mapDimension(style.minHeight); - if (style.maxWidth !== undefined) result.maxWidth = mapDimension(style.maxWidth); - if (style.maxHeight !== undefined) result.maxHeight = mapDimension(style.maxHeight); - - // Edges (individual > axis > base) - if (style.padding !== undefined || style.paddingTop !== undefined || style.paddingRight !== undefined || style.paddingBottom !== undefined || style.paddingLeft !== undefined || style.paddingHorizontal !== undefined || style.paddingVertical !== undefined) { - const base = style.padding !== undefined ? expandEdges(style.padding) : { top: 0, right: 0, bottom: 0, left: 0 }; - const vt = style.paddingVertical ?? base.top; - const vb = style.paddingVertical ?? base.bottom; - const hl = style.paddingHorizontal ?? base.left; - const hr = style.paddingHorizontal ?? base.right; - result.padding = { - top: style.paddingTop ?? vt, - right: style.paddingRight ?? hr, - bottom: style.paddingBottom ?? vb, - left: style.paddingLeft ?? hl, - }; - } - if (style.margin !== undefined || style.marginTop !== undefined || style.marginRight !== undefined || style.marginBottom !== undefined || style.marginLeft !== undefined || style.marginHorizontal !== undefined || style.marginVertical !== undefined) { - const base: FormeMarginEdges = style.margin !== undefined ? expandMarginEdges(style.margin) : { top: 0, right: 0, bottom: 0, left: 0 }; - const vt: number | 'auto' = style.marginVertical ?? base.top; - const vb: number | 'auto' = style.marginVertical ?? base.bottom; - const hl: number | 'auto' = style.marginHorizontal ?? base.left; - const hr: number | 'auto' = style.marginHorizontal ?? base.right; - result.margin = { - top: style.marginTop ?? vt, - right: style.marginRight ?? hr, - bottom: style.marginBottom ?? vb, - left: style.marginLeft ?? hl, - }; - } - - // Flex shorthand: flex: N → flexGrow: N, flexShrink: 1, flexBasis: 0 - if (style.flex !== undefined) { - if (style.flexGrow === undefined) result.flexGrow = style.flex; - if (style.flexShrink === undefined) result.flexShrink = 1; - if (style.flexBasis === undefined) result.flexBasis = { Pt: 0 }; - } - - // Flex - if (style.flexDirection !== undefined) result.flexDirection = FLEX_DIRECTION_MAP[style.flexDirection]; - if (style.justifyContent !== undefined) result.justifyContent = JUSTIFY_CONTENT_MAP[style.justifyContent]; - if (style.alignItems !== undefined) result.alignItems = ALIGN_ITEMS_MAP[style.alignItems]; - if (style.alignSelf !== undefined) result.alignSelf = ALIGN_ITEMS_MAP[style.alignSelf]; - if (style.flexWrap !== undefined) result.flexWrap = FLEX_WRAP_MAP[style.flexWrap]; - if (style.alignContent !== undefined) result.alignContent = ALIGN_CONTENT_MAP[style.alignContent]; - if (style.flexGrow !== undefined) result.flexGrow = style.flexGrow; - if (style.flexShrink !== undefined) result.flexShrink = style.flexShrink; - if (style.flexBasis !== undefined) result.flexBasis = mapDimension(style.flexBasis); - if (style.gap !== undefined) result.gap = style.gap; - if (style.rowGap !== undefined) result.rowGap = style.rowGap; - if (style.columnGap !== undefined) result.columnGap = style.columnGap; - - // Display mode - if (style.display !== undefined) { - result.display = style.display === 'grid' ? 'Grid' : 'Flex'; - } - - // CSS Grid - if (style.gridTemplateColumns !== undefined) { - result.gridTemplateColumns = parseGridTemplate(style.gridTemplateColumns); - } - if (style.gridTemplateRows !== undefined) { - result.gridTemplateRows = parseGridTemplate(style.gridTemplateRows); - } - if (style.gridAutoRows !== undefined) { - result.gridAutoRows = mapGridTrack(style.gridAutoRows); - } - if (style.gridAutoColumns !== undefined) { - result.gridAutoColumns = mapGridTrack(style.gridAutoColumns); - } - // Grid placement (individual props → single gridPlacement object) - if (style.gridColumnStart !== undefined || style.gridColumnEnd !== undefined || - style.gridRowStart !== undefined || style.gridRowEnd !== undefined || - style.gridColumnSpan !== undefined || style.gridRowSpan !== undefined) { - const placement: FormeGridPlacement = {}; - if (style.gridColumnStart !== undefined) placement.columnStart = style.gridColumnStart; - if (style.gridColumnEnd !== undefined) placement.columnEnd = style.gridColumnEnd; - if (style.gridRowStart !== undefined) placement.rowStart = style.gridRowStart; - if (style.gridRowEnd !== undefined) placement.rowEnd = style.gridRowEnd; - if (style.gridColumnSpan !== undefined) placement.columnSpan = style.gridColumnSpan; - if (style.gridRowSpan !== undefined) placement.rowSpan = style.gridRowSpan; - result.gridPlacement = placement; - } - - // Typography - if (style.fontFamily !== undefined) result.fontFamily = style.fontFamily; - if (style.fontSize !== undefined) result.fontSize = style.fontSize; - if (style.fontWeight !== undefined) { - result.fontWeight = style.fontWeight === 'bold' ? 700 : style.fontWeight === 'normal' ? 400 : style.fontWeight; - } - if (style.fontStyle !== undefined) result.fontStyle = FONT_STYLE_MAP[style.fontStyle]; - if (style.lineHeight !== undefined) result.lineHeight = style.lineHeight; - if (style.textAlign !== undefined) result.textAlign = TEXT_ALIGN_MAP[style.textAlign]; - if (style.letterSpacing !== undefined) result.letterSpacing = style.letterSpacing; - if (style.wordSpacing !== undefined) result.wordSpacing = style.wordSpacing; - if (style.boxShadow !== undefined) { - const parsed = parseBoxShadow(style.boxShadow); - if (parsed) result.boxShadow = parsed; - } - if (style.transform !== undefined) { - const parsed = parseTransform(style.transform); - if (parsed && parsed.length > 0) result.transform = parsed; - } - if (style.transformOrigin !== undefined) { - const parsed = parseTransformOrigin(style.transformOrigin); - if (parsed) result.transformOrigin = parsed; - } - if (style.textDecoration !== undefined) result.textDecoration = TEXT_DECORATION_MAP[style.textDecoration]; - if (style.textTransform !== undefined) result.textTransform = TEXT_TRANSFORM_MAP[style.textTransform]; - if (style.hyphens !== undefined) result.hyphens = HYPHENS_MAP[style.hyphens]; - if (style.lang !== undefined) result.lang = style.lang; - if (style.direction !== undefined) result.direction = style.direction; - if (style.textOverflow !== undefined) result.textOverflow = TEXT_OVERFLOW_MAP[style.textOverflow]; - if (style.lineBreaking !== undefined) result.lineBreaking = LINE_BREAKING_MAP[style.lineBreaking]; - if (style.overflow !== undefined) result.overflow = OVERFLOW_MAP[style.overflow]; - - // Color - if (style.color !== undefined) result.color = parseColor(style.color); - if (style.backgroundColor !== undefined) result.backgroundColor = parseColor(style.backgroundColor); - if (style.background !== undefined) { - const parsed = parseBackground(style.background); - if (parsed) { - if (parsed.type === 'color') { - // Solid color string in `background`: route to backgroundColor for - // engine compatibility (Background::Color also works, but - // backgroundColor is the canonical solid path). - if (result.backgroundColor === undefined) result.backgroundColor = parsed.value; - } else { - result.background = parsed; - } - } - } - if (style.opacity !== undefined) result.opacity = style.opacity; - - // Border — cascade: border < borderTop/Right/Bottom/Left < borderWidth/borderColor < borderTopWidth/borderTopColor - // Step 1: Parse string shorthands into intermediate per-side values - let shortWidth: FormeEdgeValues = { top: undefined, right: undefined, bottom: undefined, left: undefined }; - let shortColor: FormeEdgeValues = { top: undefined, right: undefined, bottom: undefined, left: undefined }; - - if (style.border !== undefined) { - const parsed = parseBorderString(style.border); - if (parsed.width !== undefined) shortWidth = { top: parsed.width, right: parsed.width, bottom: parsed.width, left: parsed.width }; - if (parsed.color !== undefined) shortColor = { top: parsed.color, right: parsed.color, bottom: parsed.color, left: parsed.color }; - } - - // Per-side string shorthands override all-side shorthand - for (const [side, prop] of [['top', 'borderTop'], ['right', 'borderRight'], ['bottom', 'borderBottom'], ['left', 'borderLeft']] as const) { - const val = style[prop]; - if (val === undefined) continue; - if (typeof val === 'number') { - shortWidth[side] = val; - } else { - const parsed = parseBorderString(val); - if (parsed.width !== undefined) shortWidth[side] = parsed.width; - if (parsed.color !== undefined) shortColor[side] = parsed.color; - } - } - - // Step 2: Build borderWidth — existing borderWidth/borderTopWidth override shorthands - const hasBorderWidth = style.borderWidth !== undefined || style.borderTopWidth !== undefined || style.borderRightWidth !== undefined || style.borderBottomWidth !== undefined || style.borderLeftWidth !== undefined; - const hasShortWidth = shortWidth.top !== undefined || shortWidth.right !== undefined || shortWidth.bottom !== undefined || shortWidth.left !== undefined; - if (hasBorderWidth || hasShortWidth) { - const base = style.borderWidth !== undefined - ? expandEdgeValues(style.borderWidth) - : { top: shortWidth.top ?? 0, right: shortWidth.right ?? 0, bottom: shortWidth.bottom ?? 0, left: shortWidth.left ?? 0 }; - result.borderWidth = { - top: style.borderTopWidth ?? base.top, - right: style.borderRightWidth ?? base.right, - bottom: style.borderBottomWidth ?? base.bottom, - left: style.borderLeftWidth ?? base.left, - }; - } - - // Step 3: Build borderColor — existing borderColor/borderTopColor override shorthands - const hasBorderColor = style.borderColor !== undefined || style.borderTopColor !== undefined || style.borderRightColor !== undefined || style.borderBottomColor !== undefined || style.borderLeftColor !== undefined; - const hasShortColor = shortColor.top !== undefined || shortColor.right !== undefined || shortColor.bottom !== undefined || shortColor.left !== undefined; - if (hasBorderColor || hasShortColor) { - const defaultColor = parseColor('#000000'); - let base = { - top: shortColor.top ?? defaultColor, - right: shortColor.right ?? defaultColor, - bottom: shortColor.bottom ?? defaultColor, - left: shortColor.left ?? defaultColor, - }; - if (typeof style.borderColor === 'string') { - const c = parseColor(style.borderColor); - base = { top: c, right: c, bottom: c, left: c }; - } else if (style.borderColor && typeof style.borderColor === 'object') { - base = { - top: parseColor(style.borderColor.top), - right: parseColor(style.borderColor.right), - bottom: parseColor(style.borderColor.bottom), - left: parseColor(style.borderColor.left), - }; - } - result.borderColor = { - top: style.borderTopColor ? parseColor(style.borderTopColor) : base.top, - right: style.borderRightColor ? parseColor(style.borderRightColor) : base.right, - bottom: style.borderBottomColor ? parseColor(style.borderBottomColor) : base.bottom, - left: style.borderLeftColor ? parseColor(style.borderLeftColor) : base.left, - }; - } - if (style.borderRadius !== undefined || style.borderTopLeftRadius !== undefined || style.borderTopRightRadius !== undefined || style.borderBottomRightRadius !== undefined || style.borderBottomLeftRadius !== undefined) { - const base = style.borderRadius !== undefined ? expandCorners(style.borderRadius) : { top_left: 0, top_right: 0, bottom_right: 0, bottom_left: 0 }; - result.borderRadius = { - top_left: style.borderTopLeftRadius ?? base.top_left, - top_right: style.borderTopRightRadius ?? base.top_right, - bottom_right: style.borderBottomRightRadius ?? base.bottom_right, - bottom_left: style.borderBottomLeftRadius ?? base.bottom_left, - }; - } - - // Positioning - if (style.position !== undefined) { - result.position = style.position === 'absolute' ? 'Absolute' : 'Relative'; - } - if (style.top !== undefined) result.top = style.top; - if (style.right !== undefined) result.right = style.right; - if (style.bottom !== undefined) result.bottom = style.bottom; - if (style.left !== undefined) result.left = style.left; - - // Page behavior - if (style.wrap !== undefined) result.wrap = style.wrap; - if (style.breakBefore !== undefined) result.breakBefore = style.breakBefore; - if (style.minWidowLines !== undefined) result.minWidowLines = style.minWidowLines; - if (style.minOrphanLines !== undefined) result.minOrphanLines = style.minOrphanLines; - - return result; -} - -// ─── Grid helpers ─────────────────────────────────────────────────── - -/** Convert a single GridTrackSize to the Forme JSON format. */ -function mapGridTrack(track: GridTrackSize): FormeGridTrackSize { - if (typeof track === 'number') return { Pt: track }; - if (track === 'auto') return 'Auto'; - if (typeof track === 'string') { - const frMatch = track.match(/^([0-9.]+)fr$/); - if (frMatch) return { Fr: parseFloat(frMatch[1]) }; - // Try numeric string - const num = parseFloat(track); - if (!isNaN(num)) return { Pt: num }; - return 'Auto'; - } - if (typeof track === 'object' && 'min' in track && 'max' in track) { - return { MinMax: [mapGridTrack(track.min), mapGridTrack(track.max)] }; - } - return 'Auto'; -} - -/** - * Expand `repeat(N, tracks)` in a grid template string. - * E.g. `"repeat(3, 1fr)"` → `"1fr 1fr 1fr"` - * `"200 repeat(2, 1fr) 200"` → `"200 1fr 1fr 200"` - */ -function expandRepeat(input: string): string { - return input.replace(/repeat\(\s*(\d+)\s*,\s*([^)]+)\)/g, (_match, count, tracks) => { - return (tracks.trim() + ' ').repeat(parseInt(count, 10)).trim(); - }); -} - -/** - * Parse a grid template string shorthand into an array of FormeGridTrackSize. - * E.g. `"1fr 2fr 200"` → `[{Fr:1}, {Fr:2}, {Pt:200}]` - * Supports `repeat(N, tracks)` syntax. - */ -function parseGridTemplate(value: string | GridTrackSize[]): FormeGridTrackSize[] { - if (Array.isArray(value)) { - return value.map(mapGridTrack); - } - const expanded = expandRepeat(value); - return expanded.split(/\s+/).filter(Boolean).map((token) => { - if (token === 'auto') return 'Auto' as FormeGridTrackSize; - const frMatch = token.match(/^([0-9.]+)fr$/); - if (frMatch) return { Fr: parseFloat(frMatch[1]) } as FormeGridTrackSize; - const num = parseFloat(token); - if (!isNaN(num)) return { Pt: num } as FormeGridTrackSize; - return 'Auto' as FormeGridTrackSize; - }); -} - -export function mapDimension(val: number | string): FormeDimension { - if (typeof val === 'number') { - return { Pt: val }; - } - if (val === 'auto') return 'Auto'; - const match = val.match(/^([0-9.]+)%$/); - if (match) { - return { Percent: parseFloat(match[1]) }; - } - // Try to parse as a number (e.g. "100" without units) - const num = parseFloat(val); - if (!isNaN(num)) { - return { Pt: num }; - } - return 'Auto'; -} - -export function parseColor(hex: string): FormeColor { - const s = hex.trim(); - - // rgba(r, g, b, a) - const rgbaMatch = s.match(/^rgba\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)$/); - if (rgbaMatch) { - return { - r: parseFloat(rgbaMatch[1]) / 255, - g: parseFloat(rgbaMatch[2]) / 255, - b: parseFloat(rgbaMatch[3]) / 255, - a: parseFloat(rgbaMatch[4]), - }; - } - - // rgb(r, g, b) - const rgbMatch = s.match(/^rgb\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)$/); - if (rgbMatch) { - return { - r: parseFloat(rgbMatch[1]) / 255, - g: parseFloat(rgbMatch[2]) / 255, - b: parseFloat(rgbMatch[3]) / 255, - a: 1, - }; - } - - const h = s.replace(/^#/, ''); - - if (h.length === 3) { - const r = parseInt(h[0] + h[0], 16) / 255; - const g = parseInt(h[1] + h[1], 16) / 255; - const b = parseInt(h[2] + h[2], 16) / 255; - return { r, g, b, a: 1 }; - } - - if (h.length === 6) { - const r = parseInt(h.slice(0, 2), 16) / 255; - const g = parseInt(h.slice(2, 4), 16) / 255; - const b = parseInt(h.slice(4, 6), 16) / 255; - return { r, g, b, a: 1 }; - } - - if (h.length === 8) { - const r = parseInt(h.slice(0, 2), 16) / 255; - const g = parseInt(h.slice(2, 4), 16) / 255; - const b = parseInt(h.slice(4, 6), 16) / 255; - const a = parseInt(h.slice(6, 8), 16) / 255; - return { r, g, b, a }; - } - - // Fallback: black - return { r: 0, g: 0, b: 0, a: 1 }; -} - -/** - * Parse a CSS-like `transform` string into the engine's TransformOp[]. - * - * Accepted syntax (space- or comma-separated ops, in any order): - * `rotate(45deg)`, `rotate(-15deg)`, `rotate(0.5turn)`, `rotate(1.2rad)` - * `scale(1.5)`, `scale(2, 0.5)` - * `translate(10, -4)`, `translate(10px, -4pt)` - * - * Lengths are interpreted as points (px/pt suffix accepted and ignored; - * Forme is a print-first engine — 1 point = 1 point). Returns `null` on - * unparseable input (the property is silently dropped, matching the - * boxShadow precedent). - */ -function parseTransform(val: string): FormeTransformOp[] | null { - const ops: FormeTransformOp[] = []; - // Match `name(args)` pairs. Names: ASCII letters. Args: anything between - // parentheses (no nesting in this grammar). - const re = /([a-zA-Z]+)\s*\(([^)]*)\)/g; - let m: RegExpExecArray | null; - let matched = false; - while ((m = re.exec(val)) !== null) { - matched = true; - const name = m[1].toLowerCase(); - const args = m[2] - .split(/[,\s]+/) - .map((s) => s.trim()) - .filter((s) => s.length > 0); - - if (name === 'rotate' || name === 'rotatez') { - if (args.length !== 1) return null; - const deg = parseAngle(args[0]); - if (deg === null) return null; - ops.push({ type: 'rotate', deg }); - } else if (name === 'scale') { - if (args.length === 0 || args.length > 2) return null; - const x = parseFloat(args[0]); - if (Number.isNaN(x)) return null; - const y = args.length === 2 ? parseFloat(args[1]) : x; - if (Number.isNaN(y)) return null; - ops.push({ type: 'scale', x, y }); - } else if (name === 'scalex') { - if (args.length !== 1) return null; - const x = parseFloat(args[0]); - if (Number.isNaN(x)) return null; - ops.push({ type: 'scale', x, y: 1 }); - } else if (name === 'scaley') { - if (args.length !== 1) return null; - const y = parseFloat(args[0]); - if (Number.isNaN(y)) return null; - ops.push({ type: 'scale', x: 1, y }); - } else if (name === 'translate') { - if (args.length === 0 || args.length > 2) return null; - const x = parseLengthPt(args[0]); - if (x === null) return null; - const y = args.length === 2 ? parseLengthPt(args[1]) : 0; - if (y === null) return null; - ops.push({ type: 'translate', x, y }); - } else if (name === 'translatex') { - if (args.length !== 1) return null; - const x = parseLengthPt(args[0]); - if (x === null) return null; - ops.push({ type: 'translate', x, y: 0 }); - } else if (name === 'translatey') { - if (args.length !== 1) return null; - const y = parseLengthPt(args[0]); - if (y === null) return null; - ops.push({ type: 'translate', x: 0, y }); - } else { - // Unknown op (e.g. skew, matrix) — reject the whole transform so - // users notice rather than getting silently-half-applied transforms. - return null; - } - } - if (!matched) return null; - return ops; -} - -/** - * Parse an angle in `deg` / `rad` / `turn` (no unit defaults to deg). - * Returns the angle in DEGREES, or null on parse failure. - */ -function parseAngle(s: string): number | null { - const t = s.trim().toLowerCase(); - let mult = 1; - let body = t; - if (t.endsWith('deg')) { - body = t.slice(0, -3); - } else if (t.endsWith('rad')) { - body = t.slice(0, -3); - mult = 180 / Math.PI; - } else if (t.endsWith('turn')) { - body = t.slice(0, -4); - mult = 360; - } - const n = parseFloat(body); - if (Number.isNaN(n)) return null; - return n * mult; -} - -/** - * Parse a length value — accepts a bare number or `px`/`pt` and - * returns the value in points. Forme is print-first; px/pt are treated - * as the same unit at the React layer. - */ -function parseLengthPt(s: string): number | null { - const t = s.trim().toLowerCase(); - const body = t.endsWith('px') || t.endsWith('pt') ? t.slice(0, -2) : t; - const n = parseFloat(body); - return Number.isNaN(n) ? null : n; -} - -/** - * Parse a `transformOrigin` value into a `[x, y]` tuple of fractions - * (0.0–1.0). Accepts the user-side tuple shape directly, or a CSS-like - * string with two tokens (`"50% 50%"`, `"0% 100%"`, `"center top"`, - * `"left bottom"`, etc.). Returns null on unparseable input. - */ -function parseTransformOrigin( - val: string | [number, number], -): [number, number] | null { - if (Array.isArray(val)) { - const [x, y] = val; - if (typeof x !== 'number' || typeof y !== 'number') return null; - return [x, y]; - } - const tokens = val.trim().split(/\s+/).filter((t) => t.length > 0); - if (tokens.length === 0 || tokens.length > 2) return null; - const x = parseOriginToken(tokens[0], 'x'); - if (x === null) return null; - const y = tokens.length === 2 ? parseOriginToken(tokens[1], 'y') : 0.5; - if (y === null) return null; - return [x, y]; -} - -function parseOriginToken(token: string, axis: 'x' | 'y'): number | null { - const t = token.toLowerCase(); - if (t === 'center') return 0.5; - if (axis === 'x') { - if (t === 'left') return 0; - if (t === 'right') return 1; - } else { - if (t === 'top') return 0; - if (t === 'bottom') return 1; - } - if (t.endsWith('%')) { - const n = parseFloat(t.slice(0, -1)); - return Number.isNaN(n) ? null : n / 100; - } - return null; -} - -/** - * Parse a `boxShadow` value (object form or CSS-like string - * `"offsetX offsetY blur color"`) into the engine's FormeBoxShadow shape. - * Returns null on malformed input. v1 ignores blur but parses it. - */ -function parseBoxShadow( - val: string | { offsetX: number; offsetY: number; blur?: number; color: string }, -): FormeBoxShadow | null { - if (typeof val === 'object') { - const c = parseColor(val.color); - return { - offsetX: val.offsetX, - offsetY: val.offsetY, - blur: val.blur ?? 0, - color: c, - }; - } - // String form: "offsetX offsetY blur color". - // Split on whitespace, but preserve any rgba(...)/rgb(...) parens. - const tokens: string[] = []; - let depth = 0; - let buf = ''; - for (const ch of val.trim()) { - if (ch === '(') depth += 1; - if (ch === ')') depth = Math.max(0, depth - 1); - if (/\s/.test(ch) && depth === 0) { - if (buf) { - tokens.push(buf); - buf = ''; - } - } else { - buf += ch; - } - } - if (buf) tokens.push(buf); - if (tokens.length < 4) return null; - const offsetX = parseFloat(tokens[0]); - const offsetY = parseFloat(tokens[1]); - const blur = parseFloat(tokens[2]); - if (Number.isNaN(offsetX) || Number.isNaN(offsetY) || Number.isNaN(blur)) return null; - const color = parseColor(tokens[3]); - return { offsetX, offsetY, blur, color }; -} - -/** - * Parse a CSS `background` value. Supports three forms: - * - `linear-gradient(, , , ...)` — CSS angle conventions - * (`0deg` = bottom→top, `90deg` = left→right, `180deg` = top→bottom). - * Angle is optional; defaults to `180deg` (top→bottom). Side keywords - * (`to bottom`, `to right`, etc.) also supported. - * - `radial-gradient(circle, , , ...)` — `circle` is the only - * shape in v1. The `circle` keyword is optional. - * - solid color (`#abc`, `rgb(...)`, `rgba(...)`) — falls through to a - * `Color`-typed background, which the caller routes to `backgroundColor`. - * - * v1 supports exactly 2 stops; gradients with 3+ stops are flattened to - * the first and last stop (the engine's v1 ShadingType 2 only renders 2 - * colors). Multi-stop support is planned via PDF Type 3 stitching. - * - * Returns null on parse failure (e.g. malformed gradient string with no - * usable color tokens) so the caller can omit the property. - */ -function parseBackground(val: string): FormeBackground | null { - const s = val.trim(); - - // linear-gradient(...) - const linearMatch = s.match(/^linear-gradient\s*\(\s*([\s\S]*)\s*\)$/i); - if (linearMatch) { - const inner = linearMatch[1]; - const parts = splitGradientArgs(inner); - if (parts.length === 0) return null; - - let angleDeg = 180; - let stopParts = parts; - const first = parts[0].trim(); - const angleParsed = parseGradientAngle(first); - if (angleParsed !== null) { - angleDeg = angleParsed; - stopParts = parts.slice(1); - } - const stops = parseGradientStops(stopParts); - if (stops.length < 2) return null; - return { type: 'linear', angleDeg, stops }; - } - - // radial-gradient(...) - const radialMatch = s.match(/^radial-gradient\s*\(\s*([\s\S]*)\s*\)$/i); - if (radialMatch) { - const inner = radialMatch[1]; - const parts = splitGradientArgs(inner); - if (parts.length === 0) return null; - let stopParts = parts; - // Optional shape keyword (`circle`, `ellipse`) — only `circle` honored - // here; `ellipse` strings parse but render as a circle. - const first = parts[0].trim().toLowerCase(); - if (first === 'circle' || first === 'ellipse' || first.startsWith('circle ') || first.startsWith('ellipse ')) { - stopParts = parts.slice(1); - } - const stops = parseGradientStops(stopParts); - if (stops.length < 2) return null; - return { type: 'radial', stops }; - } - - // Solid color fallback. parseColor never throws; on garbage input it - // returns black, so check that the input looks color-shaped first to - // avoid silently turning a typo'd gradient into a black background. - if (/^(#|rgb\(|rgba\()/i.test(s)) { - return { type: 'color', value: parseColor(s) }; - } - return null; -} - -/** - * Split a gradient's interior comma-separated arguments, respecting parens - * so `rgba(0, 0, 0, 0.5)` doesn't get split mid-color. - */ -function splitGradientArgs(inner: string): string[] { - const parts: string[] = []; - let depth = 0; - let buf = ''; - for (const ch of inner) { - if (ch === '(') depth += 1; - else if (ch === ')') depth = Math.max(0, depth - 1); - if (ch === ',' && depth === 0) { - parts.push(buf); - buf = ''; - } else { - buf += ch; - } - } - if (buf.trim()) parts.push(buf); - return parts.map((p) => p.trim()).filter((p) => p.length > 0); -} - -/** - * Parse a CSS gradient angle token. Returns degrees, or null if the token - * isn't an angle. - * - * Forms supported: - * - `deg` (e.g. `135deg`) - * - `turn` (e.g. `0.5turn`) - * - `rad` / `grad` - * - `to ` keywords: `to top` (0), `to right` (90), `to bottom` (180), `to left` (270), - * and the four diagonal forms (`to top right`, etc.). - */ -function parseGradientAngle(token: string): number | null { - const t = token.trim().toLowerCase(); - const degMatch = t.match(/^(-?\d+(?:\.\d+)?)deg$/); - if (degMatch) return parseFloat(degMatch[1]); - const turnMatch = t.match(/^(-?\d+(?:\.\d+)?)turn$/); - if (turnMatch) return parseFloat(turnMatch[1]) * 360; - const radMatch = t.match(/^(-?\d+(?:\.\d+)?)rad$/); - if (radMatch) return (parseFloat(radMatch[1]) * 180) / Math.PI; - const gradMatch = t.match(/^(-?\d+(?:\.\d+)?)grad$/); - if (gradMatch) return parseFloat(gradMatch[1]) * 0.9; - if (t === 'to top') return 0; - if (t === 'to right') return 90; - if (t === 'to bottom') return 180; - if (t === 'to left') return 270; - if (t === 'to top right' || t === 'to right top') return 45; - if (t === 'to bottom right' || t === 'to right bottom') return 135; - if (t === 'to bottom left' || t === 'to left bottom') return 225; - if (t === 'to top left' || t === 'to left top') return 315; - return null; -} - -/** - * Parse a list of gradient color stops. Each stop is `` or - * ` `. Position can be `%` (CSS) or `` (treated as - * a 0..1 fraction). Stops without explicit positions get evenly distributed - * positions matching CSS defaults: first at 0, last at 1, intermediate - * stops linearly interpolated. - */ -function parseGradientStops(parts: string[]): FormeGradientStop[] { - if (parts.length === 0) return []; - const positions: (number | null)[] = []; - const colors: { r: number; g: number; b: number; a: number }[] = []; - for (const p of parts) { - // Find the last whitespace-separated token; if it's a position - // (`50%` or `0.5`), treat it as such, else everything is the color. - const trimmed = p.trim(); - const tokens = splitColorAndPosition(trimmed); - if (!tokens) continue; - colors.push(parseColor(tokens.color)); - positions.push(tokens.position); - } - if (colors.length === 0) return []; - - // Fill in missing positions with CSS defaults. - if (positions[0] === null) positions[0] = 0; - if (positions[positions.length - 1] === null) positions[positions.length - 1] = 1; - for (let i = 1; i < positions.length - 1; i += 1) { - if (positions[i] === null) { - // Linear interpolate between previous known and next known. - let prev = i - 1; - while (prev >= 0 && positions[prev] === null) prev -= 1; - let next = i + 1; - while (next < positions.length && positions[next] === null) next += 1; - const p0 = positions[prev] ?? 0; - const p1 = positions[next] ?? 1; - positions[i] = p0 + ((p1 - p0) * (i - prev)) / (next - prev); - } - } - return colors.map((color, i) => ({ - position: Math.max(0, Math.min(1, positions[i] as number)), - color, - })); -} - -/** - * Split a stop string like `"#fff 50%"` into color + position. - * Position can be percentage or fraction; null if not specified. - */ -function splitColorAndPosition(s: string): { color: string; position: number | null } | null { - // The position token, if present, is the last whitespace-separated piece - // and matches `%` or a bare number. Splitting on whitespace - // respects parens so `rgba(...)` is not chopped up. - const tokens: string[] = []; - let depth = 0; - let buf = ''; - for (const ch of s) { - if (ch === '(') depth += 1; - else if (ch === ')') depth = Math.max(0, depth - 1); - if (/\s/.test(ch) && depth === 0) { - if (buf) { - tokens.push(buf); - buf = ''; - } - } else { - buf += ch; - } - } - if (buf) tokens.push(buf); - if (tokens.length === 0) return null; - const last = tokens[tokens.length - 1]; - const pctMatch = last.match(/^(-?\d+(?:\.\d+)?)%$/); - if (pctMatch) { - return { color: tokens.slice(0, -1).join(' '), position: parseFloat(pctMatch[1]) / 100 }; - } - const fracMatch = last.match(/^(-?\d+(?:\.\d+)?)$/); - if (fracMatch && tokens.length > 1) { - return { color: tokens.slice(0, -1).join(' '), position: parseFloat(fracMatch[1]) }; - } - return { color: tokens.join(' '), position: null }; -} - -/** - * Parse a CSS-style 1-4 value edge shorthand. - * Accepts: `"8"`, `"8 16"`, `"8 16 24"`, `"8 16 24 32"` (with optional `px` suffix). - * Also accepts number arrays: `[8]`, `[8, 16]`, `[8, 16, 24]`, `[8, 16, 24, 32]`. - */ -function parseCSSEdges(val: string | number[]): FormeEdges { - const values: number[] = Array.isArray(val) - ? val - : val.trim().split(/\s+/).map(s => parseFloat(s.replace(/px$/i, ''))); - - switch (values.length) { - case 1: return { top: values[0], right: values[0], bottom: values[0], left: values[0] }; - case 2: return { top: values[0], right: values[1], bottom: values[0], left: values[1] }; - case 3: return { top: values[0], right: values[1], bottom: values[2], left: values[1] }; - default: return { top: values[0], right: values[1], bottom: values[2], left: values[3] }; - } -} - -const BORDER_STYLE_KEYWORDS = new Set([ - 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset', 'none', 'hidden', -]); - -/** - * Parse a CSS border shorthand string like `"1px solid #000"`. - * Returns extracted width and/or color. Style keywords are recognized but ignored. - */ -function parseBorderString(val: string): { width?: number; color?: FormeColor } { - const tokens = val.trim().split(/\s+/); - let width: number | undefined; - let color: FormeColor | undefined; - - for (const token of tokens) { - const lower = token.toLowerCase(); - if (BORDER_STYLE_KEYWORDS.has(lower)) continue; - - const num = parseFloat(lower.replace(/px$/i, '')); - if (!isNaN(num) && /^[\d.]/.test(lower)) { - width = num; - } else { - color = parseColor(token); - } - } - - return { width, color }; -} - -export function expandEdges(val: number | string | number[] | Edges): FormeEdges { - if (typeof val === 'number') { - return { top: val, right: val, bottom: val, left: val }; - } - if (typeof val === 'string' || Array.isArray(val)) { - return parseCSSEdges(val); - } - return { top: val.top, right: val.right, bottom: val.bottom, left: val.left }; -} - -/** Expand margin edges, preserving 'auto' string values. */ -function expandMarginEdges(val: number | string | number[] | Edges): FormeMarginEdges { - if (typeof val === 'number') { - return { top: val, right: val, bottom: val, left: val }; - } - if (typeof val === 'string') { - if (val === 'auto') { - return { top: 'auto', right: 'auto', bottom: 'auto', left: 'auto' }; - } - const edges = parseCSSEdges(val); - return edges; - } - if (Array.isArray(val)) { - return parseCSSEdges(val); - } - return { top: val.top, right: val.right, bottom: val.bottom, left: val.left }; -} - -function expandEdgeValues(val: number | Edges): FormeEdgeValues { - if (typeof val === 'number') { - return { top: val, right: val, bottom: val, left: val }; - } - return { top: val.top, right: val.right, bottom: val.bottom, left: val.left }; -} - -export function expandCorners(val: number | Corners): FormeCornerValues { - if (typeof val === 'number') { - return { top_left: val, top_right: val, bottom_right: val, bottom_left: val }; - } - return { - top_left: val.topLeft, - top_right: val.topRight, - bottom_right: val.bottomRight, - bottom_left: val.bottomLeft, - }; -} - -function mapColumnWidth(w: ColumnDef['width']): FormeColumnWidth { - if (w === 'auto') return 'Auto'; - if ('fraction' in w) return { Fraction: w.fraction }; - if ('fixed' in w) return { Fixed: w.fixed }; - return 'Auto'; -} - -// ─── Font merging ───────────────────────────────────────────────── - -function normalizeFontWeight(w?: number | string): number { - if (w === undefined || w === 'normal') return 400; - if (w === 'bold') return 700; - return typeof w === 'number' ? w : (parseInt(w, 10) || 400); -} - -function fontKey(family: string, weight: number, italic: boolean): string { - return `${family}:${weight}:${italic}`; -} - -function mergeFonts( - globalFonts: FontRegistration[], - docFonts?: FontRegistration[], -): FormeFont[] { - const map = new Map(); - - for (const f of globalFonts) { - const weight = normalizeFontWeight(f.fontWeight); - const italic = f.fontStyle === 'italic' || f.fontStyle === 'oblique'; - const key = fontKey(f.family, weight, italic); - map.set(key, { family: f.family, src: f.src, weight, italic }); - } - - if (docFonts) { - for (const f of docFonts) { - const weight = normalizeFontWeight(f.fontWeight); - const italic = f.fontStyle === 'italic' || f.fontStyle === 'oblique'; - const key = fontKey(f.family, weight, italic); - map.set(key, { family: f.family, src: f.src, weight, italic }); - } - } - - return Array.from(map.values()); -} - // ─── Template serialization ───────────────────────────────────────── // // Parallel to `serialize()` but detects proxy markers and expr markers, diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 31d9781..1b36b30 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,238 +1,64 @@ import type { ReactNode } from 'react'; import type { FontRegistration } from './font.js'; - -// ─── Developer-facing types ────────────────────────────────────────── - -/** Edge values for padding, margin, borderWidth */ -export interface Edges { - top: number; - right: number; - bottom: number; - left: number; -} - -/** Corner values for borderRadius */ -export interface Corners { - topLeft: number; - topRight: number; - bottomRight: number; - bottomLeft: number; -} - -/** A single CSS Grid track size */ -export type GridTrackSize = - | number // Fixed size in points - | `${number}fr` // Fractional unit (e.g. "1fr", "2fr") - | 'auto' // Content-sized - | { min: GridTrackSize; max: GridTrackSize }; // MinMax - -/** Per-edge colors for borderColor */ -export interface EdgeColors { - top: string; - right: string; - bottom: string; - left: string; -} - -/** CSS-like style properties for Forme components */ -export interface Style { - // Layout - display?: 'flex' | 'grid'; - width?: number | string; - height?: number | string; - minWidth?: number | string; - minHeight?: number | string; - maxWidth?: number | string; - maxHeight?: number | string; - flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse'; - flex?: number; - flexGrow?: number; - flexShrink?: number; - flexBasis?: number | string; - flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse'; - justifyContent?: 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly'; - alignItems?: 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'; - alignSelf?: 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'; - alignContent?: 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'stretch'; - gap?: number; - rowGap?: number; - columnGap?: number; - - // CSS Grid - /** Column track definitions. String shorthand: `"1fr 2fr 200"` or array of track sizes. */ - gridTemplateColumns?: string | GridTrackSize[]; - /** Row track definitions. String shorthand: `"auto 1fr"` or array of track sizes. */ - gridTemplateRows?: string | GridTrackSize[]; - /** Default size for auto-generated rows. */ - gridAutoRows?: GridTrackSize; - /** Default size for auto-generated columns. */ - gridAutoColumns?: GridTrackSize; - /** Grid column start line (1-based). */ - gridColumnStart?: number; - /** Grid column end line (1-based). */ - gridColumnEnd?: number; - /** Grid row start line (1-based). */ - gridRowStart?: number; - /** Grid row end line (1-based). */ - gridRowEnd?: number; - /** Number of columns to span. */ - gridColumnSpan?: number; - /** Number of rows to span. */ - gridRowSpan?: number; - - // Box model - padding?: number | string | number[] | Edges; - paddingTop?: number; - paddingRight?: number; - paddingBottom?: number; - paddingLeft?: number; - paddingHorizontal?: number; - paddingVertical?: number; - margin?: number | string | number[] | Edges; - marginTop?: number | 'auto'; - marginRight?: number | 'auto'; - marginBottom?: number | 'auto'; - marginLeft?: number | 'auto'; - marginHorizontal?: number | 'auto'; - marginVertical?: number | 'auto'; - - // Typography - fontSize?: number; - fontFamily?: string; - fontWeight?: number | 'normal' | 'bold'; - fontStyle?: 'normal' | 'italic' | 'oblique'; - lineHeight?: number; - textAlign?: 'left' | 'center' | 'right' | 'justify'; - letterSpacing?: number; - /** Extra width (points) added to each ASCII space — PDF Tw operator. - * Negative values tighten word gaps. When `textAlign: 'justify'` is - * set, the layout engine adds the computed slack-per-space on top. */ - wordSpacing?: number; - /** Drop shadow painted behind the element. Object form is preferred; - * CSS-like string form `"offsetX offsetY blur color"` is also - * accepted. v1 paints a solid offset shadow only — `blur` is parsed - * for forward-compat but ignored. */ - boxShadow?: - | string - | { offsetX: number; offsetY: number; blur?: number; color: string }; - textDecoration?: 'none' | 'underline' | 'line-through'; - textTransform?: 'none' | 'uppercase' | 'lowercase' | 'capitalize'; - hyphens?: 'none' | 'manual' | 'auto'; - /** Language tag (BCP 47, e.g. "en-US", "de"). Controls hyphenation dictionary. */ - lang?: string; - /** Text direction for BiDi support (Arabic, Hebrew). */ - direction?: 'ltr' | 'rtl' | 'auto'; - /** Text overflow behavior: 'wrap' (default), 'ellipsis' (truncate with ...), 'clip' (truncate). */ - textOverflow?: 'wrap' | 'ellipsis' | 'clip'; - /** Line breaking algorithm: 'optimal' (Knuth-Plass, default) or 'greedy'. */ - lineBreaking?: 'optimal' | 'greedy'; - /** Overflow behavior: 'visible' (default) or 'hidden' (clips children to bounds). */ - overflow?: 'visible' | 'hidden'; - - // Visual - color?: string; - backgroundColor?: string; - /** - * CSS background. Supports: - * - solid colors (`"#1e293b"`, `"rgba(...)"`) — equivalent to backgroundColor - * - 2-stop linear gradients: `"linear-gradient(180deg, #fff 0%, #000 100%)"` - * - 2-stop radial gradients: `"radial-gradient(circle, #fff 0%, #000 100%)"` - * - * v1 supports exactly 2 color stops; gradients with 3+ stops fall back to - * the first and last stop. Multi-stop support is planned via PDF Type 3 - * stitching functions in a follow-up. - */ - background?: string; - opacity?: number; - /** - * Paint-only transform applied around the element's origin (default: - * center). Layout flow is NOT affected — matches CSS `transform`. - * - * Accepts a CSS-like string with one or more space-separated ops: - * `"rotate(45deg)"` - * `"rotate(-15deg) scale(1.2)"` - * `"translate(10, -4) rotate(30deg)"` - * - * Supported ops: `rotate(deg)`, `scale([, ])`, - * `translate([, ])`. Angles are degrees, lengths are points. - */ - transform?: string; - /** - * Origin of the transform as fractions of the element box. - * `"50% 50%"` (default) = center, `"0% 0%"` = top-left, `"100% 0%"` - * = top-right, etc. Also accepts a tuple `[x, y]` where each is 0–1. - */ - transformOrigin?: string | [number, number]; - borderWidth?: number | Edges; - borderTopWidth?: number; - borderRightWidth?: number; - borderBottomWidth?: number; - borderLeftWidth?: number; - borderColor?: string | EdgeColors; - borderTopColor?: string; - borderRightColor?: string; - borderBottomColor?: string; - borderLeftColor?: string; - borderRadius?: number | Corners; - borderTopLeftRadius?: number; - borderTopRightRadius?: number; - borderBottomRightRadius?: number; - borderBottomLeftRadius?: number; - - // Border shorthands (CSS-like string parsing) - /** CSS border shorthand, e.g. `"1px solid #000"` */ - border?: string; - /** Per-side border shorthand: string parses as CSS, number sets width */ - borderTop?: string | number; - /** Per-side border shorthand: string parses as CSS, number sets width */ - borderRight?: string | number; - /** Per-side border shorthand: string parses as CSS, number sets width */ - borderBottom?: string | number; - /** Per-side border shorthand: string parses as CSS, number sets width */ - borderLeft?: string | number; - - // Positioning - position?: 'relative' | 'absolute'; - top?: number; - right?: number; - bottom?: number; - left?: number; - - // Page behavior - wrap?: boolean; - breakBefore?: boolean; - minWidowLines?: number; - minOrphanLines?: number; -} +import type { + Style, + Edges, + ColumnDef, + BarcodeFormat, + ChartDataPoint, + ChartSeries, + DotPlotGroup, + CanvasContext, + CertificationConfig, +} from '@formepdf/shared'; + +// Framework-neutral types (document model, Style, canvas ops) live in +// @formepdf/shared; re-exported here so the public API is unchanged. +export type { + Edges, + Corners, + GridTrackSize, + EdgeColors, + Style, + CertificationConfig, + SignatureConfig, + ColumnDef, + BarcodeFormat, + ChartDataPoint, + PieDataPoint, + ChartSeries, + DotPlotGroup, + CanvasContext, + CanvasOp, + TextRun, + FormeFont, + FormeDocument, + FormeMetadata, + FormePageConfig, + FormePageSize, + FormeEdges, + FormeBoxShadow, + FormeGradientStop, + FormeBackground, + FormeMarginEdges, + FormeNode, + FormeNodeKind, + FormeColumnDef, + FormeColumnWidth, + FormeDimension, + FormeColor, + FormeEdgeValues, + FormeCornerValues, + FormeGridTrackSize, + FormeGridPlacement, + FormeStyle, + ListMarker, + FormeListMarkerType, + FormeTransformOp, +} from '@formepdf/shared'; // ─── Component prop types ──────────────────────────────────────────── -export interface CertificationConfig { - /** PEM-encoded X.509 certificate. */ - certificatePem: string; - /** PEM-encoded RSA private key (PKCS#8). */ - privateKeyPem: string; - /** Reason for certification (e.g. "Approved"). */ - reason?: string; - /** Location of certification (e.g. "New York, NY"). */ - location?: string; - /** Contact info for the certifier. */ - contact?: string; - /** Whether to show a visible signature annotation on the page. */ - visible?: boolean; - /** X coordinate in points for visible signature. */ - x?: number; - /** Y coordinate in points for visible signature. */ - y?: number; - /** Width in points for visible signature. */ - width?: number; - /** Height in points for visible signature. */ - height?: number; -} - -/** @deprecated Use CertificationConfig */ -export type SignatureConfig = CertificationConfig; - export interface DocumentProps { title?: string; author?: string; @@ -286,21 +112,6 @@ export interface TextProps { children?: ReactNode; } -/** - * Marker style for an ordered or unordered list. Matches CSS - * `list-style-type` for the supported values. - */ -export type ListMarker = - | 'disc' - | 'circle' - | 'square' - | 'none' - | 'decimal' - | 'lower-alpha' - | 'upper-alpha' - | 'lower-roman' - | 'upper-roman'; - /** * An ordered (numbered) list. Children must be ``s. */ @@ -395,10 +206,6 @@ export interface ImageProps { alt?: string; } -export interface ColumnDef { - width: { fraction: number } | { fixed: number } | 'auto'; -} - export interface TableProps { columns?: ColumnDef[]; style?: Style; @@ -450,8 +257,6 @@ export interface QrCodeProps { style?: Style; } -export type BarcodeFormat = 'Code128' | 'Code39' | 'EAN13' | 'EAN8' | 'Codabar'; - export interface BarcodeProps { /** The data to encode. */ data: string; @@ -466,37 +271,6 @@ export interface BarcodeProps { style?: Style; } -/** Data point for bar and pie charts. */ -export interface ChartDataPoint { - label: string; - value: number; - /** Optional per-item color (hex string). */ - color?: string; -} - -/** @deprecated Use ChartDataPoint instead (color is now optional on ChartDataPoint). */ -export interface PieDataPoint { - label: string; - value: number; - color: string; -} - -/** A data series for multi-series line and area charts. */ -export interface ChartSeries { - name: string; - data: number[]; - /** Optional series color (hex string). */ - color?: string; -} - -/** A group of (x, y) data points for dot plots. */ -export interface DotPlotGroup { - name: string; - /** Optional group color (hex string). */ - color?: string; - data: [number, number][]; -} - export interface BarChartProps { width: number; height: number; @@ -665,314 +439,9 @@ export interface WatermarkProps { style?: Style; } -/** Canvas drawing context for the draw callback. */ -export interface CanvasContext { - moveTo(x: number, y: number): void; - lineTo(x: number, y: number): void; - bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void; - quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void; - closePath(): void; - rect(x: number, y: number, w: number, h: number): void; - circle(cx: number, cy: number, r: number): void; - ellipse(cx: number, cy: number, rx: number, ry: number): void; - arc(cx: number, cy: number, r: number, startAngle: number, endAngle: number, counterclockwise?: boolean): void; - /** Convenience: draws a stroked line from (x1,y1) to (x2,y2). */ - line(x1: number, y1: number, x2: number, y2: number): void; - stroke(): void; - fill(): void; - fillAndStroke(): void; - setFillColor(r: number, g: number, b: number): void; - setStrokeColor(r: number, g: number, b: number): void; - setLineWidth(w: number): void; - setLineCap(cap: number): void; - setLineJoin(join: number): void; - save(): void; - restore(): void; -} - -/** A single canvas drawing operation (serialized to JSON). */ -export type CanvasOp = - | { op: 'MoveTo'; x: number; y: number } - | { op: 'LineTo'; x: number; y: number } - | { op: 'BezierCurveTo'; cp1x: number; cp1y: number; cp2x: number; cp2y: number; x: number; y: number } - | { op: 'QuadraticCurveTo'; cpx: number; cpy: number; x: number; y: number } - | { op: 'ClosePath' } - | { op: 'Rect'; x: number; y: number; width: number; height: number } - | { op: 'Circle'; cx: number; cy: number; r: number } - | { op: 'Ellipse'; cx: number; cy: number; rx: number; ry: number } - | { op: 'Arc'; cx: number; cy: number; r: number; start_angle: number; end_angle: number; counterclockwise: boolean } - | { op: 'Stroke' } - | { op: 'Fill' } - | { op: 'FillAndStroke' } - | { op: 'SetFillColor'; r: number; g: number; b: number } - | { op: 'SetStrokeColor'; r: number; g: number; b: number } - | { op: 'SetLineWidth'; width: number } - | { op: 'SetLineCap'; cap: number } - | { op: 'SetLineJoin'; join: number } - | { op: 'Save' } - | { op: 'Restore' }; - export interface CanvasProps { width: number; height: number; draw: (ctx: CanvasContext) => void; style?: Style; } - -/** A styled text segment within a element */ -export interface TextRun { - content: string; - style?: FormeStyle; - href?: string; -} - -// ─── Forme JSON output types (match Rust serde format) ─────────────── - -export interface FormeFont { - family: string; - src: string | Uint8Array; - weight: number; - italic: boolean; -} - -export interface FormeDocument { - children: FormeNode[]; - metadata: FormeMetadata; - defaultPage: FormePageConfig; - defaultStyle?: FormeStyle; - fonts?: FormeFont[]; - tagged?: boolean; - pdfa?: '2a' | '2b'; - pdfUa?: boolean; - flattenForms?: boolean; - certification?: CertificationConfig; -} - -export interface FormeMetadata { - title?: string; - author?: string; - subject?: string; - creator?: string; - lang?: string; -} - -export interface FormePageConfig { - size: FormePageSize; - margin: FormeEdges; - wrap: boolean; - backgroundImage?: string; - backgroundOpacity?: number; - backgroundSize?: 'fill' | 'cover' | 'contain'; - backgroundPosition?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; -} - -export type FormePageSize = - | 'A4' | 'A3' | 'A5' | 'Letter' | 'Legal' | 'Tabloid' - | { Custom: { width: number; height: number } }; - -export interface FormeEdges { - top: number; - right: number; - bottom: number; - left: number; -} - -export interface FormeBoxShadow { - offsetX: number; - offsetY: number; - blur?: number; - color: { r: number; g: number; b: number; a: number }; -} - -/** Engine wire format for list marker type — matches the Rust enum's - * serde camelCase output. */ -export type FormeListMarkerType = - | 'disc' - | 'circle' - | 'square' - | 'none' - | 'decimal' - | 'lowerAlpha' - | 'upperAlpha' - | 'lowerRoman' - | 'upperRoman'; - -/** A single transform operation in the engine wire format. The discriminator - * is `type` matching the engine's serde tag. */ -export type FormeTransformOp = - | { type: 'rotate'; deg: number } - | { type: 'scale'; x: number; y: number } - | { type: 'translate'; x: number; y: number }; - -export interface FormeGradientStop { - position: number; - color: { r: number; g: number; b: number; a: number }; -} - -export type FormeBackground = - | { type: 'color'; value: { r: number; g: number; b: number; a: number } } - | { type: 'linear'; angleDeg: number; stops: FormeGradientStop[] } - | { type: 'radial'; stops: FormeGradientStop[] }; - -/** Margin edges that support auto values (for centering). */ -export interface FormeMarginEdges { - top: number | 'auto'; - right: number | 'auto'; - bottom: number | 'auto'; - left: number | 'auto'; -} - -export interface FormeNode { - kind: FormeNodeKind; - style: FormeStyle; - children: FormeNode[]; - bookmark?: string; - href?: string; - alt?: string; - sourceLocation?: { file: string; line: number; column: number }; -} - -export type FormeNodeKind = - | { type: 'Page'; config: FormePageConfig } - | { type: 'View' } - | { type: 'Text'; content: string; href?: string; runs?: TextRun[] } - | { type: 'Heading'; level: number; content: string; href?: string; runs?: TextRun[] } - | { type: 'List'; ordered: boolean; marker_type: FormeListMarkerType; start: number } - | { type: 'ListItem' } - | { type: 'Image'; src: string; width?: number; height?: number } - | { type: 'Table'; columns: FormeColumnDef[] } - | { type: 'TableRow'; is_header: boolean } - | { type: 'TableCell'; col_span: number; row_span: number } - | { type: 'Fixed'; position: 'Header' | 'Footer' } - | { type: 'Svg'; width: number; height: number; view_box?: string; content: string } - | { type: 'QrCode'; data: string; size?: number } - | { type: 'Barcode'; data: string; format: BarcodeFormat; width?: number; height: number } - | { type: 'Canvas'; width: number; height: number; operations: CanvasOp[] } - | { type: 'BarChart'; data: ChartDataPoint[]; width: number; height: number; color?: string; show_labels: boolean; show_values: boolean; show_grid: boolean; title?: string } - | { type: 'LineChart'; series: ChartSeries[]; labels: string[]; width: number; height: number; show_points: boolean; show_grid: boolean; title?: string } - | { type: 'PieChart'; data: ChartDataPoint[]; width: number; height: number; donut: boolean; show_legend: boolean; title?: string } - | { type: 'AreaChart'; series: ChartSeries[]; labels: string[]; width: number; height: number; show_grid: boolean; title?: string } - | { type: 'DotPlot'; groups: DotPlotGroup[]; width: number; height: number; x_min?: number; x_max?: number; y_min?: number; y_max?: number; x_label?: string; y_label?: string; show_legend: boolean; dot_size: number } - | { type: 'Watermark'; text: string; font_size: number; angle: number } - | { type: 'TextField'; name: string; width: number; height: number; value?: string; placeholder?: string; multiline: boolean; password: boolean; read_only: boolean; max_length?: number; font_size: number } - | { type: 'Checkbox'; name: string; width: number; height: number; checked: boolean; read_only: boolean } - | { type: 'Dropdown'; name: string; options: string[]; width: number; height: number; value?: string; read_only: boolean; font_size: number } - | { type: 'RadioButton'; name: string; value: string; width: number; height: number; checked: boolean; read_only: boolean } - | { type: 'PageBreak' }; - -export interface FormeColumnDef { - width: FormeColumnWidth; -} - -export type FormeColumnWidth = - | { Fraction: number } - | { Fixed: number } - | 'Auto'; - -export type FormeDimension = - | { Pt: number } - | { Percent: number } - | 'Auto'; - -export interface FormeColor { - r: number; - g: number; - b: number; - a: number; -} - -export interface FormeEdgeValues { - top: T; - right: T; - bottom: T; - left: T; -} - -export interface FormeCornerValues { - top_left: number; - top_right: number; - bottom_right: number; - bottom_left: number; -} - -/** Grid track size in Forme JSON format (matches Rust GridTrackSize enum) */ -export type FormeGridTrackSize = - | { Pt: number } - | { Fr: number } - | 'Auto' - | { MinMax: [FormeGridTrackSize, FormeGridTrackSize] }; - -/** Grid placement in Forme JSON format */ -export interface FormeGridPlacement { - columnStart?: number; - columnEnd?: number; - rowStart?: number; - rowEnd?: number; - columnSpan?: number; - rowSpan?: number; -} - -/** Style in the Forme JSON format (camelCase field names, PascalCase enum values) */ -export interface FormeStyle { - display?: string; - width?: FormeDimension; - height?: FormeDimension; - minWidth?: FormeDimension; - minHeight?: FormeDimension; - maxWidth?: FormeDimension; - maxHeight?: FormeDimension; - padding?: FormeEdges; - margin?: FormeMarginEdges; - flexDirection?: string; - justifyContent?: string; - alignItems?: string; - alignSelf?: string; - alignContent?: string; - flexWrap?: string; - flexGrow?: number; - flexShrink?: number; - flexBasis?: FormeDimension; - gap?: number; - rowGap?: number; - columnGap?: number; - gridTemplateColumns?: FormeGridTrackSize[]; - gridTemplateRows?: FormeGridTrackSize[]; - gridAutoRows?: FormeGridTrackSize; - gridAutoColumns?: FormeGridTrackSize; - gridPlacement?: FormeGridPlacement; - fontFamily?: string; - fontSize?: number; - fontWeight?: number; - fontStyle?: string; - lineHeight?: number; - textAlign?: string; - letterSpacing?: number; - wordSpacing?: number; - boxShadow?: FormeBoxShadow; - transform?: FormeTransformOp[]; - transformOrigin?: [number, number]; - textDecoration?: string; - textTransform?: string; - hyphens?: string; - lang?: string; - direction?: string; - textOverflow?: string; - lineBreaking?: string; - overflow?: string; - color?: FormeColor; - backgroundColor?: FormeColor; - background?: FormeBackground; - opacity?: number; - borderWidth?: FormeEdgeValues; - borderColor?: FormeEdgeValues; - borderRadius?: FormeCornerValues; - position?: string; - top?: number; - right?: number; - bottom?: number; - left?: number; - wrap?: boolean; - breakBefore?: boolean; - minWidowLines?: number; - minOrphanLines?: number; -} diff --git a/packages/shared/README.md b/packages/shared/README.md new file mode 100644 index 0000000..82b6eb1 --- /dev/null +++ b/packages/shared/README.md @@ -0,0 +1,7 @@ +# @formepdf/shared + +Framework-agnostic core shared by Forme's authoring adapters (`@formepdf/react`, and future adapters): the Forme document-model types, the `Style` type and its mapping to engine JSON (including CSS string shorthands), color/dimension/edge/corner parsing, custom font registration and merging, and the `` operation recorder. + +Plain data in → document model out. No framework dependencies. + +You normally don't install this package directly — it comes in as a dependency of an adapter, which re-exports everything user-facing. diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..18a0b91 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,30 @@ +{ + "name": "@formepdf/shared", + "version": "0.10.5", + "description": "Framework-agnostic document model and serialization core shared by Forme adapters", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "^5.7.0" + }, + "files": [ + "dist" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/formepdf/forme", + "directory": "packages/shared" + } +} diff --git a/packages/shared/src/canvas.ts b/packages/shared/src/canvas.ts new file mode 100644 index 0000000..434cd6b --- /dev/null +++ b/packages/shared/src/canvas.ts @@ -0,0 +1,48 @@ +import type { CanvasContext, CanvasOp } from './types.js'; + +/** + * Execute a draw callback against a recording context, capturing + * the drawing calls as a serializable CanvasOp list. + */ +export function recordCanvasOperations(draw: (ctx: CanvasContext) => void): CanvasOp[] { + const operations: CanvasOp[] = []; + + // Create a recording context that captures draw calls as CanvasOp[] + const ctx: CanvasContext = { + moveTo(x, y) { operations.push({ op: 'MoveTo', x, y }); }, + lineTo(x, y) { operations.push({ op: 'LineTo', x, y }); }, + bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) { + operations.push({ op: 'BezierCurveTo', cp1x, cp1y, cp2x, cp2y, x, y }); + }, + quadraticCurveTo(cpx, cpy, x, y) { + operations.push({ op: 'QuadraticCurveTo', cpx, cpy, x, y }); + }, + closePath() { operations.push({ op: 'ClosePath' }); }, + rect(x, y, w, h) { operations.push({ op: 'Rect', x, y, width: w, height: h }); }, + circle(cx, cy, r) { operations.push({ op: 'Circle', cx, cy, r }); }, + ellipse(cx, cy, rx, ry) { operations.push({ op: 'Ellipse', cx, cy, rx, ry }); }, + arc(cx, cy, r, startAngle, endAngle, counterclockwise = false) { + operations.push({ op: 'Arc', cx, cy, r, start_angle: startAngle, end_angle: endAngle, counterclockwise }); + }, + line(x1, y1, x2, y2) { + operations.push({ op: 'MoveTo', x: x1, y: y1 }); + operations.push({ op: 'LineTo', x: x2, y: y2 }); + operations.push({ op: 'Stroke' }); + }, + stroke() { operations.push({ op: 'Stroke' }); }, + fill() { operations.push({ op: 'Fill' }); }, + fillAndStroke() { operations.push({ op: 'FillAndStroke' }); }, + setFillColor(r, g, b) { operations.push({ op: 'SetFillColor', r, g, b }); }, + setStrokeColor(r, g, b) { operations.push({ op: 'SetStrokeColor', r, g, b }); }, + setLineWidth(w) { operations.push({ op: 'SetLineWidth', width: w }); }, + setLineCap(cap) { operations.push({ op: 'SetLineCap', cap }); }, + setLineJoin(join) { operations.push({ op: 'SetLineJoin', join }); }, + save() { operations.push({ op: 'Save' }); }, + restore() { operations.push({ op: 'Restore' }); }, + }; + + // Execute the draw callback to record operations + draw(ctx); + + return operations; +} diff --git a/packages/shared/src/font.ts b/packages/shared/src/font.ts new file mode 100644 index 0000000..4a16955 --- /dev/null +++ b/packages/shared/src/font.ts @@ -0,0 +1,72 @@ +import type { FormeFont } from './types.js'; + +/// Options for registering a custom font. +export interface FontRegistration { + family: string; + src: string | Uint8Array; + fontWeight?: number | 'normal' | 'bold'; + fontStyle?: 'normal' | 'italic' | 'oblique'; +} + +const globalFonts: FontRegistration[] = []; + +function normalizeWeight(w?: number | string): number { + if (w === undefined || w === 'normal') return 400; + if (w === 'bold') return 700; + return typeof w === 'number' ? w : (parseInt(w, 10) || 400); +} + +export const Font = { + register(options: FontRegistration): void { + globalFonts.push({ + ...options, + fontWeight: normalizeWeight(options.fontWeight), + fontStyle: options.fontStyle || 'normal', + }); + }, + + clear(): void { + globalFonts.length = 0; + }, + + getRegistered(): FontRegistration[] { + return [...globalFonts]; + }, +}; + +// ─── Font merging ───────────────────────────────────────────────── + +function normalizeFontWeight(w?: number | string): number { + if (w === undefined || w === 'normal') return 400; + if (w === 'bold') return 700; + return typeof w === 'number' ? w : (parseInt(w, 10) || 400); +} + +function fontKey(family: string, weight: number, italic: boolean): string { + return `${family}:${weight}:${italic}`; +} + +export function mergeFonts( + globalFonts: FontRegistration[], + docFonts?: FontRegistration[], +): FormeFont[] { + const map = new Map(); + + for (const f of globalFonts) { + const weight = normalizeFontWeight(f.fontWeight); + const italic = f.fontStyle === 'italic' || f.fontStyle === 'oblique'; + const key = fontKey(f.family, weight, italic); + map.set(key, { family: f.family, src: f.src, weight, italic }); + } + + if (docFonts) { + for (const f of docFonts) { + const weight = normalizeFontWeight(f.fontWeight); + const italic = f.fontStyle === 'italic' || f.fontStyle === 'oblique'; + const key = fontKey(f.family, weight, italic); + map.set(key, { family: f.family, src: f.src, weight, italic }); + } + } + + return Array.from(map.values()); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..5c90caa --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,65 @@ +// Style mapping +export { mapStyle, mapDimension, parseColor, expandEdges, expandCorners, mapColumnWidth } from './style.js'; + +// Font registration +export { Font, mergeFonts } from './font.js'; +export type { FontRegistration } from './font.js'; + +// Canvas recording +export { recordCanvasOperations } from './canvas.js'; + +// Semantic component constants (headings, inline formatting, lists) +export { + STRONG_DEFAULTS, + EM_DEFAULTS, + CODE_DEFAULTS, + LINK_DEFAULTS, + HEADING_DEFAULTS, + mapListMarker, +} from './semantics.js'; + +// Types +export type { + // Developer-facing + Style, + GridTrackSize, + Edges, + Corners, + EdgeColors, + CertificationConfig, + SignatureConfig, + ColumnDef, + BarcodeFormat, + ChartDataPoint, + PieDataPoint, + ChartSeries, + DotPlotGroup, + CanvasContext, + CanvasOp, + TextRun, + ListMarker, + // Forme JSON output + FormeDocument, + FormeFont, + FormeNode, + FormeNodeKind, + FormeStyle, + FormeMetadata, + FormePageConfig, + FormePageSize, + FormeEdges, + FormeBoxShadow, + FormeGradientStop, + FormeBackground, + FormeMarginEdges, + FormeColumnDef, + FormeColumnWidth, + FormeDimension, + FormeColor, + FormeEdgeValues, + FormeCornerValues, + FormeGridTrackSize, + FormeGridPlacement, + FormeListMarkerType, + FormeTransformOp, +} from './types.js'; diff --git a/packages/shared/src/semantics.ts b/packages/shared/src/semantics.ts new file mode 100644 index 0000000..f24fbf5 --- /dev/null +++ b/packages/shared/src/semantics.ts @@ -0,0 +1,53 @@ +/** + * Framework-neutral constants for the semantic components (headings, + * inline formatting, lists). Each adapter (react, svelte) walks its own + * element tree, but the default styles and the list-marker wire mapping + * must be identical so the adapters cannot drift. + */ + +import type { Style } from './types.js'; +import type { FormeListMarkerType } from './types.js'; + +// Inline formatting components produce TextRuns with these default styles +// (merged under any user-supplied style, so user style wins). +export const STRONG_DEFAULTS: Style = { fontWeight: 700 }; +export const EM_DEFAULTS: Style = { fontStyle: 'italic' }; +export const CODE_DEFAULTS: Style = { + fontFamily: 'Courier', + backgroundColor: '#F4F4F5', +}; +export const LINK_DEFAULTS: Style = { + color: '#2563EB', + textDecoration: 'underline', +}; + +// Default style per heading level. Tuned for typical document layout — +// users override individual properties via `style` on the heading element. +// margin top/bottom are in points; font sizes are points. +export const HEADING_DEFAULTS: Record<1 | 2 | 3 | 4 | 5 | 6, Style> = { + 1: { fontSize: 32, fontWeight: 700, marginTop: 24, marginBottom: 16 }, + 2: { fontSize: 24, fontWeight: 700, marginTop: 20, marginBottom: 14 }, + 3: { fontSize: 20, fontWeight: 600, marginTop: 16, marginBottom: 12 }, + 4: { fontSize: 18, fontWeight: 600, marginTop: 14, marginBottom: 10 }, + 5: { fontSize: 16, fontWeight: 600, marginTop: 12, marginBottom: 8 }, + 6: { fontSize: 14, fontWeight: 600, marginTop: 10, marginBottom: 6 }, +}; + +/** CSS `list-style-type`-shaped string → engine wire enum value. */ +export function mapListMarker( + marker: string | undefined, + defaultValue: FormeListMarkerType, +): FormeListMarkerType { + switch (marker) { + case 'disc': return 'disc'; + case 'circle': return 'circle'; + case 'square': return 'square'; + case 'none': return 'none'; + case 'decimal': return 'decimal'; + case 'lower-alpha': return 'lowerAlpha'; + case 'upper-alpha': return 'upperAlpha'; + case 'lower-roman': return 'lowerRoman'; + case 'upper-roman': return 'upperRoman'; + default: return defaultValue; + } +} diff --git a/packages/shared/src/style.ts b/packages/shared/src/style.ts new file mode 100644 index 0000000..3db0794 --- /dev/null +++ b/packages/shared/src/style.ts @@ -0,0 +1,969 @@ +import type { + Style, + Edges, + Corners, + ColumnDef, + GridTrackSize, + FormeStyle, + FormeEdges, + FormeMarginEdges, + FormeColumnWidth, + FormeDimension, + FormeColor, + FormeBoxShadow, + FormeBackground, + FormeGradientStop, + FormeEdgeValues, + FormeCornerValues, + FormeGridTrackSize, + FormeGridPlacement, + FormeTransformOp, +} from './types.js'; + +// ─── Style mapping ────────────────────────────────────────────────── + +const FLEX_DIRECTION_MAP: Record = { + 'row': 'Row', + 'column': 'Column', + 'row-reverse': 'RowReverse', + 'column-reverse': 'ColumnReverse', +}; + +const JUSTIFY_CONTENT_MAP: Record = { + 'flex-start': 'FlexStart', + 'flex-end': 'FlexEnd', + 'center': 'Center', + 'space-between': 'SpaceBetween', + 'space-around': 'SpaceAround', + 'space-evenly': 'SpaceEvenly', +}; + +const ALIGN_ITEMS_MAP: Record = { + 'flex-start': 'FlexStart', + 'flex-end': 'FlexEnd', + 'center': 'Center', + 'stretch': 'Stretch', + 'baseline': 'Baseline', +}; + +const FLEX_WRAP_MAP: Record = { + 'nowrap': 'NoWrap', + 'wrap': 'Wrap', + 'wrap-reverse': 'WrapReverse', +}; + +const ALIGN_CONTENT_MAP: Record = { + 'flex-start': 'FlexStart', + 'flex-end': 'FlexEnd', + 'center': 'Center', + 'space-between': 'SpaceBetween', + 'space-around': 'SpaceAround', + 'space-evenly': 'SpaceEvenly', + 'stretch': 'Stretch', +}; + +const FONT_STYLE_MAP: Record = { + 'normal': 'Normal', + 'italic': 'Italic', + 'oblique': 'Oblique', +}; + +const TEXT_ALIGN_MAP: Record = { + 'left': 'Left', + 'right': 'Right', + 'center': 'Center', + 'justify': 'Justify', +}; + +const TEXT_DECORATION_MAP: Record = { + 'none': 'None', + 'underline': 'Underline', + 'line-through': 'LineThrough', +}; + +const TEXT_TRANSFORM_MAP: Record = { + 'none': 'None', + 'uppercase': 'Uppercase', + 'lowercase': 'Lowercase', + 'capitalize': 'Capitalize', +}; + +const HYPHENS_MAP: Record = { + 'none': 'none', + 'manual': 'manual', + 'auto': 'auto', +}; + +const TEXT_OVERFLOW_MAP: Record = { + 'wrap': 'Wrap', + 'ellipsis': 'Ellipsis', + 'clip': 'Clip', +}; + +const LINE_BREAKING_MAP: Record = { + 'optimal': 'optimal', + 'greedy': 'greedy', +}; + +const OVERFLOW_MAP: Record = { + 'visible': 'Visible', + 'hidden': 'Hidden', +}; + +export function mapStyle(style?: Style): FormeStyle { + if (!style) return {}; + + const result: FormeStyle = {}; + + // Dimensions + if (style.width !== undefined) result.width = mapDimension(style.width); + if (style.height !== undefined) result.height = mapDimension(style.height); + if (style.minWidth !== undefined) result.minWidth = mapDimension(style.minWidth); + if (style.minHeight !== undefined) result.minHeight = mapDimension(style.minHeight); + if (style.maxWidth !== undefined) result.maxWidth = mapDimension(style.maxWidth); + if (style.maxHeight !== undefined) result.maxHeight = mapDimension(style.maxHeight); + + // Edges (individual > axis > base) + if (style.padding !== undefined || style.paddingTop !== undefined || style.paddingRight !== undefined || style.paddingBottom !== undefined || style.paddingLeft !== undefined || style.paddingHorizontal !== undefined || style.paddingVertical !== undefined) { + const base = style.padding !== undefined ? expandEdges(style.padding) : { top: 0, right: 0, bottom: 0, left: 0 }; + const vt = style.paddingVertical ?? base.top; + const vb = style.paddingVertical ?? base.bottom; + const hl = style.paddingHorizontal ?? base.left; + const hr = style.paddingHorizontal ?? base.right; + result.padding = { + top: style.paddingTop ?? vt, + right: style.paddingRight ?? hr, + bottom: style.paddingBottom ?? vb, + left: style.paddingLeft ?? hl, + }; + } + if (style.margin !== undefined || style.marginTop !== undefined || style.marginRight !== undefined || style.marginBottom !== undefined || style.marginLeft !== undefined || style.marginHorizontal !== undefined || style.marginVertical !== undefined) { + const base: FormeMarginEdges = style.margin !== undefined ? expandMarginEdges(style.margin) : { top: 0, right: 0, bottom: 0, left: 0 }; + const vt: number | 'auto' = style.marginVertical ?? base.top; + const vb: number | 'auto' = style.marginVertical ?? base.bottom; + const hl: number | 'auto' = style.marginHorizontal ?? base.left; + const hr: number | 'auto' = style.marginHorizontal ?? base.right; + result.margin = { + top: style.marginTop ?? vt, + right: style.marginRight ?? hr, + bottom: style.marginBottom ?? vb, + left: style.marginLeft ?? hl, + }; + } + + // Flex shorthand: flex: N → flexGrow: N, flexShrink: 1, flexBasis: 0 + if (style.flex !== undefined) { + if (style.flexGrow === undefined) result.flexGrow = style.flex; + if (style.flexShrink === undefined) result.flexShrink = 1; + if (style.flexBasis === undefined) result.flexBasis = { Pt: 0 }; + } + + // Flex + if (style.flexDirection !== undefined) result.flexDirection = FLEX_DIRECTION_MAP[style.flexDirection]; + if (style.justifyContent !== undefined) result.justifyContent = JUSTIFY_CONTENT_MAP[style.justifyContent]; + if (style.alignItems !== undefined) result.alignItems = ALIGN_ITEMS_MAP[style.alignItems]; + if (style.alignSelf !== undefined) result.alignSelf = ALIGN_ITEMS_MAP[style.alignSelf]; + if (style.flexWrap !== undefined) result.flexWrap = FLEX_WRAP_MAP[style.flexWrap]; + if (style.alignContent !== undefined) result.alignContent = ALIGN_CONTENT_MAP[style.alignContent]; + if (style.flexGrow !== undefined) result.flexGrow = style.flexGrow; + if (style.flexShrink !== undefined) result.flexShrink = style.flexShrink; + if (style.flexBasis !== undefined) result.flexBasis = mapDimension(style.flexBasis); + if (style.gap !== undefined) result.gap = style.gap; + if (style.rowGap !== undefined) result.rowGap = style.rowGap; + if (style.columnGap !== undefined) result.columnGap = style.columnGap; + + // Display mode + if (style.display !== undefined) { + result.display = style.display === 'grid' ? 'Grid' : 'Flex'; + } + + // CSS Grid + if (style.gridTemplateColumns !== undefined) { + result.gridTemplateColumns = parseGridTemplate(style.gridTemplateColumns); + } + if (style.gridTemplateRows !== undefined) { + result.gridTemplateRows = parseGridTemplate(style.gridTemplateRows); + } + if (style.gridAutoRows !== undefined) { + result.gridAutoRows = mapGridTrack(style.gridAutoRows); + } + if (style.gridAutoColumns !== undefined) { + result.gridAutoColumns = mapGridTrack(style.gridAutoColumns); + } + // Grid placement (individual props → single gridPlacement object) + if (style.gridColumnStart !== undefined || style.gridColumnEnd !== undefined || + style.gridRowStart !== undefined || style.gridRowEnd !== undefined || + style.gridColumnSpan !== undefined || style.gridRowSpan !== undefined) { + const placement: FormeGridPlacement = {}; + if (style.gridColumnStart !== undefined) placement.columnStart = style.gridColumnStart; + if (style.gridColumnEnd !== undefined) placement.columnEnd = style.gridColumnEnd; + if (style.gridRowStart !== undefined) placement.rowStart = style.gridRowStart; + if (style.gridRowEnd !== undefined) placement.rowEnd = style.gridRowEnd; + if (style.gridColumnSpan !== undefined) placement.columnSpan = style.gridColumnSpan; + if (style.gridRowSpan !== undefined) placement.rowSpan = style.gridRowSpan; + result.gridPlacement = placement; + } + + // Typography + if (style.fontFamily !== undefined) result.fontFamily = style.fontFamily; + if (style.fontSize !== undefined) result.fontSize = style.fontSize; + if (style.fontWeight !== undefined) { + result.fontWeight = style.fontWeight === 'bold' ? 700 : style.fontWeight === 'normal' ? 400 : style.fontWeight; + } + if (style.fontStyle !== undefined) result.fontStyle = FONT_STYLE_MAP[style.fontStyle]; + if (style.lineHeight !== undefined) result.lineHeight = style.lineHeight; + if (style.textAlign !== undefined) result.textAlign = TEXT_ALIGN_MAP[style.textAlign]; + if (style.letterSpacing !== undefined) result.letterSpacing = style.letterSpacing; + if (style.wordSpacing !== undefined) result.wordSpacing = style.wordSpacing; + if (style.boxShadow !== undefined) { + const parsed = parseBoxShadow(style.boxShadow); + if (parsed) result.boxShadow = parsed; + } + if (style.transform !== undefined) { + const parsed = parseTransform(style.transform); + if (parsed && parsed.length > 0) result.transform = parsed; + } + if (style.transformOrigin !== undefined) { + const parsed = parseTransformOrigin(style.transformOrigin); + if (parsed) result.transformOrigin = parsed; + } + if (style.textDecoration !== undefined) result.textDecoration = TEXT_DECORATION_MAP[style.textDecoration]; + if (style.textTransform !== undefined) result.textTransform = TEXT_TRANSFORM_MAP[style.textTransform]; + if (style.hyphens !== undefined) result.hyphens = HYPHENS_MAP[style.hyphens]; + if (style.lang !== undefined) result.lang = style.lang; + if (style.direction !== undefined) result.direction = style.direction; + if (style.textOverflow !== undefined) result.textOverflow = TEXT_OVERFLOW_MAP[style.textOverflow]; + if (style.lineBreaking !== undefined) result.lineBreaking = LINE_BREAKING_MAP[style.lineBreaking]; + if (style.overflow !== undefined) result.overflow = OVERFLOW_MAP[style.overflow]; + + // Color + if (style.color !== undefined) result.color = parseColor(style.color); + if (style.backgroundColor !== undefined) result.backgroundColor = parseColor(style.backgroundColor); + if (style.background !== undefined) { + const parsed = parseBackground(style.background); + if (parsed) { + if (parsed.type === 'color') { + // Solid color string in `background`: route to backgroundColor for + // engine compatibility (Background::Color also works, but + // backgroundColor is the canonical solid path). + if (result.backgroundColor === undefined) result.backgroundColor = parsed.value; + } else { + result.background = parsed; + } + } + } + if (style.opacity !== undefined) result.opacity = style.opacity; + + // Border — cascade: border < borderTop/Right/Bottom/Left < borderWidth/borderColor < borderTopWidth/borderTopColor + // Step 1: Parse string shorthands into intermediate per-side values + let shortWidth: FormeEdgeValues = { top: undefined, right: undefined, bottom: undefined, left: undefined }; + let shortColor: FormeEdgeValues = { top: undefined, right: undefined, bottom: undefined, left: undefined }; + + if (style.border !== undefined) { + const parsed = parseBorderString(style.border); + if (parsed.width !== undefined) shortWidth = { top: parsed.width, right: parsed.width, bottom: parsed.width, left: parsed.width }; + if (parsed.color !== undefined) shortColor = { top: parsed.color, right: parsed.color, bottom: parsed.color, left: parsed.color }; + } + + // Per-side string shorthands override all-side shorthand + for (const [side, prop] of [['top', 'borderTop'], ['right', 'borderRight'], ['bottom', 'borderBottom'], ['left', 'borderLeft']] as const) { + const val = style[prop]; + if (val === undefined) continue; + if (typeof val === 'number') { + shortWidth[side] = val; + } else { + const parsed = parseBorderString(val); + if (parsed.width !== undefined) shortWidth[side] = parsed.width; + if (parsed.color !== undefined) shortColor[side] = parsed.color; + } + } + + // Step 2: Build borderWidth — existing borderWidth/borderTopWidth override shorthands + const hasBorderWidth = style.borderWidth !== undefined || style.borderTopWidth !== undefined || style.borderRightWidth !== undefined || style.borderBottomWidth !== undefined || style.borderLeftWidth !== undefined; + const hasShortWidth = shortWidth.top !== undefined || shortWidth.right !== undefined || shortWidth.bottom !== undefined || shortWidth.left !== undefined; + if (hasBorderWidth || hasShortWidth) { + const base = style.borderWidth !== undefined + ? expandEdgeValues(style.borderWidth) + : { top: shortWidth.top ?? 0, right: shortWidth.right ?? 0, bottom: shortWidth.bottom ?? 0, left: shortWidth.left ?? 0 }; + result.borderWidth = { + top: style.borderTopWidth ?? base.top, + right: style.borderRightWidth ?? base.right, + bottom: style.borderBottomWidth ?? base.bottom, + left: style.borderLeftWidth ?? base.left, + }; + } + + // Step 3: Build borderColor — existing borderColor/borderTopColor override shorthands + const hasBorderColor = style.borderColor !== undefined || style.borderTopColor !== undefined || style.borderRightColor !== undefined || style.borderBottomColor !== undefined || style.borderLeftColor !== undefined; + const hasShortColor = shortColor.top !== undefined || shortColor.right !== undefined || shortColor.bottom !== undefined || shortColor.left !== undefined; + if (hasBorderColor || hasShortColor) { + const defaultColor = parseColor('#000000'); + let base = { + top: shortColor.top ?? defaultColor, + right: shortColor.right ?? defaultColor, + bottom: shortColor.bottom ?? defaultColor, + left: shortColor.left ?? defaultColor, + }; + if (typeof style.borderColor === 'string') { + const c = parseColor(style.borderColor); + base = { top: c, right: c, bottom: c, left: c }; + } else if (style.borderColor && typeof style.borderColor === 'object') { + base = { + top: parseColor(style.borderColor.top), + right: parseColor(style.borderColor.right), + bottom: parseColor(style.borderColor.bottom), + left: parseColor(style.borderColor.left), + }; + } + result.borderColor = { + top: style.borderTopColor ? parseColor(style.borderTopColor) : base.top, + right: style.borderRightColor ? parseColor(style.borderRightColor) : base.right, + bottom: style.borderBottomColor ? parseColor(style.borderBottomColor) : base.bottom, + left: style.borderLeftColor ? parseColor(style.borderLeftColor) : base.left, + }; + } + if (style.borderRadius !== undefined || style.borderTopLeftRadius !== undefined || style.borderTopRightRadius !== undefined || style.borderBottomRightRadius !== undefined || style.borderBottomLeftRadius !== undefined) { + const base = style.borderRadius !== undefined ? expandCorners(style.borderRadius) : { top_left: 0, top_right: 0, bottom_right: 0, bottom_left: 0 }; + result.borderRadius = { + top_left: style.borderTopLeftRadius ?? base.top_left, + top_right: style.borderTopRightRadius ?? base.top_right, + bottom_right: style.borderBottomRightRadius ?? base.bottom_right, + bottom_left: style.borderBottomLeftRadius ?? base.bottom_left, + }; + } + + // Positioning + if (style.position !== undefined) { + result.position = style.position === 'absolute' ? 'Absolute' : 'Relative'; + } + if (style.top !== undefined) result.top = style.top; + if (style.right !== undefined) result.right = style.right; + if (style.bottom !== undefined) result.bottom = style.bottom; + if (style.left !== undefined) result.left = style.left; + + // Page behavior + if (style.wrap !== undefined) result.wrap = style.wrap; + if (style.breakBefore !== undefined) result.breakBefore = style.breakBefore; + if (style.minWidowLines !== undefined) result.minWidowLines = style.minWidowLines; + if (style.minOrphanLines !== undefined) result.minOrphanLines = style.minOrphanLines; + + return result; +} + +// ─── Grid helpers ─────────────────────────────────────────────────── + +/** Convert a single GridTrackSize to the Forme JSON format. */ +function mapGridTrack(track: GridTrackSize): FormeGridTrackSize { + if (typeof track === 'number') return { Pt: track }; + if (track === 'auto') return 'Auto'; + if (typeof track === 'string') { + const frMatch = track.match(/^([0-9.]+)fr$/); + if (frMatch) return { Fr: parseFloat(frMatch[1]) }; + // Try numeric string + const num = parseFloat(track); + if (!isNaN(num)) return { Pt: num }; + return 'Auto'; + } + if (typeof track === 'object' && 'min' in track && 'max' in track) { + return { MinMax: [mapGridTrack(track.min), mapGridTrack(track.max)] }; + } + return 'Auto'; +} + +/** + * Expand `repeat(N, tracks)` in a grid template string. + * E.g. `"repeat(3, 1fr)"` → `"1fr 1fr 1fr"` + * `"200 repeat(2, 1fr) 200"` → `"200 1fr 1fr 200"` + */ +function expandRepeat(input: string): string { + return input.replace(/repeat\(\s*(\d+)\s*,\s*([^)]+)\)/g, (_match, count, tracks) => { + return (tracks.trim() + ' ').repeat(parseInt(count, 10)).trim(); + }); +} + +/** + * Parse a grid template string shorthand into an array of FormeGridTrackSize. + * E.g. `"1fr 2fr 200"` → `[{Fr:1}, {Fr:2}, {Pt:200}]` + * Supports `repeat(N, tracks)` syntax. + */ +function parseGridTemplate(value: string | GridTrackSize[]): FormeGridTrackSize[] { + if (Array.isArray(value)) { + return value.map(mapGridTrack); + } + const expanded = expandRepeat(value); + return expanded.split(/\s+/).filter(Boolean).map((token) => { + if (token === 'auto') return 'Auto' as FormeGridTrackSize; + const frMatch = token.match(/^([0-9.]+)fr$/); + if (frMatch) return { Fr: parseFloat(frMatch[1]) } as FormeGridTrackSize; + const num = parseFloat(token); + if (!isNaN(num)) return { Pt: num } as FormeGridTrackSize; + return 'Auto' as FormeGridTrackSize; + }); +} + +export function mapDimension(val: number | string): FormeDimension { + if (typeof val === 'number') { + return { Pt: val }; + } + if (val === 'auto') return 'Auto'; + const match = val.match(/^([0-9.]+)%$/); + if (match) { + return { Percent: parseFloat(match[1]) }; + } + // Try to parse as a number (e.g. "100" without units) + const num = parseFloat(val); + if (!isNaN(num)) { + return { Pt: num }; + } + return 'Auto'; +} + +export function parseColor(hex: string): FormeColor { + const s = hex.trim(); + + // rgba(r, g, b, a) + const rgbaMatch = s.match(/^rgba\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)$/); + if (rgbaMatch) { + return { + r: parseFloat(rgbaMatch[1]) / 255, + g: parseFloat(rgbaMatch[2]) / 255, + b: parseFloat(rgbaMatch[3]) / 255, + a: parseFloat(rgbaMatch[4]), + }; + } + + // rgb(r, g, b) + const rgbMatch = s.match(/^rgb\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*\)$/); + if (rgbMatch) { + return { + r: parseFloat(rgbMatch[1]) / 255, + g: parseFloat(rgbMatch[2]) / 255, + b: parseFloat(rgbMatch[3]) / 255, + a: 1, + }; + } + + const h = s.replace(/^#/, ''); + + if (h.length === 3) { + const r = parseInt(h[0] + h[0], 16) / 255; + const g = parseInt(h[1] + h[1], 16) / 255; + const b = parseInt(h[2] + h[2], 16) / 255; + return { r, g, b, a: 1 }; + } + + if (h.length === 6) { + const r = parseInt(h.slice(0, 2), 16) / 255; + const g = parseInt(h.slice(2, 4), 16) / 255; + const b = parseInt(h.slice(4, 6), 16) / 255; + return { r, g, b, a: 1 }; + } + + if (h.length === 8) { + const r = parseInt(h.slice(0, 2), 16) / 255; + const g = parseInt(h.slice(2, 4), 16) / 255; + const b = parseInt(h.slice(4, 6), 16) / 255; + const a = parseInt(h.slice(6, 8), 16) / 255; + return { r, g, b, a }; + } + + // Fallback: black + return { r: 0, g: 0, b: 0, a: 1 }; +} + +/** + * Parse a CSS-like `transform` string into the engine's TransformOp[]. + * + * Accepted syntax (space- or comma-separated ops, in any order): + * `rotate(45deg)`, `rotate(-15deg)`, `rotate(0.5turn)`, `rotate(1.2rad)` + * `scale(1.5)`, `scale(2, 0.5)` + * `translate(10, -4)`, `translate(10px, -4pt)` + * + * Lengths are interpreted as points (px/pt suffix accepted and ignored; + * Forme is a print-first engine — 1 point = 1 point). Returns `null` on + * unparseable input (the property is silently dropped, matching the + * boxShadow precedent). + */ +function parseTransform(val: string): FormeTransformOp[] | null { + const ops: FormeTransformOp[] = []; + // Match `name(args)` pairs. Names: ASCII letters. Args: anything between + // parentheses (no nesting in this grammar). + const re = /([a-zA-Z]+)\s*\(([^)]*)\)/g; + let m: RegExpExecArray | null; + let matched = false; + while ((m = re.exec(val)) !== null) { + matched = true; + const name = m[1].toLowerCase(); + const args = m[2] + .split(/[,\s]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + if (name === 'rotate' || name === 'rotatez') { + if (args.length !== 1) return null; + const deg = parseAngle(args[0]); + if (deg === null) return null; + ops.push({ type: 'rotate', deg }); + } else if (name === 'scale') { + if (args.length === 0 || args.length > 2) return null; + const x = parseFloat(args[0]); + if (Number.isNaN(x)) return null; + const y = args.length === 2 ? parseFloat(args[1]) : x; + if (Number.isNaN(y)) return null; + ops.push({ type: 'scale', x, y }); + } else if (name === 'scalex') { + if (args.length !== 1) return null; + const x = parseFloat(args[0]); + if (Number.isNaN(x)) return null; + ops.push({ type: 'scale', x, y: 1 }); + } else if (name === 'scaley') { + if (args.length !== 1) return null; + const y = parseFloat(args[0]); + if (Number.isNaN(y)) return null; + ops.push({ type: 'scale', x: 1, y }); + } else if (name === 'translate') { + if (args.length === 0 || args.length > 2) return null; + const x = parseLengthPt(args[0]); + if (x === null) return null; + const y = args.length === 2 ? parseLengthPt(args[1]) : 0; + if (y === null) return null; + ops.push({ type: 'translate', x, y }); + } else if (name === 'translatex') { + if (args.length !== 1) return null; + const x = parseLengthPt(args[0]); + if (x === null) return null; + ops.push({ type: 'translate', x, y: 0 }); + } else if (name === 'translatey') { + if (args.length !== 1) return null; + const y = parseLengthPt(args[0]); + if (y === null) return null; + ops.push({ type: 'translate', x: 0, y }); + } else { + // Unknown op (e.g. skew, matrix) — reject the whole transform so + // users notice rather than getting silently-half-applied transforms. + return null; + } + } + if (!matched) return null; + return ops; +} + +/** + * Parse an angle in `deg` / `rad` / `turn` (no unit defaults to deg). + * Returns the angle in DEGREES, or null on parse failure. + */ +function parseAngle(s: string): number | null { + const t = s.trim().toLowerCase(); + let mult = 1; + let body = t; + if (t.endsWith('deg')) { + body = t.slice(0, -3); + } else if (t.endsWith('rad')) { + body = t.slice(0, -3); + mult = 180 / Math.PI; + } else if (t.endsWith('turn')) { + body = t.slice(0, -4); + mult = 360; + } + const n = parseFloat(body); + if (Number.isNaN(n)) return null; + return n * mult; +} + +/** + * Parse a length value — accepts a bare number or `px`/`pt` and + * returns the value in points. Forme is print-first; px/pt are treated + * as the same unit at this layer. + */ +function parseLengthPt(s: string): number | null { + const t = s.trim().toLowerCase(); + const body = t.endsWith('px') || t.endsWith('pt') ? t.slice(0, -2) : t; + const n = parseFloat(body); + return Number.isNaN(n) ? null : n; +} + +/** + * Parse a `transformOrigin` value into a `[x, y]` tuple of fractions + * (0.0–1.0). Accepts the user-side tuple shape directly, or a CSS-like + * string with two tokens (`"50% 50%"`, `"0% 100%"`, `"center top"`, + * `"left bottom"`, etc.). Returns null on unparseable input. + */ +function parseTransformOrigin( + val: string | [number, number], +): [number, number] | null { + if (Array.isArray(val)) { + const [x, y] = val; + if (typeof x !== 'number' || typeof y !== 'number') return null; + return [x, y]; + } + const tokens = val.trim().split(/\s+/).filter((t) => t.length > 0); + if (tokens.length === 0 || tokens.length > 2) return null; + const x = parseOriginToken(tokens[0], 'x'); + if (x === null) return null; + const y = tokens.length === 2 ? parseOriginToken(tokens[1], 'y') : 0.5; + if (y === null) return null; + return [x, y]; +} + +function parseOriginToken(token: string, axis: 'x' | 'y'): number | null { + const t = token.toLowerCase(); + if (t === 'center') return 0.5; + if (axis === 'x') { + if (t === 'left') return 0; + if (t === 'right') return 1; + } else { + if (t === 'top') return 0; + if (t === 'bottom') return 1; + } + if (t.endsWith('%')) { + const n = parseFloat(t.slice(0, -1)); + return Number.isNaN(n) ? null : n / 100; + } + return null; +} + +/** + * Parse a `boxShadow` value (object form or CSS-like string + * `"offsetX offsetY blur color"`) into the engine's FormeBoxShadow shape. + * Returns null on malformed input. v1 ignores blur but parses it. + */ +function parseBoxShadow( + val: string | { offsetX: number; offsetY: number; blur?: number; color: string }, +): FormeBoxShadow | null { + if (typeof val === 'object') { + const c = parseColor(val.color); + return { + offsetX: val.offsetX, + offsetY: val.offsetY, + blur: val.blur ?? 0, + color: c, + }; + } + // String form: "offsetX offsetY blur color". + // Split on whitespace, but preserve any rgba(...)/rgb(...) parens. + const tokens: string[] = []; + let depth = 0; + let buf = ''; + for (const ch of val.trim()) { + if (ch === '(') depth += 1; + if (ch === ')') depth = Math.max(0, depth - 1); + if (/\s/.test(ch) && depth === 0) { + if (buf) { + tokens.push(buf); + buf = ''; + } + } else { + buf += ch; + } + } + if (buf) tokens.push(buf); + if (tokens.length < 4) return null; + const offsetX = parseFloat(tokens[0]); + const offsetY = parseFloat(tokens[1]); + const blur = parseFloat(tokens[2]); + if (Number.isNaN(offsetX) || Number.isNaN(offsetY) || Number.isNaN(blur)) return null; + const color = parseColor(tokens[3]); + return { offsetX, offsetY, blur, color }; +} + +/** + * Parse a CSS `background` value. Supports three forms: + * - `linear-gradient(, , , ...)` — CSS angle conventions + * (`0deg` = bottom→top, `90deg` = left→right, `180deg` = top→bottom). + * Angle is optional; defaults to `180deg` (top→bottom). Side keywords + * (`to bottom`, `to right`, etc.) also supported. + * - `radial-gradient(circle, , , ...)` — `circle` is the only + * shape in v1. The `circle` keyword is optional. + * - solid color (`#abc`, `rgb(...)`, `rgba(...)`) — falls through to a + * `Color`-typed background, which the caller routes to `backgroundColor`. + * + * v1 supports exactly 2 stops; gradients with 3+ stops are flattened to + * the first and last stop (the engine's v1 ShadingType 2 only renders 2 + * colors). Multi-stop support is planned via PDF Type 3 stitching. + * + * Returns null on parse failure (e.g. malformed gradient string with no + * usable color tokens) so the caller can omit the property. + */ +function parseBackground(val: string): FormeBackground | null { + const s = val.trim(); + + // linear-gradient(...) + const linearMatch = s.match(/^linear-gradient\s*\(\s*([\s\S]*)\s*\)$/i); + if (linearMatch) { + const inner = linearMatch[1]; + const parts = splitGradientArgs(inner); + if (parts.length === 0) return null; + + let angleDeg = 180; + let stopParts = parts; + const first = parts[0].trim(); + const angleParsed = parseGradientAngle(first); + if (angleParsed !== null) { + angleDeg = angleParsed; + stopParts = parts.slice(1); + } + const stops = parseGradientStops(stopParts); + if (stops.length < 2) return null; + return { type: 'linear', angleDeg, stops }; + } + + // radial-gradient(...) + const radialMatch = s.match(/^radial-gradient\s*\(\s*([\s\S]*)\s*\)$/i); + if (radialMatch) { + const inner = radialMatch[1]; + const parts = splitGradientArgs(inner); + if (parts.length === 0) return null; + let stopParts = parts; + // Optional shape keyword (`circle`, `ellipse`) — only `circle` honored + // here; `ellipse` strings parse but render as a circle. + const first = parts[0].trim().toLowerCase(); + if (first === 'circle' || first === 'ellipse' || first.startsWith('circle ') || first.startsWith('ellipse ')) { + stopParts = parts.slice(1); + } + const stops = parseGradientStops(stopParts); + if (stops.length < 2) return null; + return { type: 'radial', stops }; + } + + // Solid color fallback. parseColor never throws; on garbage input it + // returns black, so check that the input looks color-shaped first to + // avoid silently turning a typo'd gradient into a black background. + if (/^(#|rgb\(|rgba\()/i.test(s)) { + return { type: 'color', value: parseColor(s) }; + } + return null; +} + +/** + * Split a gradient's interior comma-separated arguments, respecting parens + * so `rgba(0, 0, 0, 0.5)` doesn't get split mid-color. + */ +function splitGradientArgs(inner: string): string[] { + const parts: string[] = []; + let depth = 0; + let buf = ''; + for (const ch of inner) { + if (ch === '(') depth += 1; + else if (ch === ')') depth = Math.max(0, depth - 1); + if (ch === ',' && depth === 0) { + parts.push(buf); + buf = ''; + } else { + buf += ch; + } + } + if (buf.trim()) parts.push(buf); + return parts.map((p) => p.trim()).filter((p) => p.length > 0); +} + +/** + * Parse a CSS gradient angle token. Returns degrees, or null if the token + * isn't an angle. + * + * Forms supported: + * - `deg` (e.g. `135deg`) + * - `turn` (e.g. `0.5turn`) + * - `rad` / `grad` + * - `to ` keywords: `to top` (0), `to right` (90), `to bottom` (180), `to left` (270), + * and the four diagonal forms (`to top right`, etc.). + */ +function parseGradientAngle(token: string): number | null { + const t = token.trim().toLowerCase(); + const degMatch = t.match(/^(-?\d+(?:\.\d+)?)deg$/); + if (degMatch) return parseFloat(degMatch[1]); + const turnMatch = t.match(/^(-?\d+(?:\.\d+)?)turn$/); + if (turnMatch) return parseFloat(turnMatch[1]) * 360; + const radMatch = t.match(/^(-?\d+(?:\.\d+)?)rad$/); + if (radMatch) return (parseFloat(radMatch[1]) * 180) / Math.PI; + const gradMatch = t.match(/^(-?\d+(?:\.\d+)?)grad$/); + if (gradMatch) return parseFloat(gradMatch[1]) * 0.9; + if (t === 'to top') return 0; + if (t === 'to right') return 90; + if (t === 'to bottom') return 180; + if (t === 'to left') return 270; + if (t === 'to top right' || t === 'to right top') return 45; + if (t === 'to bottom right' || t === 'to right bottom') return 135; + if (t === 'to bottom left' || t === 'to left bottom') return 225; + if (t === 'to top left' || t === 'to left top') return 315; + return null; +} + +/** + * Parse a list of gradient color stops. Each stop is `` or + * ` `. Position can be `%` (CSS) or `` (treated as + * a 0..1 fraction). Stops without explicit positions get evenly distributed + * positions matching CSS defaults: first at 0, last at 1, intermediate + * stops linearly interpolated. + */ +function parseGradientStops(parts: string[]): FormeGradientStop[] { + if (parts.length === 0) return []; + const positions: (number | null)[] = []; + const colors: { r: number; g: number; b: number; a: number }[] = []; + for (const p of parts) { + // Find the last whitespace-separated token; if it's a position + // (`50%` or `0.5`), treat it as such, else everything is the color. + const trimmed = p.trim(); + const tokens = splitColorAndPosition(trimmed); + if (!tokens) continue; + colors.push(parseColor(tokens.color)); + positions.push(tokens.position); + } + if (colors.length === 0) return []; + + // Fill in missing positions with CSS defaults. + if (positions[0] === null) positions[0] = 0; + if (positions[positions.length - 1] === null) positions[positions.length - 1] = 1; + for (let i = 1; i < positions.length - 1; i += 1) { + if (positions[i] === null) { + // Linear interpolate between previous known and next known. + let prev = i - 1; + while (prev >= 0 && positions[prev] === null) prev -= 1; + let next = i + 1; + while (next < positions.length && positions[next] === null) next += 1; + const p0 = positions[prev] ?? 0; + const p1 = positions[next] ?? 1; + positions[i] = p0 + ((p1 - p0) * (i - prev)) / (next - prev); + } + } + return colors.map((color, i) => ({ + position: Math.max(0, Math.min(1, positions[i] as number)), + color, + })); +} + +/** + * Split a stop string like `"#fff 50%"` into color + position. + * Position can be percentage or fraction; null if not specified. + */ +function splitColorAndPosition(s: string): { color: string; position: number | null } | null { + // The position token, if present, is the last whitespace-separated piece + // and matches `%` or a bare number. Splitting on whitespace + // respects parens so `rgba(...)` is not chopped up. + const tokens: string[] = []; + let depth = 0; + let buf = ''; + for (const ch of s) { + if (ch === '(') depth += 1; + else if (ch === ')') depth = Math.max(0, depth - 1); + if (/\s/.test(ch) && depth === 0) { + if (buf) { + tokens.push(buf); + buf = ''; + } + } else { + buf += ch; + } + } + if (buf) tokens.push(buf); + if (tokens.length === 0) return null; + const last = tokens[tokens.length - 1]; + const pctMatch = last.match(/^(-?\d+(?:\.\d+)?)%$/); + if (pctMatch) { + return { color: tokens.slice(0, -1).join(' '), position: parseFloat(pctMatch[1]) / 100 }; + } + const fracMatch = last.match(/^(-?\d+(?:\.\d+)?)$/); + if (fracMatch && tokens.length > 1) { + return { color: tokens.slice(0, -1).join(' '), position: parseFloat(fracMatch[1]) }; + } + return { color: tokens.join(' '), position: null }; +} + +/** + * Parse a CSS-style 1-4 value edge shorthand. + * Accepts: `"8"`, `"8 16"`, `"8 16 24"`, `"8 16 24 32"` (with optional `px` suffix). + * Also accepts number arrays: `[8]`, `[8, 16]`, `[8, 16, 24]`, `[8, 16, 24, 32]`. + */ +function parseCSSEdges(val: string | number[]): FormeEdges { + const values: number[] = Array.isArray(val) + ? val + : val.trim().split(/\s+/).map(s => parseFloat(s.replace(/px$/i, ''))); + + switch (values.length) { + case 1: return { top: values[0], right: values[0], bottom: values[0], left: values[0] }; + case 2: return { top: values[0], right: values[1], bottom: values[0], left: values[1] }; + case 3: return { top: values[0], right: values[1], bottom: values[2], left: values[1] }; + default: return { top: values[0], right: values[1], bottom: values[2], left: values[3] }; + } +} + +const BORDER_STYLE_KEYWORDS = new Set([ + 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset', 'none', 'hidden', +]); + +/** + * Parse a CSS border shorthand string like `"1px solid #000"`. + * Returns extracted width and/or color. Style keywords are recognized but ignored. + */ +function parseBorderString(val: string): { width?: number; color?: FormeColor } { + const tokens = val.trim().split(/\s+/); + let width: number | undefined; + let color: FormeColor | undefined; + + for (const token of tokens) { + const lower = token.toLowerCase(); + if (BORDER_STYLE_KEYWORDS.has(lower)) continue; + + const num = parseFloat(lower.replace(/px$/i, '')); + if (!isNaN(num) && /^[\d.]/.test(lower)) { + width = num; + } else { + color = parseColor(token); + } + } + + return { width, color }; +} + +export function expandEdges(val: number | string | number[] | Edges): FormeEdges { + if (typeof val === 'number') { + return { top: val, right: val, bottom: val, left: val }; + } + if (typeof val === 'string' || Array.isArray(val)) { + return parseCSSEdges(val); + } + return { top: val.top, right: val.right, bottom: val.bottom, left: val.left }; +} + +/** Expand margin edges, preserving 'auto' string values. */ +function expandMarginEdges(val: number | string | number[] | Edges): FormeMarginEdges { + if (typeof val === 'number') { + return { top: val, right: val, bottom: val, left: val }; + } + if (typeof val === 'string') { + if (val === 'auto') { + return { top: 'auto', right: 'auto', bottom: 'auto', left: 'auto' }; + } + const edges = parseCSSEdges(val); + return edges; + } + if (Array.isArray(val)) { + return parseCSSEdges(val); + } + return { top: val.top, right: val.right, bottom: val.bottom, left: val.left }; +} + +function expandEdgeValues(val: number | Edges): FormeEdgeValues { + if (typeof val === 'number') { + return { top: val, right: val, bottom: val, left: val }; + } + return { top: val.top, right: val.right, bottom: val.bottom, left: val.left }; +} + +export function expandCorners(val: number | Corners): FormeCornerValues { + if (typeof val === 'number') { + return { top_left: val, top_right: val, bottom_right: val, bottom_left: val }; + } + return { + top_left: val.topLeft, + top_right: val.topRight, + bottom_right: val.bottomRight, + bottom_left: val.bottomLeft, + }; +} + +export function mapColumnWidth(w: ColumnDef['width']): FormeColumnWidth { + if (w === 'auto') return 'Auto'; + if ('fraction' in w) return { Fraction: w.fraction }; + if ('fixed' in w) return { Fixed: w.fixed }; + return 'Auto'; +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..d8c840a --- /dev/null +++ b/packages/shared/src/types.ts @@ -0,0 +1,586 @@ +// ─── Developer-facing types ────────────────────────────────────────── + +/** Edge values for padding, margin, borderWidth */ +export interface Edges { + top: number; + right: number; + bottom: number; + left: number; +} + +/** Corner values for borderRadius */ +export interface Corners { + topLeft: number; + topRight: number; + bottomRight: number; + bottomLeft: number; +} + +/** A single CSS Grid track size */ +export type GridTrackSize = + | number // Fixed size in points + | `${number}fr` // Fractional unit (e.g. "1fr", "2fr") + | 'auto' // Content-sized + | { min: GridTrackSize; max: GridTrackSize }; // MinMax + +/** Per-edge colors for borderColor */ +export interface EdgeColors { + top: string; + right: string; + bottom: string; + left: string; +} + +/** CSS-like style properties for Forme components */ +export interface Style { + // Layout + display?: 'flex' | 'grid'; + width?: number | string; + height?: number | string; + minWidth?: number | string; + minHeight?: number | string; + maxWidth?: number | string; + maxHeight?: number | string; + flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse'; + flex?: number; + flexGrow?: number; + flexShrink?: number; + flexBasis?: number | string; + flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse'; + justifyContent?: 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly'; + alignItems?: 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'; + alignSelf?: 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'; + alignContent?: 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly' | 'stretch'; + gap?: number; + rowGap?: number; + columnGap?: number; + + // CSS Grid + /** Column track definitions. String shorthand: `"1fr 2fr 200"` or array of track sizes. */ + gridTemplateColumns?: string | GridTrackSize[]; + /** Row track definitions. String shorthand: `"auto 1fr"` or array of track sizes. */ + gridTemplateRows?: string | GridTrackSize[]; + /** Default size for auto-generated rows. */ + gridAutoRows?: GridTrackSize; + /** Default size for auto-generated columns. */ + gridAutoColumns?: GridTrackSize; + /** Grid column start line (1-based). */ + gridColumnStart?: number; + /** Grid column end line (1-based). */ + gridColumnEnd?: number; + /** Grid row start line (1-based). */ + gridRowStart?: number; + /** Grid row end line (1-based). */ + gridRowEnd?: number; + /** Number of columns to span. */ + gridColumnSpan?: number; + /** Number of rows to span. */ + gridRowSpan?: number; + + // Box model + padding?: number | string | number[] | Edges; + paddingTop?: number; + paddingRight?: number; + paddingBottom?: number; + paddingLeft?: number; + paddingHorizontal?: number; + paddingVertical?: number; + margin?: number | string | number[] | Edges; + marginTop?: number | 'auto'; + marginRight?: number | 'auto'; + marginBottom?: number | 'auto'; + marginLeft?: number | 'auto'; + marginHorizontal?: number | 'auto'; + marginVertical?: number | 'auto'; + + // Typography + fontSize?: number; + fontFamily?: string; + fontWeight?: number | 'normal' | 'bold'; + fontStyle?: 'normal' | 'italic' | 'oblique'; + lineHeight?: number; + textAlign?: 'left' | 'center' | 'right' | 'justify'; + letterSpacing?: number; + /** Extra width (points) added to each ASCII space — PDF Tw operator. + * Negative values tighten word gaps. When `textAlign: 'justify'` is + * set, the layout engine adds the computed slack-per-space on top. */ + wordSpacing?: number; + /** Drop shadow painted behind the element. Object form is preferred; + * CSS-like string form `"offsetX offsetY blur color"` is also + * accepted. v1 paints a solid offset shadow only — `blur` is parsed + * for forward-compat but ignored. */ + boxShadow?: + | string + | { offsetX: number; offsetY: number; blur?: number; color: string }; + /** + * Paint-only transform applied around the element's origin (default: + * center). Layout flow is NOT affected — matches CSS `transform`. + * + * Accepts a CSS-like string with one or more space-separated ops: + * `"rotate(45deg)"` + * `"rotate(-15deg) scale(1.2)"` + * `"translate(10, -4) rotate(30deg)"` + * + * Supported ops: `rotate(deg)`, `scale([, ])`, + * `translate([, ])`. Angles are degrees, lengths are points. + */ + transform?: string; + /** + * Origin of the transform as fractions of the element box. + * `"50% 50%"` (default) = center, `"0% 0%"` = top-left, `"100% 0%"` + * = top-right, etc. Also accepts a tuple `[x, y]` where each is 0–1. + */ + transformOrigin?: string | [number, number]; + textDecoration?: 'none' | 'underline' | 'line-through'; + textTransform?: 'none' | 'uppercase' | 'lowercase' | 'capitalize'; + hyphens?: 'none' | 'manual' | 'auto'; + /** Language tag (BCP 47, e.g. "en-US", "de"). Controls hyphenation dictionary. */ + lang?: string; + /** Text direction for BiDi support (Arabic, Hebrew). */ + direction?: 'ltr' | 'rtl' | 'auto'; + /** Text overflow behavior: 'wrap' (default), 'ellipsis' (truncate with ...), 'clip' (truncate). */ + textOverflow?: 'wrap' | 'ellipsis' | 'clip'; + /** Line breaking algorithm: 'optimal' (Knuth-Plass, default) or 'greedy'. */ + lineBreaking?: 'optimal' | 'greedy'; + /** Overflow behavior: 'visible' (default) or 'hidden' (clips children to bounds). */ + overflow?: 'visible' | 'hidden'; + + // Visual + color?: string; + backgroundColor?: string; + /** + * CSS background. Supports: + * - solid colors (`"#1e293b"`, `"rgba(...)"`) — equivalent to backgroundColor + * - 2-stop linear gradients: `"linear-gradient(180deg, #fff 0%, #000 100%)"` + * - 2-stop radial gradients: `"radial-gradient(circle, #fff 0%, #000 100%)"` + * + * v1 supports exactly 2 color stops; gradients with 3+ stops fall back to + * the first and last stop. Multi-stop support is planned via PDF Type 3 + * stitching functions in a follow-up. + */ + background?: string; + opacity?: number; + borderWidth?: number | Edges; + borderTopWidth?: number; + borderRightWidth?: number; + borderBottomWidth?: number; + borderLeftWidth?: number; + borderColor?: string | EdgeColors; + borderTopColor?: string; + borderRightColor?: string; + borderBottomColor?: string; + borderLeftColor?: string; + borderRadius?: number | Corners; + borderTopLeftRadius?: number; + borderTopRightRadius?: number; + borderBottomRightRadius?: number; + borderBottomLeftRadius?: number; + + // Border shorthands (CSS-like string parsing) + /** CSS border shorthand, e.g. `"1px solid #000"` */ + border?: string; + /** Per-side border shorthand: string parses as CSS, number sets width */ + borderTop?: string | number; + /** Per-side border shorthand: string parses as CSS, number sets width */ + borderRight?: string | number; + /** Per-side border shorthand: string parses as CSS, number sets width */ + borderBottom?: string | number; + /** Per-side border shorthand: string parses as CSS, number sets width */ + borderLeft?: string | number; + + // Positioning + position?: 'relative' | 'absolute'; + top?: number; + right?: number; + bottom?: number; + left?: number; + + // Page behavior + wrap?: boolean; + breakBefore?: boolean; + minWidowLines?: number; + minOrphanLines?: number; +} + +export interface CertificationConfig { + /** PEM-encoded X.509 certificate. */ + certificatePem: string; + /** PEM-encoded RSA private key (PKCS#8). */ + privateKeyPem: string; + /** Reason for certification (e.g. "Approved"). */ + reason?: string; + /** Location of certification (e.g. "New York, NY"). */ + location?: string; + /** Contact info for the certifier. */ + contact?: string; + /** Whether to show a visible signature annotation on the page. */ + visible?: boolean; + /** X coordinate in points for visible signature. */ + x?: number; + /** Y coordinate in points for visible signature. */ + y?: number; + /** Width in points for visible signature. */ + width?: number; + /** Height in points for visible signature. */ + height?: number; +} + +/** @deprecated Use CertificationConfig */ +export type SignatureConfig = CertificationConfig; + +export interface ColumnDef { + width: { fraction: number } | { fixed: number } | 'auto'; +} + +export type BarcodeFormat = 'Code128' | 'Code39' | 'EAN13' | 'EAN8' | 'Codabar'; + +/** Data point for bar and pie charts. */ +export interface ChartDataPoint { + label: string; + value: number; + /** Optional per-item color (hex string). */ + color?: string; +} + +/** @deprecated Use ChartDataPoint instead (color is now optional on ChartDataPoint). */ +export interface PieDataPoint { + label: string; + value: number; + color: string; +} + +/** A data series for multi-series line and area charts. */ +export interface ChartSeries { + name: string; + data: number[]; + /** Optional series color (hex string). */ + color?: string; +} + +/** A group of (x, y) data points for dot plots. */ +export interface DotPlotGroup { + name: string; + /** Optional group color (hex string). */ + color?: string; + data: [number, number][]; +} + +/** Canvas drawing context for the draw callback. */ +export interface CanvasContext { + moveTo(x: number, y: number): void; + lineTo(x: number, y: number): void; + bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void; + quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void; + closePath(): void; + rect(x: number, y: number, w: number, h: number): void; + circle(cx: number, cy: number, r: number): void; + ellipse(cx: number, cy: number, rx: number, ry: number): void; + arc(cx: number, cy: number, r: number, startAngle: number, endAngle: number, counterclockwise?: boolean): void; + /** Convenience: draws a stroked line from (x1,y1) to (x2,y2). */ + line(x1: number, y1: number, x2: number, y2: number): void; + stroke(): void; + fill(): void; + fillAndStroke(): void; + setFillColor(r: number, g: number, b: number): void; + setStrokeColor(r: number, g: number, b: number): void; + setLineWidth(w: number): void; + setLineCap(cap: number): void; + setLineJoin(join: number): void; + save(): void; + restore(): void; +} + +/** A single canvas drawing operation (serialized to JSON). */ +export type CanvasOp = + | { op: 'MoveTo'; x: number; y: number } + | { op: 'LineTo'; x: number; y: number } + | { op: 'BezierCurveTo'; cp1x: number; cp1y: number; cp2x: number; cp2y: number; x: number; y: number } + | { op: 'QuadraticCurveTo'; cpx: number; cpy: number; x: number; y: number } + | { op: 'ClosePath' } + | { op: 'Rect'; x: number; y: number; width: number; height: number } + | { op: 'Circle'; cx: number; cy: number; r: number } + | { op: 'Ellipse'; cx: number; cy: number; rx: number; ry: number } + | { op: 'Arc'; cx: number; cy: number; r: number; start_angle: number; end_angle: number; counterclockwise: boolean } + | { op: 'Stroke' } + | { op: 'Fill' } + | { op: 'FillAndStroke' } + | { op: 'SetFillColor'; r: number; g: number; b: number } + | { op: 'SetStrokeColor'; r: number; g: number; b: number } + | { op: 'SetLineWidth'; width: number } + | { op: 'SetLineCap'; cap: number } + | { op: 'SetLineJoin'; join: number } + | { op: 'Save' } + | { op: 'Restore' }; + +/** A styled text segment within a element */ +export interface TextRun { + content: string; + style?: FormeStyle; + href?: string; +} + +// ─── Forme JSON output types (match Rust serde format) ─────────────── + +export interface FormeFont { + family: string; + src: string | Uint8Array; + weight: number; + italic: boolean; +} + +export interface FormeDocument { + children: FormeNode[]; + metadata: FormeMetadata; + defaultPage: FormePageConfig; + defaultStyle?: FormeStyle; + fonts?: FormeFont[]; + tagged?: boolean; + pdfa?: '2a' | '2b'; + pdfUa?: boolean; + flattenForms?: boolean; + certification?: CertificationConfig; +} + +export interface FormeMetadata { + title?: string; + author?: string; + subject?: string; + creator?: string; + lang?: string; +} + +export interface FormePageConfig { + size: FormePageSize; + margin: FormeEdges; + wrap: boolean; + backgroundImage?: string; + backgroundOpacity?: number; + backgroundSize?: 'fill' | 'cover' | 'contain'; + backgroundPosition?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +} + +export type FormePageSize = + | 'A4' | 'A3' | 'A5' | 'Letter' | 'Legal' | 'Tabloid' + | { Custom: { width: number; height: number } }; + +export interface FormeEdges { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface FormeBoxShadow { + offsetX: number; + offsetY: number; + blur?: number; + color: { r: number; g: number; b: number; a: number }; +} + +export interface FormeGradientStop { + position: number; + color: { r: number; g: number; b: number; a: number }; +} + +export type FormeBackground = + | { type: 'color'; value: { r: number; g: number; b: number; a: number } } + | { type: 'linear'; angleDeg: number; stops: FormeGradientStop[] } + | { type: 'radial'; stops: FormeGradientStop[] }; + +/** Margin edges that support auto values (for centering). */ +export interface FormeMarginEdges { + top: number | 'auto'; + right: number | 'auto'; + bottom: number | 'auto'; + left: number | 'auto'; +} + +export interface FormeNode { + kind: FormeNodeKind; + style: FormeStyle; + children: FormeNode[]; + bookmark?: string; + href?: string; + alt?: string; + sourceLocation?: { file: string; line: number; column: number }; +} + +export type FormeNodeKind = + | { type: 'Page'; config: FormePageConfig } + | { type: 'View' } + | { type: 'Text'; content: string; href?: string; runs?: TextRun[] } + | { type: 'Heading'; level: number; content: string; href?: string; runs?: TextRun[] } + | { type: 'List'; ordered: boolean; marker_type: FormeListMarkerType; start: number } + | { type: 'ListItem' } + | { type: 'Image'; src: string; width?: number; height?: number } + | { type: 'Table'; columns: FormeColumnDef[] } + | { type: 'TableRow'; is_header: boolean } + | { type: 'TableCell'; col_span: number; row_span: number } + | { type: 'Fixed'; position: 'Header' | 'Footer' } + | { type: 'Svg'; width: number; height: number; view_box?: string; content: string } + | { type: 'QrCode'; data: string; size?: number } + | { type: 'Barcode'; data: string; format: BarcodeFormat; width?: number; height: number } + | { type: 'Canvas'; width: number; height: number; operations: CanvasOp[] } + | { type: 'BarChart'; data: ChartDataPoint[]; width: number; height: number; color?: string; show_labels: boolean; show_values: boolean; show_grid: boolean; title?: string } + | { type: 'LineChart'; series: ChartSeries[]; labels: string[]; width: number; height: number; show_points: boolean; show_grid: boolean; title?: string } + | { type: 'PieChart'; data: ChartDataPoint[]; width: number; height: number; donut: boolean; show_legend: boolean; title?: string } + | { type: 'AreaChart'; series: ChartSeries[]; labels: string[]; width: number; height: number; show_grid: boolean; title?: string } + | { type: 'DotPlot'; groups: DotPlotGroup[]; width: number; height: number; x_min?: number; x_max?: number; y_min?: number; y_max?: number; x_label?: string; y_label?: string; show_legend: boolean; dot_size: number } + | { type: 'Watermark'; text: string; font_size: number; angle: number } + | { type: 'TextField'; name: string; width: number; height: number; value?: string; placeholder?: string; multiline: boolean; password: boolean; read_only: boolean; max_length?: number; font_size: number } + | { type: 'Checkbox'; name: string; width: number; height: number; checked: boolean; read_only: boolean } + | { type: 'Dropdown'; name: string; options: string[]; width: number; height: number; value?: string; read_only: boolean; font_size: number } + | { type: 'RadioButton'; name: string; value: string; width: number; height: number; checked: boolean; read_only: boolean } + | { type: 'PageBreak' }; + +/** + * Marker style for an ordered or unordered list. Matches CSS + * `list-style-type` for the supported values. + */ +export type ListMarker = + | 'disc' + | 'circle' + | 'square' + | 'none' + | 'decimal' + | 'lower-alpha' + | 'upper-alpha' + | 'lower-roman' + | 'upper-roman'; + +/** Engine wire format for list marker type — matches the Rust enum's + * serde camelCase output. */ +export type FormeListMarkerType = + | 'disc' + | 'circle' + | 'square' + | 'none' + | 'decimal' + | 'lowerAlpha' + | 'upperAlpha' + | 'lowerRoman' + | 'upperRoman'; + +/** A single transform operation in the engine wire format. The discriminator + * is `type` matching the engine's serde tag. */ +export type FormeTransformOp = + | { type: 'rotate'; deg: number } + | { type: 'scale'; x: number; y: number } + | { type: 'translate'; x: number; y: number }; + +export interface FormeColumnDef { + width: FormeColumnWidth; +} + +export type FormeColumnWidth = + | { Fraction: number } + | { Fixed: number } + | 'Auto'; + +export type FormeDimension = + | { Pt: number } + | { Percent: number } + | 'Auto'; + +export interface FormeColor { + r: number; + g: number; + b: number; + a: number; +} + +export interface FormeEdgeValues { + top: T; + right: T; + bottom: T; + left: T; +} + +export interface FormeCornerValues { + top_left: number; + top_right: number; + bottom_right: number; + bottom_left: number; +} + +/** Grid track size in Forme JSON format (matches Rust GridTrackSize enum) */ +export type FormeGridTrackSize = + | { Pt: number } + | { Fr: number } + | 'Auto' + | { MinMax: [FormeGridTrackSize, FormeGridTrackSize] }; + +/** Grid placement in Forme JSON format */ +export interface FormeGridPlacement { + columnStart?: number; + columnEnd?: number; + rowStart?: number; + rowEnd?: number; + columnSpan?: number; + rowSpan?: number; +} + +/** Style in the Forme JSON format (camelCase field names, PascalCase enum values) */ +export interface FormeStyle { + display?: string; + width?: FormeDimension; + height?: FormeDimension; + minWidth?: FormeDimension; + minHeight?: FormeDimension; + maxWidth?: FormeDimension; + maxHeight?: FormeDimension; + padding?: FormeEdges; + margin?: FormeMarginEdges; + flexDirection?: string; + justifyContent?: string; + alignItems?: string; + alignSelf?: string; + alignContent?: string; + flexWrap?: string; + flexGrow?: number; + flexShrink?: number; + flexBasis?: FormeDimension; + gap?: number; + rowGap?: number; + columnGap?: number; + gridTemplateColumns?: FormeGridTrackSize[]; + gridTemplateRows?: FormeGridTrackSize[]; + gridAutoRows?: FormeGridTrackSize; + gridAutoColumns?: FormeGridTrackSize; + gridPlacement?: FormeGridPlacement; + fontFamily?: string; + fontSize?: number; + fontWeight?: number; + fontStyle?: string; + lineHeight?: number; + textAlign?: string; + letterSpacing?: number; + wordSpacing?: number; + boxShadow?: FormeBoxShadow; + transform?: FormeTransformOp[]; + transformOrigin?: [number, number]; + textDecoration?: string; + textTransform?: string; + hyphens?: string; + lang?: string; + direction?: string; + textOverflow?: string; + lineBreaking?: string; + overflow?: string; + color?: FormeColor; + backgroundColor?: FormeColor; + background?: FormeBackground; + opacity?: number; + borderWidth?: FormeEdgeValues; + borderColor?: FormeEdgeValues; + borderRadius?: FormeCornerValues; + position?: string; + top?: number; + right?: number; + bottom?: number; + left?: number; + wrap?: boolean; + breakBefore?: boolean; + minWidowLines?: number; + minOrphanLines?: number; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..71325ff --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} From 7b3aec33feee47555f1afa853ba48d96c5629526 Mon Sep 17 00:00:00 2001 From: Chris Joseph <95969273+cmjoseph07@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:43:15 -0500 Subject: [PATCH 02/18] packages/svelte: add Svelte adapter walking skeleton --- .gitignore | 1 + package-lock.json | 696 +++++++++++++++--- packages/svelte/package.json | 51 ++ .../svelte/src/components/Document.svelte | 31 + packages/svelte/src/components/Page.svelte | 24 + packages/svelte/src/components/Text.svelte | 16 + packages/svelte/src/components/View.svelte | 17 + packages/svelte/src/encode.ts | 39 + packages/svelte/src/index.ts | 43 ++ packages/svelte/src/parser.ts | 391 ++++++++++ packages/svelte/src/serialize.ts | 59 ++ packages/svelte/src/stylesheet.ts | 7 + packages/svelte/tests/encode.test.ts | 33 + .../svelte/tests/fixtures/bad-prop.svelte | 7 + .../svelte/tests/fixtures/hello-world.svelte | 25 + .../svelte/tests/fixtures/hello-world.tsx | 24 + .../svelte/tests/fixtures/kitchen-sink.svelte | 32 + .../svelte/tests/fixtures/kitchen-sink.tsx | 33 + packages/svelte/tests/parity.test.tsx | 35 + packages/svelte/tests/parser.test.ts | 337 +++++++++ packages/svelte/tests/serialize.test.ts | 59 ++ packages/svelte/tests/wasm-smoke.test.ts | 24 + packages/svelte/tsconfig.json | 16 + packages/svelte/vitest.config.ts | 13 + 24 files changed, 1893 insertions(+), 120 deletions(-) create mode 100644 packages/svelte/package.json create mode 100644 packages/svelte/src/components/Document.svelte create mode 100644 packages/svelte/src/components/Page.svelte create mode 100644 packages/svelte/src/components/Text.svelte create mode 100644 packages/svelte/src/components/View.svelte create mode 100644 packages/svelte/src/encode.ts create mode 100644 packages/svelte/src/index.ts create mode 100644 packages/svelte/src/parser.ts create mode 100644 packages/svelte/src/serialize.ts create mode 100644 packages/svelte/src/stylesheet.ts create mode 100644 packages/svelte/tests/encode.test.ts create mode 100644 packages/svelte/tests/fixtures/bad-prop.svelte create mode 100644 packages/svelte/tests/fixtures/hello-world.svelte create mode 100644 packages/svelte/tests/fixtures/hello-world.tsx create mode 100644 packages/svelte/tests/fixtures/kitchen-sink.svelte create mode 100644 packages/svelte/tests/fixtures/kitchen-sink.tsx create mode 100644 packages/svelte/tests/parity.test.tsx create mode 100644 packages/svelte/tests/parser.test.ts create mode 100644 packages/svelte/tests/serialize.test.ts create mode 100644 packages/svelte/tests/wasm-smoke.test.ts create mode 100644 packages/svelte/tsconfig.json create mode 100644 packages/svelte/vitest.config.ts diff --git a/.gitignore b/.gitignore index a488c4b..7aaaad9 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ packages/vscode/pkg/ # Node node_modules/ dist/ +.svelte-kit/ # OS .DS_Store diff --git a/package-lock.json b/package-lock.json index 5304ae8..f3e2dba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1084,6 +1084,10 @@ "resolved": "packages/shared", "link": true }, + "node_modules/@formepdf/svelte": { + "resolved": "packages/svelte", + "link": true + }, "node_modules/@formepdf/tailwind": { "resolved": "packages/tailwind", "link": true @@ -1570,6 +1574,50 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2077,6 +2125,119 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/package": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/@sveltejs/package/-/package-2.5.8.tgz", + "integrity": "sha512-zeBbsXYvHiBu56v4gJaGQoEHzg96w0E1j3dOMX8vo56s6vI5eQ57ZEZhudjwjnegnVitRRu5MrmhO0eNvaonIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "kleur": "^4.1.5", + "sade": "^1.8.1", + "semver": "^7.5.4", + "svelte2tsx": "~0.7.55" + }, + "bin": { + "svelte-package": "svelte-package.js" + }, + "engines": { + "node": "^16.14 || >=18" + }, + "peerDependencies": { + "svelte": "^3.44.0 || ^4.0.0 || ^5.0.0-next.1" + } + }, + "node_modules/@sveltejs/package/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@sveltejs/package/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2131,6 +2292,13 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/vscode": { "version": "1.109.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", @@ -2333,6 +2501,16 @@ } } }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2343,6 +2521,16 @@ "node": ">=12" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.31", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz", @@ -2527,6 +2715,16 @@ "license": "MIT", "peer": true }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -2622,6 +2820,13 @@ } } }, + "node_modules/dedent-js": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz", + "integrity": "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -2701,6 +2906,13 @@ "node": ">=8" } }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -2907,6 +3119,31 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3343,6 +3580,16 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -3412,6 +3659,13 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -3527,6 +3781,16 @@ } } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3703,6 +3967,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseley": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", @@ -4044,6 +4332,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4057,6 +4358,13 @@ "license": "MIT", "peer": true }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, "node_modules/selderee": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", @@ -4361,6 +4669,85 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.1.tgz", + "integrity": "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-check/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/svelte2tsx": { + "version": "0.7.57", + "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.57.tgz", + "integrity": "sha512-nQo0xEfUpSVjfkxan2UmC6Wl9UZVe9+/pglUiyBNMJ7KDQ9DDooucmXdToBOvzSfgYjj/kMYwf6CTTDz/z048w==", + "dev": true, + "license": "MIT", + "dependencies": { + "dedent-js": "^1.0.1", + "scule": "^1.3.0" + }, + "peerDependencies": { + "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", + "typescript": "^4.9.4 || ^5.0.0 || ^6.0.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4511,24 +4898,24 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -4537,14 +4924,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", - "less": "^4.0.0", + "less": "*", "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -4609,9 +4996,9 @@ } }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -4626,9 +5013,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -4643,9 +5030,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -4660,9 +5047,9 @@ } }, "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -4677,9 +5064,9 @@ } }, "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -4694,9 +5081,9 @@ } }, "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -4711,9 +5098,9 @@ } }, "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -4728,9 +5115,9 @@ } }, "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -4745,9 +5132,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -4762,9 +5149,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -4779,9 +5166,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -4796,9 +5183,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -4813,9 +5200,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -4830,9 +5217,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -4847,9 +5234,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -4864,9 +5251,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -4881,9 +5268,9 @@ } }, "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -4898,9 +5285,9 @@ } }, "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], @@ -4915,9 +5302,9 @@ } }, "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -4932,9 +5319,9 @@ } }, "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", "cpu": [ "arm64" ], @@ -4949,9 +5336,9 @@ } }, "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -4965,10 +5352,27 @@ "node": ">=18" } }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -4983,9 +5387,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -5000,9 +5404,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -5017,9 +5421,9 @@ } }, "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -5034,9 +5438,9 @@ } }, "node_modules/vite/node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5047,32 +5451,52 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } } }, "node_modules/vitest": { @@ -5791,6 +6215,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -5998,6 +6429,31 @@ "typescript": "^5.7.0" } }, + "packages/svelte": { + "name": "@formepdf/svelte", + "version": "0.10.4", + "license": "MIT", + "dependencies": { + "@formepdf/shared": "0.10.4", + "parse5": "^7.2.1" + }, + "devDependencies": { + "@formepdf/core": "0.10.4", + "@formepdf/react": "0.10.4", + "@sveltejs/package": "^2.3.10", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/react": "^19.0.0", + "react": "^19.0.0", + "svelte": "^5.30.0", + "svelte-check": "^4.7.1", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + }, + "peerDependencies": { + "svelte": "^5.30.0" + } + }, "packages/tailwind": { "name": "@formepdf/tailwind", "version": "0.10.5", diff --git a/packages/svelte/package.json b/packages/svelte/package.json new file mode 100644 index 0000000..5398bcc --- /dev/null +++ b/packages/svelte/package.json @@ -0,0 +1,51 @@ +{ + "name": "@formepdf/svelte", + "version": "0.10.4", + "description": "Svelte-to-JSON serializer for Forme PDF engine", + "type": "module", + "main": "./dist/index.js", + "svelte": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "svelte-package -i src -o dist", + "check": "svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@formepdf/shared": "0.10.4", + "parse5": "^7.2.1" + }, + "peerDependencies": { + "svelte": "^5.30.0" + }, + "devDependencies": { + "@formepdf/core": "0.10.4", + "@formepdf/react": "0.10.4", + "@sveltejs/package": "^2.3.10", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/react": "^19.0.0", + "react": "^19.0.0", + "svelte": "^5.30.0", + "svelte-check": "^4.7.1", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + }, + "files": [ + "dist" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/formepdf/forme", + "directory": "packages/svelte" + } +} diff --git a/packages/svelte/src/components/Document.svelte b/packages/svelte/src/components/Document.svelte new file mode 100644 index 0000000..85a926b --- /dev/null +++ b/packages/svelte/src/components/Document.svelte @@ -0,0 +1,31 @@ + + +{@render children?.()} diff --git a/packages/svelte/src/components/Page.svelte b/packages/svelte/src/components/Page.svelte new file mode 100644 index 0000000..d38797b --- /dev/null +++ b/packages/svelte/src/components/Page.svelte @@ -0,0 +1,24 @@ + + +{@render children?.()} diff --git a/packages/svelte/src/components/Text.svelte b/packages/svelte/src/components/Text.svelte new file mode 100644 index 0000000..ab829da --- /dev/null +++ b/packages/svelte/src/components/Text.svelte @@ -0,0 +1,16 @@ + + +{@render children?.()} diff --git a/packages/svelte/src/components/View.svelte b/packages/svelte/src/components/View.svelte new file mode 100644 index 0000000..81b645e --- /dev/null +++ b/packages/svelte/src/components/View.svelte @@ -0,0 +1,17 @@ + + +{@render children?.()} diff --git a/packages/svelte/src/encode.ts b/packages/svelte/src/encode.ts new file mode 100644 index 0000000..fbfefdd --- /dev/null +++ b/packages/svelte/src/encode.ts @@ -0,0 +1,39 @@ +/** + * Prop encoding for the emitting components. + * + * Each Forme Svelte component renders one placeholder tag whose + * `props` attribute carries the component's props as JSON (part of + * the internal emitter/parser contract — see `parser.ts`). Values + * that cannot survive the JSON round-trip fail loudly here, naming + * the component and prop, rather than silently producing a wrong PDF. + */ + +/** + * JSON-encode a component's props for the placeholder `props` + * attribute. `undefined` values are omitted (matching how absent + * props behave in the react adapter). Functions, symbols, bigints, + * and circular structures throw an error naming the component and + * the offending prop. + */ +export function encodeProps(component: string, props: Record): string { + const parts: string[] = []; + for (const [key, value] of Object.entries(props)) { + if (value === undefined) continue; + let json: string | undefined; + try { + json = JSON.stringify(value, (_k, v) => { + const t = typeof v; + if (t === 'function' || t === 'symbol' || t === 'bigint') { + throw new TypeError(`contains a ${t}, which cannot be serialized to JSON`); + } + return v; + }); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`[Forme] <${component}>: prop "${key}" is not serializable: ${reason}`); + } + if (json === undefined) continue; + parts.push(`${JSON.stringify(key)}:${json}`); + } + return `{${parts.join(',')}}`; +} diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts new file mode 100644 index 0000000..9ed9126 --- /dev/null +++ b/packages/svelte/src/index.ts @@ -0,0 +1,43 @@ +// Components +export { default as Document } from './components/Document.svelte'; +export { default as Page } from './components/Page.svelte'; +export { default as View } from './components/View.svelte'; +export { default as Text } from './components/Text.svelte'; + +// Serialization +export { serialize, render, renderToObject } from './serialize.js'; +export type { SerializeOptions } from './serialize.js'; +export { mapStyle, mapDimension, parseColor, expandEdges, expandCorners } from '@formepdf/shared'; + +// StyleSheet +export { StyleSheet } from './stylesheet.js'; + +// Types +export type { + // Developer-facing + Style, + GridTrackSize, + Edges, + Corners, + EdgeColors, + CertificationConfig, + SignatureConfig, + // Forme JSON output + FormeDocument, + FormeFont, + FormeNode, + FormeNodeKind, + FormeStyle, + FormeMetadata, + FormePageConfig, + FormePageSize, + FormeEdges, + FormeColumnDef, + FormeColumnWidth, + FormeDimension, + FormeColor, + FormeEdgeValues, + FormeCornerValues, + FormeGridTrackSize, + FormeGridPlacement, +} from '@formepdf/shared'; diff --git a/packages/svelte/src/parser.ts b/packages/svelte/src/parser.ts new file mode 100644 index 0000000..388548b --- /dev/null +++ b/packages/svelte/src/parser.ts @@ -0,0 +1,391 @@ +/** + * Markup parser: Svelte SSR output in, Forme document model out. + * + * The Forme Svelte components render namespaced placeholder tags + * (``, ``, ``, ``) + * with a single `props` attribute holding the JSON-encoded component + * props. This module parses that markup — the emitting components and + * this parser are a matched pair inside this package. The placeholder + * tag/attribute vocabulary is an INTERNAL contract, not a public + * format: it may change in any release without notice. + * + * Whitespace is normalized to JSX-equivalent semantics so that a + * `.svelte` template and its TSX counterpart serialize identically: + * indentation and newlines never leak into text content, while + * interior spaces and interpolation boundaries are preserved. The + * Svelte compiler trims fragment edges and collapses inter-element + * whitespace before we ever see the markup, so the remaining rules + * are applied here (see `cleanJsxText`). Known divergences from JSX, + * where the compiler destroys the distinction before serialization — + * each matches how Svelte itself renders the template to the DOM: + * + * - Same-line leading/trailing spaces inside `` are trimmed + * (` a ` → `"a"`; JSX keeps them). + * - Interpolations split across lines join with a single space + * (JSX drops the whitespace-only literal between them entirely). + * - Newlines inside interpolated *values* are treated like template + * text and collapse to a space. + */ + +import { parseFragment } from 'parse5'; +import type { DefaultTreeAdapterMap } from 'parse5'; +import { expandEdges, mapStyle } from '@formepdf/shared'; +import type { + CertificationConfig, + Edges, + FormeDocument, + FormeEdges, + FormeNode, + FormePageConfig, + FormePageSize, + Style, +} from '@formepdf/shared'; + +type P5Node = DefaultTreeAdapterMap['childNode']; +type P5Element = DefaultTreeAdapterMap['element']; + +type P5Text = DefaultTreeAdapterMap['textNode']; + +function isElement(node: P5Node): node is P5Element { + return 'tagName' in node; +} + +function isText(node: P5Node): node is P5Text { + return node.nodeName === '#text'; +} + +/** + * Parse placeholder markup emitted by the Forme Svelte components into + * a Forme JSON document object. The markup must contain a single + * `` root element. + */ +interface DocumentProps { + title?: string; + author?: string; + subject?: string; + creator?: string; + lang?: string; + style?: Style; + tagged?: boolean; + pdfa?: '2a' | '2b'; + pdfUa?: boolean; + certification?: CertificationConfig; + /** @deprecated Use certification */ + signature?: CertificationConfig; +} + +export function parseMarkup(markup: string): FormeDocument { + const fragment = parseFragment(markup); + const root = findDocumentRoot(fragment.childNodes); + + const props = decodeProps(root, 'Document') as DocumentProps; + + // Separate Page children from content children + const pageNodes: FormeNode[] = []; + const contentNodes: FormeNode[] = []; + for (const child of root.childNodes) { + if (isElement(child) && child.tagName === 'forme-page') { + pageNodes.push(parsePage(child)); + } else { + const node = parseChild(child, 'Document'); + if (node) contentNodes.push(node); + } + } + + // If there are page nodes, use them. Otherwise the content stands alone. + let children: FormeNode[]; + if (pageNodes.length > 0) { + // Any loose content nodes get added to the last page's children + if (contentNodes.length > 0) { + const lastPage = pageNodes[pageNodes.length - 1]; + lastPage.children.push(...contentNodes); + } + children = pageNodes; + } else { + children = contentNodes; + } + + const metadata: FormeDocument['metadata'] = {}; + if (props.title !== undefined) metadata.title = props.title; + if (props.author !== undefined) metadata.author = props.author; + if (props.subject !== undefined) metadata.subject = props.subject; + if (props.creator !== undefined) metadata.creator = props.creator; + if (props.lang !== undefined) metadata.lang = props.lang; + + const result: FormeDocument = { + children, + metadata, + defaultPage: { + size: 'A4', + margin: { top: 54, right: 54, bottom: 54, left: 54 }, + wrap: true, + }, + }; + + if (props.style) result.defaultStyle = mapStyle(props.style); + if (props.tagged !== undefined) result.tagged = props.tagged; + if (props.pdfa !== undefined) result.pdfa = props.pdfa; + if (props.pdfUa) result.pdfUa = true; + const cert = props.certification ?? props.signature; + if (cert) { + if (props.signature && !props.certification) { + console.warn('[Forme] The `signature` prop is deprecated. Use `certification` instead.'); + } + result.certification = cert; + } + + return result; +} + +// ─── Node dispatch ─────────────────────────────────────────────────── + +type ParentContext = 'Document' | 'Page' | 'View' | 'Table' | 'Row' | 'Cell' | 'Fixed' | null; + +/** + * Parse one markup node into a Forme node. Returns null for nodes that + * produce no output (comments, i.e. Svelte SSR block anchors, and + * whitespace-only text between elements). + */ +function parseChild(node: P5Node, parent: ParentContext): FormeNode | null { + if (isElement(node)) { + return parseElement(node, parent); + } + if (isText(node)) { + // Loose text directly inside a container becomes an anonymous Text + // node. Edges are trimmed: the Svelte compiler collapses the + // whitespace around inter-element newlines to a single space, so + // by the time the markup reaches the parser, indentation and + // intentional edge spaces are indistinguishable. + const content = cleanJsxText(node.value).replace(/^[ \t]+|[ \t]+$/g, ''); + if (content === '') return null; + return { + kind: { type: 'Text', content }, + style: {}, + children: [], + }; + } + return null; +} + +function parseElement(element: P5Element, parent: ParentContext): FormeNode | null { + switch (element.tagName) { + case 'forme-view': + return parseView(element); + case 'forme-text': + return parseText(element); + case 'forme-page': + validateNesting('Page', parent); + return parsePage(element); + default: + throw unknownElementError(element.tagName); + } +} + +// ─── Nesting validation ────────────────────────────────────────────── + +const VALID_PARENTS: Record = { + Page: { + allowed: ['Document'], + suggestion: ' must be a direct child of .', + }, +}; + +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: 'Text', h2: 'Text', + h3: 'Text', img: 'Image', table: 'Table', tr: 'Row', td: 'Cell', +}; + +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: 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; +} + +/** + * Extract the 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. + */ +function textContentOf(nodes: P5Node[]): string { + let merged = ''; + for (const node of nodes) { + if (isText(node)) merged += node.value; + } + return cleanJsxText(merged); +} + +// ─── 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): Record { + const attr = element.attrs.find(a => a.name === 'props'); + if (attr === undefined) return {}; + try { + return JSON.parse(attr.value) as Record; + } 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/serialize.ts b/packages/svelte/src/serialize.ts new file mode 100644 index 0000000..b1b21f3 --- /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/encode.test.ts b/packages/svelte/tests/encode.test.ts new file mode 100644 index 0000000..c09051b --- /dev/null +++ b/packages/svelte/tests/encode.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { encodeProps } from '../src/encode.js'; + +describe('encodeProps', () => { + it('drops undefined props but keeps falsy values', () => { + const json = encodeProps('View', { style: undefined, wrap: false, gap: 0, label: '' }); + expect(JSON.parse(json)).toEqual({ wrap: false, gap: 0, label: '' }); + }); + + it('round-trips nested style objects', () => { + const style = { flexDirection: 'row', padding: [8, 16], border: '1px solid #000' }; + const json = encodeProps('View', { style }); + expect(JSON.parse(json)).toEqual({ style }); + }); + + it('rejects function props, naming component and prop', () => { + expect(() => encodeProps('Text', { style: () => {} })).toThrow( + /\[Forme\] : prop "style".*function/ + ); + }); + + it('rejects functions nested inside props', () => { + expect(() => encodeProps('View', { style: { color: '#fff', bad: () => {} } })).toThrow( + /\[Forme\] : prop "style".*function/ + ); + }); + + it('rejects circular props, naming component and prop', () => { + const circular: Record = {}; + circular.self = circular; + expect(() => encodeProps('Page', { style: circular })).toThrow(/\[Forme\] : prop "style"/); + }); +}); diff --git a/packages/svelte/tests/fixtures/bad-prop.svelte b/packages/svelte/tests/fixtures/bad-prop.svelte new file mode 100644 index 0000000..1b62b8c --- /dev/null +++ b/packages/svelte/tests/fixtures/bad-prop.svelte @@ -0,0 +1,7 @@ + + + + {}}>oops + 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/parity.test.tsx b/packages/svelte/tests/parity.test.tsx new file mode 100644 index 0000000..5d14bf8 --- /dev/null +++ b/packages/svelte/tests/parity.test.tsx @@ -0,0 +1,35 @@ +/** + * 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 } from '../src/index.js'; +import HelloWorldReact from './fixtures/hello-world'; +import KitchenSinkReact from './fixtures/kitchen-sink'; +// @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'; + +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(); + expect(svelteDoc).toEqual(reactDoc); + }); + + it('hello-world with default props', async () => { + const svelteDoc = await serialize(HelloWorldSvelte); + const reactDoc = serializeReact(); + expect(svelteDoc).toEqual(reactDoc); + }); + + it('kitchen-sink: document props, CSS shorthands, multi-line text', async () => { + const svelteDoc = await serialize(KitchenSinkSvelte, { props: { discount: 25 } }); + const reactDoc = serializeReact(); + expect(svelteDoc).toEqual(reactDoc); + }); +}); diff --git a/packages/svelte/tests/parser.test.ts b/packages/svelte/tests/parser.test.ts new file mode 100644 index 0000000..02149b1 --- /dev/null +++ b/packages/svelte/tests/parser.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect } from 'vitest'; +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('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('errors', () => { + it('rejects a Page nested outside Document', () => { + expect(() => + parseIn('') + ).toThrow('Invalid nesting: found inside . must be a direct child of .'); + }); + + 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/serialize.test.ts b/packages/svelte/tests/serialize.test.ts new file mode 100644 index 0000000..7f28afb --- /dev/null +++ b/packages/svelte/tests/serialize.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { serialize, render, renderToObject } from '../src/index.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'; + +describe('serialize', () => { + it('serializes a template with interpolation, #each, and #if', async () => { + const doc = await serialize(HelloWorld, { + props: { name: 'Svelte', items: ['a', 'b'], showFooter: true }, + }); + + expect(doc.metadata).toEqual({ title: 'Hello' }); + expect(doc.children).toHaveLength(1); + + const page = doc.children[0]; + expect(page.kind).toEqual({ + type: 'Page', + config: { + size: 'A4', + margin: { top: 40, right: 40, bottom: 40, left: 40 }, + wrap: true, + }, + }); + + const view = page.children[0]; + expect(view.kind).toEqual({ type: 'View' }); + expect(view.style).toEqual({ flexDirection: 'Column', gap: 8 }); + expect(view.children.map(c => c.kind)).toEqual([ + { type: 'Text', content: 'Hello Svelte!' }, + { type: 'Text', content: 'Item: a' }, + { type: 'Text', content: 'Item: b' }, + { type: 'Text', content: 'The footer' }, + ]); + expect(view.children[0].style).toEqual({ fontSize: 24 }); + }); + + it('honors #if=false and empty #each', async () => { + const doc = await serialize(HelloWorld, { props: {} }); + const view = doc.children[0].children[0]; + expect(view.children.map(c => c.kind)).toEqual([{ type: 'Text', content: 'Hello World!' }]); + }); + + it('render returns a JSON string and renderToObject the same document', async () => { + const [str, obj, doc] = await Promise.all([ + render(HelloWorld, { props: { name: 'X' } }), + renderToObject(HelloWorld, { props: { name: 'X' } }), + serialize(HelloWorld, { props: { name: 'X' } }), + ]); + expect(typeof str).toBe('string'); + expect(JSON.parse(str)).toEqual(obj); + expect(obj).toEqual(doc); + }); + + it('propagates encoding errors naming component and prop', async () => { + await expect(serialize(BadProp)).rejects.toThrow(/\[Forme\] : prop "style"/); + }); +}); diff --git a/packages/svelte/tests/wasm-smoke.test.ts b/packages/svelte/tests/wasm-smoke.test.ts new file mode 100644 index 0000000..7d6e537 --- /dev/null +++ b/packages/svelte/tests/wasm-smoke.test.ts @@ -0,0 +1,24 @@ +/** + * WASM smoke test: the serialized document model renders to real PDF + * bytes through @formepdf/core (devDependency — the adapter itself + * never depends on WASM). + */ +import { describe, it, expect } from 'vitest'; +import { renderPdf } from '@formepdf/core'; +import { render } from '../src/index.js'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import HelloWorld from './fixtures/hello-world.svelte'; + +describe('WASM smoke', () => { + it('renders a serialized .svelte template to valid PDF bytes', async () => { + const json = await render(HelloWorld, { + props: { name: 'Svelte', items: ['alpha', 'beta'], showFooter: true }, + }); + const pdf = await renderPdf(json); + + expect(pdf).toBeInstanceOf(Uint8Array); + expect(pdf.length).toBeGreaterThan(500); + const header = new TextDecoder().decode(pdf.slice(0, 5)); + expect(header).toBe('%PDF-'); + }); +}); diff --git a/packages/svelte/tsconfig.json b/packages/svelte/tsconfig.json new file mode 100644 index 0000000..3aa521c --- /dev/null +++ b/packages/svelte/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "lib": ["esnext", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "esnext", + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/svelte/vitest.config.ts b/packages/svelte/vitest.config.ts new file mode 100644 index 0000000..f561aac --- /dev/null +++ b/packages/svelte/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + test: { + include: ['tests/**/*.test.{ts,tsx}'], + environment: 'node', + }, + esbuild: { + jsx: 'automatic', + }, +}); From 3e6e1850e59c0f7eade11d000199f9d077716daf Mon Sep 17 00:00:00 2001 From: Chris Joseph <95969273+cmjoseph07@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:48:32 -0500 Subject: [PATCH 03/18] feat(svelte): add text runs, Fixed, and page-number constants Nested children serialize to styled runs matching the react adapter's output exactly, including boundary whitespace and deep-nesting flattening. New Fixed component emits header/footer nodes. PAGE_NUMBER / TOTAL_PAGES constants are the documented way to emit page-number placeholders, since {{pageNumber}} parses as a Svelte expression. --- packages/svelte/src/components/Fixed.svelte | 18 ++++ packages/svelte/src/constants.ts | 22 +++++ packages/svelte/src/index.ts | 5 + packages/svelte/src/parser.ts | 94 ++++++++++++++++-- .../tests/fixtures/fixed-page-numbers.svelte | 23 +++++ .../tests/fixtures/fixed-page-numbers.tsx | 24 +++++ .../svelte/tests/fixtures/text-runs.svelte | 16 +++ packages/svelte/tests/fixtures/text-runs.tsx | 17 ++++ packages/svelte/tests/parity.test.tsx | 18 ++++ packages/svelte/tests/parser.test.ts | 99 +++++++++++++++++++ packages/svelte/tests/wasm-smoke.test.ts | 49 +++++++++ 11 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 packages/svelte/src/components/Fixed.svelte create mode 100644 packages/svelte/src/constants.ts create mode 100644 packages/svelte/tests/fixtures/fixed-page-numbers.svelte create mode 100644 packages/svelte/tests/fixtures/fixed-page-numbers.tsx create mode 100644 packages/svelte/tests/fixtures/text-runs.svelte create mode 100644 packages/svelte/tests/fixtures/text-runs.tsx diff --git a/packages/svelte/src/components/Fixed.svelte b/packages/svelte/src/components/Fixed.svelte new file mode 100644 index 0000000..9c9c8a0 --- /dev/null +++ b/packages/svelte/src/components/Fixed.svelte @@ -0,0 +1,18 @@ + + +{@render children?.()} diff --git a/packages/svelte/src/constants.ts b/packages/svelte/src/constants.ts new file mode 100644 index 0000000..964c72c --- /dev/null +++ b/packages/svelte/src/constants.ts @@ -0,0 +1,22 @@ +/** + * Page-number placeholders. + * + * The engine substitutes `{{pageNumber}}` and `{{totalPages}}` in text + * content when it serializes each page. In JSX the braces can be typed + * as a string literal (`{'{{pageNumber}}'}`), but in a Svelte template + * `{{pageNumber}}` parses as an object-literal expression — so the + * documented way to emit the placeholders is interpolating these + * constants: + * + * ```svelte + * + * Page {PAGE_NUMBER} of {TOTAL_PAGES} + * + * ``` + */ + +/** Replaced with the current page number at render time. */ +export const PAGE_NUMBER = '{{pageNumber}}'; + +/** Replaced with the document's total page count at render time. */ +export const TOTAL_PAGES = '{{totalPages}}'; diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 9ed9126..5b260a2 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -3,6 +3,10 @@ export { default as Document } from './components/Document.svelte'; export { default as Page } from './components/Page.svelte'; export { default as View } from './components/View.svelte'; export { default as Text } from './components/Text.svelte'; +export { default as Fixed } from './components/Fixed.svelte'; + +// Page-number placeholders +export { PAGE_NUMBER, TOTAL_PAGES } from './constants.js'; // Serialization export { serialize, render, renderToObject } from './serialize.js'; @@ -16,6 +20,7 @@ export { StyleSheet } from './stylesheet.js'; export type { // Developer-facing Style, + TextRun, GridTrackSize, Edges, Corners, diff --git a/packages/svelte/src/parser.ts b/packages/svelte/src/parser.ts index 388548b..9eac571 100644 --- a/packages/svelte/src/parser.ts +++ b/packages/svelte/src/parser.ts @@ -39,6 +39,7 @@ import type { FormePageConfig, FormePageSize, Style, + TextRun, } from '@formepdf/shared'; type P5Node = DefaultTreeAdapterMap['childNode']; @@ -173,6 +174,8 @@ function parseElement(element: P5Element, parent: ParentContext): FormeNode | nu return parseView(element); case 'forme-text': return parseText(element); + case 'forme-fixed': + return parseFixed(element); case 'forme-page': validateNesting('Page', parent); return parsePage(element); @@ -304,7 +307,13 @@ interface TextProps { function parseText(element: P5Element): FormeNode { const props = decodeProps(element, 'Text') as TextProps; - const kind: FormeNode['kind'] = { type: 'Text', content: textContentOf(element.childNodes) }; + 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 = { @@ -318,17 +327,90 @@ function parseText(element: P5Element): FormeNode { } /** - * Extract the 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. + * Build styled text runs from a Text element's children, mirroring the + * react adapter: only used when at least one direct child is a nested + * `` (returns null otherwise). Plain text chunks become unstyled + * runs; each nested `` becomes one run carrying its own + * style/href with all its descendant text merged (deeper nesting + * flattens — a run has exactly one style). Elements other than + * `` 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 === 'forme-text')) return null; + + 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 !== '') runs.push({ content }); + }; + + for (const node of nodes) { + if (isText(node)) { + buffer += node.value; + } else if (isElement(node)) { + flush(); + if (node.tagName !== 'forme-text') continue; + const props = decodeProps(node, 'Text') as TextProps; + const run: TextRun = { content: textContentOf(node.childNodes) }; + if (props.style) run.style = mapStyle(props.style); + if (props.href) run.href = props.href; + runs.push(run); + } + // Comments (SSR block anchors) fall through: the text around them + // stays one contiguous chunk. + } + flush(); + return runs; +} + +/** + * 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 cleanJsxText(merged); + return merged; +} + +// ─── 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; } // ─── Whitespace normalization ──────────────────────────────────────── 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/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/parity.test.tsx b/packages/svelte/tests/parity.test.tsx index 5d14bf8..2dc4416 100644 --- a/packages/svelte/tests/parity.test.tsx +++ b/packages/svelte/tests/parity.test.tsx @@ -8,10 +8,16 @@ import { serialize as serializeReact } from '@formepdf/react'; import { serialize } 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'; // @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'; describe('cross-adapter parity', () => { it('hello-world: interpolation, #each/#if vs map/&&', async () => { @@ -32,4 +38,16 @@ describe('cross-adapter parity', () => { const reactDoc = serializeReact(); expect(svelteDoc).toEqual(reactDoc); }); + + it('text-runs: nested spans, run styles, boundary whitespace', async () => { + const svelteDoc = await serialize(TextRunsSvelte, { props: { price: 42 } }); + const reactDoc = serializeReact(); + expect(svelteDoc).toEqual(reactDoc); + }); + + it('fixed-page-numbers: header/footer, placeholder constants', async () => { + const svelteDoc = await serialize(FixedPageNumbersSvelte, { props: { paragraphs: 5 } }); + const reactDoc = serializeReact(); + expect(svelteDoc).toEqual(reactDoc); + }); }); diff --git a/packages/svelte/tests/parser.test.ts b/packages/svelte/tests/parser.test.ts index 02149b1..e91a7e2 100644 --- a/packages/svelte/tests/parser.test.ts +++ b/packages/svelte/tests/parser.test.ts @@ -208,6 +208,105 @@ describe('text node', () => { }); }); +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('flattens deeper nesting into the run of the outermost span', () => { + expect( + textKind( + `x abc` + ).runs + ).toEqual([{ content: 'x ' }, { content: 'abc' }]); + }); + + it('merges text across SSR block anchor comments within a chunk', () => { + expect( + textKind(`Total due: now`).runs + ).toEqual([{ content: 'Total due: ' }, { content: 'now' }]); + }); +}); + +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'); diff --git a/packages/svelte/tests/wasm-smoke.test.ts b/packages/svelte/tests/wasm-smoke.test.ts index 7d6e537..139b605 100644 --- a/packages/svelte/tests/wasm-smoke.test.ts +++ b/packages/svelte/tests/wasm-smoke.test.ts @@ -3,11 +3,43 @@ * bytes through @formepdf/core (devDependency — the adapter itself * never depends on WASM). */ +import { inflateSync } from 'node:zlib'; import { describe, it, expect } from 'vitest'; import { renderPdf } from '@formepdf/core'; import { render } from '../src/index.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 FixedPageNumbers from './fixtures/fixed-page-numbers.svelte'; + +/** + * Concatenate every stream object in the PDF, inflating the + * FlateDecode-compressed ones, so text-showing operators like + * `(Page 1 of 2) Tj` become searchable. + */ +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; +} describe('WASM smoke', () => { it('renders a serialized .svelte template to valid PDF bytes', async () => { @@ -21,4 +53,21 @@ describe('WASM smoke', () => { const header = new TextDecoder().decode(pdf.slice(0, 5)); expect(header).toBe('%PDF-'); }); + + it('substitutes PAGE_NUMBER / TOTAL_PAGES in a multi-page footer', async () => { + const json = await render(FixedPageNumbers, { props: { paragraphs: 40 } }); + const pdf = await renderPdf(json); + const text = decompressedStreams(pdf); + + const first = text.match(/\(Page 1 of (\d+)\)/); + expect(first).not.toBeNull(); + const total = Number(first![1]); + expect(total).toBeGreaterThan(1); + // The footer repeats on every page with the running page number. + for (let page = 1; page <= total; page++) { + expect(text).toContain(`(Page ${page} of ${total})`); + } + expect(text).not.toContain('{{pageNumber}}'); + expect(text).not.toContain('{{totalPages}}'); + }); }); From 3dfb6b50f09faee32760833d4e424bb870ea1c17 Mon Sep 17 00:00:00 2001 From: Chris Joseph <95969273+cmjoseph07@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:59:24 -0500 Subject: [PATCH 04/18] feat(svelte): add Table, Row, and Cell components The table trio serializes with full prop fidelity: Table columns (fraction / fixed / auto widths via shared mapColumnWidth), Row header flag driving repeat-on-continuation, and Cell colSpan / rowSpan with arbitrary nested content. Nesting is validated with the same error messages as the react adapter. Parity fixture: a 50-row {#each} table deep-equal against its TSX twin, smoke-rendered to a multi-page PDF with the header row repeated on every page. --- packages/svelte/src/components/Cell.svelte | 16 ++++ packages/svelte/src/components/Row.svelte | 17 ++++ packages/svelte/src/components/Table.svelte | 18 ++++ packages/svelte/src/index.ts | 4 + packages/svelte/src/parser.ts | 71 ++++++++++++++- packages/svelte/tests/fixtures/table.svelte | 59 +++++++++++++ packages/svelte/tests/fixtures/table.tsx | 56 ++++++++++++ packages/svelte/tests/parity.test.tsx | 9 ++ packages/svelte/tests/parser.test.ts | 95 +++++++++++++++++++++ packages/svelte/tests/wasm-smoke.test.ts | 19 +++++ 10 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 packages/svelte/src/components/Cell.svelte create mode 100644 packages/svelte/src/components/Row.svelte create mode 100644 packages/svelte/src/components/Table.svelte create mode 100644 packages/svelte/tests/fixtures/table.svelte create mode 100644 packages/svelte/tests/fixtures/table.tsx diff --git a/packages/svelte/src/components/Cell.svelte b/packages/svelte/src/components/Cell.svelte new file mode 100644 index 0000000..9bd1877 --- /dev/null +++ b/packages/svelte/src/components/Cell.svelte @@ -0,0 +1,16 @@ + + +{@render children?.()} diff --git a/packages/svelte/src/components/Row.svelte b/packages/svelte/src/components/Row.svelte new file mode 100644 index 0000000..ce3ee09 --- /dev/null +++ b/packages/svelte/src/components/Row.svelte @@ -0,0 +1,17 @@ + + +{@render children?.()} diff --git a/packages/svelte/src/components/Table.svelte b/packages/svelte/src/components/Table.svelte new file mode 100644 index 0000000..598c091 --- /dev/null +++ b/packages/svelte/src/components/Table.svelte @@ -0,0 +1,18 @@ + + +{@render children?.()} diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 5b260a2..c95a2c2 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -3,6 +3,9 @@ export { default as Document } from './components/Document.svelte'; export { default as Page } from './components/Page.svelte'; export { default as View } from './components/View.svelte'; export { default as Text } from './components/Text.svelte'; +export { default as Table } from './components/Table.svelte'; +export { default as Row } from './components/Row.svelte'; +export { default as Cell } from './components/Cell.svelte'; export { default as Fixed } from './components/Fixed.svelte'; // Page-number placeholders @@ -21,6 +24,7 @@ export type { // Developer-facing Style, TextRun, + ColumnDef, GridTrackSize, Edges, Corners, diff --git a/packages/svelte/src/parser.ts b/packages/svelte/src/parser.ts index 9eac571..f76bc30 100644 --- a/packages/svelte/src/parser.ts +++ b/packages/svelte/src/parser.ts @@ -29,10 +29,12 @@ import { parseFragment } from 'parse5'; import type { DefaultTreeAdapterMap } from 'parse5'; -import { expandEdges, mapStyle } from '@formepdf/shared'; +import { expandEdges, mapColumnWidth, mapStyle } from '@formepdf/shared'; import type { CertificationConfig, + ColumnDef, Edges, + FormeColumnDef, FormeDocument, FormeEdges, FormeNode, @@ -174,6 +176,14 @@ function parseElement(element: P5Element, parent: ParentContext): FormeNode | nu return parseView(element); case 'forme-text': return parseText(element); + case 'forme-table': + return parseTable(element); + case 'forme-row': + validateNesting('Row', parent); + return parseRow(element); + case 'forme-cell': + validateNesting('Cell', parent); + return parseCell(element); case 'forme-fixed': return parseFixed(element); case 'forme-page': @@ -191,6 +201,14 @@ const VALID_PARENTS: Record must be a direct child of .', }, + Row: { + allowed: ['Table'], + suggestion: ' must be inside a . Wrap it:
...
', + }, + Cell: { + allowed: ['Row'], + suggestion: ' must be inside a . Wrap it: ...', + }, }; function validateNesting(componentName: string, parent: ParentContext): void { @@ -390,6 +408,57 @@ function rawTextOf(nodes: P5Node[]): string { 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 { 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/parity.test.tsx b/packages/svelte/tests/parity.test.tsx index 2dc4416..08b4dba 100644 --- a/packages/svelte/tests/parity.test.tsx +++ b/packages/svelte/tests/parity.test.tsx @@ -10,6 +10,7 @@ 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'; // @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 @@ -18,6 +19,8 @@ import KitchenSinkSvelte from './fixtures/kitchen-sink.svelte'; 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'; describe('cross-adapter parity', () => { it('hello-world: interpolation, #each/#if vs map/&&', async () => { @@ -45,6 +48,12 @@ describe('cross-adapter parity', () => { expect(svelteDoc).toEqual(reactDoc); }); + it('table: 50-row #each, header row, column widths, spans, view cells', async () => { + const svelteDoc = await serialize(TableSvelte); + const reactDoc = serializeReact(); + expect(svelteDoc).toEqual(reactDoc); + }); + it('fixed-page-numbers: header/footer, placeholder constants', async () => { const svelteDoc = await serialize(FixedPageNumbersSvelte, { props: { paragraphs: 5 } }); const reactDoc = serializeReact(); diff --git a/packages/svelte/tests/parser.test.ts b/packages/svelte/tests/parser.test.ts index e91a7e2..81e544a 100644 --- a/packages/svelte/tests/parser.test.ts +++ b/packages/svelte/tests/parser.test.ts @@ -406,6 +406,88 @@ describe('document-level props', () => { }); }); +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('errors', () => { it('rejects a Page nested outside Document', () => { expect(() => @@ -413,6 +495,19 @@ describe('errors', () => { ).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.' diff --git a/packages/svelte/tests/wasm-smoke.test.ts b/packages/svelte/tests/wasm-smoke.test.ts index 139b605..38bc04a 100644 --- a/packages/svelte/tests/wasm-smoke.test.ts +++ b/packages/svelte/tests/wasm-smoke.test.ts @@ -11,6 +11,8 @@ import { render } from '../src/index.js'; import HelloWorld from './fixtures/hello-world.svelte'; // @ts-expect-error .svelte fixtures have no type declarations in tests import FixedPageNumbers from './fixtures/fixed-page-numbers.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import TableFixture from './fixtures/table.svelte'; /** * Concatenate every stream object in the PDF, inflating the @@ -54,6 +56,23 @@ describe('WASM smoke', () => { expect(header).toBe('%PDF-'); }); + it('renders a 50-row table with a header row to a multi-page PDF', async () => { + const json = await render(TableFixture); + const pdf = await renderPdf(json); + + const header = new TextDecoder().decode(pdf.slice(0, 5)); + expect(header).toBe('%PDF-'); + // One /Type /Page per page (the page-tree root is /Type /Pages). + const raw = Buffer.from(pdf).toString('latin1'); + const pageCount = (raw.match(/\/Type\s*\/Page[^s]/g) ?? []).length; + expect(pageCount).toBeGreaterThan(1); + // The header row repeats on continuation pages: "SKU" is drawn + // once per page in the content streams. + const text = decompressedStreams(pdf); + const headerDraws = (text.match(/\(SKU\)/g) ?? []).length; + expect(headerDraws).toBe(pageCount); + }); + it('substitutes PAGE_NUMBER / TOTAL_PAGES in a multi-page footer', async () => { const json = await render(FixedPageNumbers, { props: { paragraphs: 40 } }); const pdf = await renderPdf(json); From 0137ead9798b3d6f729221fd21aea68efbc42d2f Mon Sep 17 00:00:00 2001 From: Chris Joseph <95969273+cmjoseph07@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:09:18 -0500 Subject: [PATCH 05/18] feat(svelte): add Image, Svg, QrCode, and Barcode components Four media leaf components serializing to their document-model node kinds, mirroring the react adapter: Image passes src through unresolved (data URI or file path - resolution stays a rendering concern), Svg takes a content markup string with viewBox, QrCode and Barcode map their color prop into style.color. alt and href pass through on Image and Svg. encodeProps now accepts any object (interfaces lack index signatures) and decodeProps returns unknown so parser-side prop interfaces can declare required fields. Covered by parser unit tests (src byte-identity, svg content round-trip, engine defaults), cross-adapter parity fixtures, and a combined WASM smoke render. --- packages/svelte/src/components/Barcode.svelte | 23 +++ packages/svelte/src/components/Image.svelte | 25 ++++ packages/svelte/src/components/QrCode.svelte | 19 +++ packages/svelte/src/components/Svg.svelte | 26 ++++ packages/svelte/src/encode.ts | 2 +- packages/svelte/src/index.ts | 5 + packages/svelte/src/parser.ts | 120 +++++++++++++++- packages/svelte/tests/fixtures/media.svelte | 49 +++++++ packages/svelte/tests/fixtures/media.tsx | 50 +++++++ packages/svelte/tests/parity.test.tsx | 15 ++ packages/svelte/tests/parser.test.ts | 133 ++++++++++++++++++ packages/svelte/tests/wasm-smoke.test.ts | 18 +++ 12 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 packages/svelte/src/components/Barcode.svelte create mode 100644 packages/svelte/src/components/Image.svelte create mode 100644 packages/svelte/src/components/QrCode.svelte create mode 100644 packages/svelte/src/components/Svg.svelte create mode 100644 packages/svelte/tests/fixtures/media.svelte create mode 100644 packages/svelte/tests/fixtures/media.tsx diff --git a/packages/svelte/src/components/Barcode.svelte b/packages/svelte/src/components/Barcode.svelte new file mode 100644 index 0000000..35f3262 --- /dev/null +++ b/packages/svelte/src/components/Barcode.svelte @@ -0,0 +1,23 @@ + + + diff --git a/packages/svelte/src/components/Image.svelte b/packages/svelte/src/components/Image.svelte new file mode 100644 index 0000000..cba1e08 --- /dev/null +++ b/packages/svelte/src/components/Image.svelte @@ -0,0 +1,25 @@ + + + diff --git a/packages/svelte/src/components/QrCode.svelte b/packages/svelte/src/components/QrCode.svelte new file mode 100644 index 0000000..3369944 --- /dev/null +++ b/packages/svelte/src/components/QrCode.svelte @@ -0,0 +1,19 @@ + + + diff --git a/packages/svelte/src/components/Svg.svelte b/packages/svelte/src/components/Svg.svelte new file mode 100644 index 0000000..6405cc8 --- /dev/null +++ b/packages/svelte/src/components/Svg.svelte @@ -0,0 +1,26 @@ + + + diff --git a/packages/svelte/src/encode.ts b/packages/svelte/src/encode.ts index fbfefdd..2c5ff7a 100644 --- a/packages/svelte/src/encode.ts +++ b/packages/svelte/src/encode.ts @@ -15,7 +15,7 @@ * and circular structures throw an error naming the component and * the offending prop. */ -export function encodeProps(component: string, props: Record): string { +export function encodeProps(component: string, props: object): string { const parts: string[] = []; for (const [key, value] of Object.entries(props)) { if (value === undefined) continue; diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index c95a2c2..8c5837e 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -7,6 +7,10 @@ export { default as Table } from './components/Table.svelte'; export { default as Row } from './components/Row.svelte'; export { default as Cell } from './components/Cell.svelte'; export { default as Fixed } from './components/Fixed.svelte'; +export { default as Image } from './components/Image.svelte'; +export { default as Svg } from './components/Svg.svelte'; +export { default as QrCode } from './components/QrCode.svelte'; +export { default as Barcode } from './components/Barcode.svelte'; // Page-number placeholders export { PAGE_NUMBER, TOTAL_PAGES } from './constants.js'; @@ -25,6 +29,7 @@ export type { Style, TextRun, ColumnDef, + BarcodeFormat, GridTrackSize, Edges, Corners, diff --git a/packages/svelte/src/parser.ts b/packages/svelte/src/parser.ts index f76bc30..c4b769b 100644 --- a/packages/svelte/src/parser.ts +++ b/packages/svelte/src/parser.ts @@ -29,8 +29,9 @@ import { parseFragment } from 'parse5'; import type { DefaultTreeAdapterMap } from 'parse5'; -import { expandEdges, mapColumnWidth, mapStyle } from '@formepdf/shared'; +import { expandEdges, mapColumnWidth, mapStyle, parseColor } from '@formepdf/shared'; import type { + BarcodeFormat, CertificationConfig, ColumnDef, Edges, @@ -38,6 +39,7 @@ import type { FormeDocument, FormeEdges, FormeNode, + FormeNodeKind, FormePageConfig, FormePageSize, Style, @@ -186,6 +188,14 @@ function parseElement(element: P5Element, parent: ParentContext): FormeNode | nu return parseCell(element); case 'forme-fixed': return parseFixed(element); + case 'forme-image': + return parseImage(element); + case 'forme-svg': + return parseSvg(element); + case 'forme-qrcode': + return parseQrCode(element); + case 'forme-barcode': + return parseBarcode(element); case 'forme-page': validateNesting('Page', parent); return parsePage(element); @@ -482,6 +492,110 @@ function parseFixed(element: P5Element): FormeNode { 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: [] }; +} + // ─── Whitespace normalization ──────────────────────────────────────── /** @@ -529,11 +643,11 @@ function findDocumentRoot(nodes: P5Node[]): P5Element { } /** Decode the JSON `props` attribute of a placeholder element. */ -function decodeProps(element: P5Element, component: string): Record { +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) as Record; + return JSON.parse(attr.value) 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/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 @@ + + + + + + + + + + + '} + 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 ( + + + + + + + + + '} + alt="Diagonal line over a square" + href="https://formepdf.com/svg" + style={{ marginBottom: 16 }} + /> + + + + + + + + + + Ticket {ticketId} + + + ); +} diff --git a/packages/svelte/tests/parity.test.tsx b/packages/svelte/tests/parity.test.tsx index 08b4dba..65b1095 100644 --- a/packages/svelte/tests/parity.test.tsx +++ b/packages/svelte/tests/parity.test.tsx @@ -11,6 +11,7 @@ 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'; // @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 @@ -21,6 +22,8 @@ import TextRunsSvelte from './fixtures/text-runs.svelte'; 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'; describe('cross-adapter parity', () => { it('hello-world: interpolation, #each/#if vs map/&&', async () => { @@ -54,6 +57,18 @@ describe('cross-adapter parity', () => { expect(svelteDoc).toEqual(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(); + expect(svelteDoc).toEqual(reactDoc); + }); + + it('media with default props', async () => { + const svelteDoc = await serialize(MediaSvelte); + const reactDoc = serializeReact(); + expect(svelteDoc).toEqual(reactDoc); + }); + it('fixed-page-numbers: header/footer, placeholder constants', async () => { const svelteDoc = await serialize(FixedPageNumbersSvelte, { props: { paragraphs: 5 } }); const reactDoc = serializeReact(); diff --git a/packages/svelte/tests/parser.test.ts b/packages/svelte/tests/parser.test.ts index 81e544a..d6388a0 100644 --- a/packages/svelte/tests/parser.test.ts +++ b/packages/svelte/tests/parser.test.ts @@ -1,4 +1,5 @@ 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. */ @@ -488,6 +489,138 @@ describe('table', () => { }); }); +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('errors', () => { it('rejects a Page nested outside Document', () => { expect(() => diff --git a/packages/svelte/tests/wasm-smoke.test.ts b/packages/svelte/tests/wasm-smoke.test.ts index 38bc04a..f9acbda 100644 --- a/packages/svelte/tests/wasm-smoke.test.ts +++ b/packages/svelte/tests/wasm-smoke.test.ts @@ -13,6 +13,8 @@ import HelloWorld from './fixtures/hello-world.svelte'; import FixedPageNumbers from './fixtures/fixed-page-numbers.svelte'; // @ts-expect-error .svelte fixtures have no type declarations in tests import TableFixture from './fixtures/table.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import MediaFixture from './fixtures/media.svelte'; /** * Concatenate every stream object in the PDF, inflating the @@ -73,6 +75,22 @@ describe('WASM smoke', () => { expect(headerDraws).toBe(pageCount); }); + it('renders media leaves (Image, Svg, QrCode, Barcode) in one document', async () => { + const json = await render(MediaFixture, { props: { ticketId: 'TKT-0042' } }); + const pdf = await renderPdf(json); + + expect(pdf).toBeInstanceOf(Uint8Array); + const header = new TextDecoder().decode(pdf.slice(0, 5)); + expect(header).toBe('%PDF-'); + // QR modules and barcode bars are drawn as rectangle ops (`re`) in + // the content stream — a media-free page has nowhere near this + // many. The data-URI image becomes an XObject (`/Im0 Do`). + const text = decompressedStreams(pdf); + const rectOps = (text.match(/\bre\b/g) ?? []).length; + expect(rectOps).toBeGreaterThan(100); + expect(text).toMatch(/\/Im\d+ Do/); + }); + it('substitutes PAGE_NUMBER / TOTAL_PAGES in a multi-page footer', async () => { const json = await render(FixedPageNumbers, { props: { paragraphs: 40 } }); const pdf = await renderPdf(json); From 91ead56cb2e3d9d8d6442ad5338a6c3a0f03e142 Mon Sep 17 00:00:00 2001 From: Chris Joseph <95969273+cmjoseph07@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:16:54 -0500 Subject: [PATCH 06/18] feat(svelte): add Canvas, Watermark, and PageBreak components Canvas executes its draw callback at emit time against the shared CanvasContext recorder, so the one function-valued prop becomes an attribute-safe operation list. Watermark mirrors react's color-alpha-to-opacity mapping; PageBreak is a bare leaf. Parity fixtures cover all three, plus a two-page WASM smoke render. --- packages/svelte/src/components/Canvas.svelte | 28 +++++++ .../svelte/src/components/PageBreak.svelte | 6 ++ .../svelte/src/components/Watermark.svelte | 23 ++++++ packages/svelte/src/index.ts | 5 ++ packages/svelte/src/parser.ts | 57 ++++++++++++++ .../tests/fixtures/vector-extras.svelte | 56 ++++++++++++++ .../svelte/tests/fixtures/vector-extras.tsx | 57 ++++++++++++++ packages/svelte/tests/parity.test.tsx | 16 ++++ packages/svelte/tests/parser.test.ts | 75 +++++++++++++++++++ packages/svelte/tests/wasm-smoke.test.ts | 23 ++++++ 10 files changed, 346 insertions(+) create mode 100644 packages/svelte/src/components/Canvas.svelte create mode 100644 packages/svelte/src/components/PageBreak.svelte create mode 100644 packages/svelte/src/components/Watermark.svelte create mode 100644 packages/svelte/tests/fixtures/vector-extras.svelte create mode 100644 packages/svelte/tests/fixtures/vector-extras.tsx diff --git a/packages/svelte/src/components/Canvas.svelte b/packages/svelte/src/components/Canvas.svelte new file mode 100644 index 0000000..437b13e --- /dev/null +++ b/packages/svelte/src/components/Canvas.svelte @@ -0,0 +1,28 @@ + + + diff --git a/packages/svelte/src/components/PageBreak.svelte b/packages/svelte/src/components/PageBreak.svelte new file mode 100644 index 0000000..93eb3c7 --- /dev/null +++ b/packages/svelte/src/components/PageBreak.svelte @@ -0,0 +1,6 @@ + + + diff --git a/packages/svelte/src/components/Watermark.svelte b/packages/svelte/src/components/Watermark.svelte new file mode 100644 index 0000000..f7af7df --- /dev/null +++ b/packages/svelte/src/components/Watermark.svelte @@ -0,0 +1,23 @@ + + + diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 8c5837e..a9a5a5b 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -11,6 +11,9 @@ export { default as Image } from './components/Image.svelte'; export { default as Svg } from './components/Svg.svelte'; export { default as QrCode } from './components/QrCode.svelte'; export { default as Barcode } from './components/Barcode.svelte'; +export { default as Canvas } from './components/Canvas.svelte'; +export { default as Watermark } from './components/Watermark.svelte'; +export { default as PageBreak } from './components/PageBreak.svelte'; // Page-number placeholders export { PAGE_NUMBER, TOTAL_PAGES } from './constants.js'; @@ -30,6 +33,8 @@ export type { TextRun, ColumnDef, BarcodeFormat, + CanvasContext, + CanvasOp, GridTrackSize, Edges, Corners, diff --git a/packages/svelte/src/parser.ts b/packages/svelte/src/parser.ts index c4b769b..f42eb63 100644 --- a/packages/svelte/src/parser.ts +++ b/packages/svelte/src/parser.ts @@ -32,6 +32,7 @@ import type { DefaultTreeAdapterMap } from 'parse5'; import { expandEdges, mapColumnWidth, mapStyle, parseColor } from '@formepdf/shared'; import type { BarcodeFormat, + CanvasOp, CertificationConfig, ColumnDef, Edges, @@ -196,6 +197,12 @@ function parseElement(element: P5Element, parent: ParentContext): FormeNode | nu return parseQrCode(element); case 'forme-barcode': return parseBarcode(element); + case 'forme-canvas': + return parseCanvas(element); + case 'forme-watermark': + return parseWatermark(element); + case 'forme-page-break': + return { kind: { type: 'PageBreak' }, style: {}, children: [] }; case 'forme-page': validateNesting('Page', parent); return parsePage(element); @@ -596,6 +603,56 @@ function parseBarcode(element: P5Element): FormeNode { 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: [], + }; +} + // ─── Whitespace normalization ──────────────────────────────────────── /** 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 index 65b1095..8ec74dd 100644 --- a/packages/svelte/tests/parity.test.tsx +++ b/packages/svelte/tests/parity.test.tsx @@ -12,6 +12,7 @@ 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'; // @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 @@ -24,6 +25,8 @@ import FixedPageNumbersSvelte from './fixtures/fixed-page-numbers.svelte'; 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'; describe('cross-adapter parity', () => { it('hello-world: interpolation, #each/#if vs map/&&', async () => { @@ -69,6 +72,19 @@ describe('cross-adapter parity', () => { expect(svelteDoc).toEqual(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(); + expect(svelteDoc).toEqual(reactDoc); + }); + + it('vector-extras with default props', async () => { + const svelteDoc = await serialize(VectorExtrasSvelte); + const reactDoc = serializeReact(); + expect(svelteDoc).toEqual(reactDoc); + }); + it('fixed-page-numbers: header/footer, placeholder constants', async () => { const svelteDoc = await serialize(FixedPageNumbersSvelte, { props: { paragraphs: 5 } }); const reactDoc = serializeReact(); diff --git a/packages/svelte/tests/parser.test.ts b/packages/svelte/tests/parser.test.ts index d6388a0..37f9fa9 100644 --- a/packages/svelte/tests/parser.test.ts +++ b/packages/svelte/tests/parser.test.ts @@ -621,6 +621,81 @@ describe('barcode', () => { }); }); +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('errors', () => { it('rejects a Page nested outside Document', () => { expect(() => diff --git a/packages/svelte/tests/wasm-smoke.test.ts b/packages/svelte/tests/wasm-smoke.test.ts index f9acbda..92b9a91 100644 --- a/packages/svelte/tests/wasm-smoke.test.ts +++ b/packages/svelte/tests/wasm-smoke.test.ts @@ -15,6 +15,8 @@ import FixedPageNumbers from './fixtures/fixed-page-numbers.svelte'; import TableFixture from './fixtures/table.svelte'; // @ts-expect-error .svelte fixtures have no type declarations in tests import MediaFixture from './fixtures/media.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import VectorExtras from './fixtures/vector-extras.svelte'; /** * Concatenate every stream object in the PDF, inflating the @@ -91,6 +93,27 @@ describe('WASM smoke', () => { expect(text).toMatch(/\/Im\d+ Do/); }); + it('renders Canvas, Watermark, and an explicit PageBreak to a two-page PDF', async () => { + const json = await render(VectorExtras); + const pdf = await renderPdf(json); + + const header = new TextDecoder().decode(pdf.slice(0, 5)); + expect(header).toBe('%PDF-'); + // The explicit splits the document into exactly two + // pages (the page-tree root is /Type /Pages). + const raw = Buffer.from(pdf).toString('latin1'); + const pageCount = (raw.match(/\/Type\s*\/Page[^s]/g) ?? []).length; + expect(pageCount).toBe(2); + // The watermark text repeats on both pages (drawn as hex strings: + // "DRAFT" and "CONFIDENTIAL"); canvas ops land in the first page's + // content stream as vector path commands (bezier `c` operators). + const text = decompressedStreams(pdf); + expect((text.match(/<4452414654> Tj/g) ?? []).length).toBe(2); + expect((text.match(/<434F4E464944454E5449414C> Tj/g) ?? []).length).toBe(2); + expect(text).toContain(' c\n'); + expect(text).toContain('(Second page)'); + }); + it('substitutes PAGE_NUMBER / TOTAL_PAGES in a multi-page footer', async () => { const json = await render(FixedPageNumbers, { props: { paragraphs: 40 } }); const pdf = await renderPdf(json); From c7f28909a23064da933dcd5aae3af633f5590795 Mon Sep 17 00:00:00 2001 From: Chris Joseph <95969273+cmjoseph07@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:36:50 -0500 Subject: [PATCH 07/18] feat(svelte): add chart components via shared kind builders Add the five engine-native chart components (BarChart, LineChart, PieChart, AreaChart, DotPlot) to the svelte adapter. The camelCase-to-snake_case kind mapping and chart prop types move from the react adapter into @formepdf/shared (charts.ts) so both adapters serialize charts through the same builders and cannot drift. React re-exports the moved prop types and its serializers now call the shared builders - behavior-neutral, proven by its untouched suite. The svelte parser handles all five placeholder tags through one generic parseChart that delegates to the shared builders and maps style itself. Covered by parser unit tests (snake_case mapping, defaults, per-datum colors, axis bounds), a dashboard-style parity fixture pair exercising multi-series, grouped, and minimal-props variants of every chart type, and a WASM smoke render asserting each chart's drawn output. --- packages/react/src/serialize.ts | 65 +---- packages/react/src/types.ts | 92 +------ packages/shared/src/charts.ts | 185 ++++++++++++++ packages/shared/src/index.ts | 16 ++ .../svelte/src/components/AreaChart.svelte | 8 + .../svelte/src/components/BarChart.svelte | 8 + packages/svelte/src/components/DotPlot.svelte | 8 + .../svelte/src/components/LineChart.svelte | 8 + .../svelte/src/components/PieChart.svelte | 8 + packages/svelte/src/index.ts | 13 + packages/svelte/src/parser.ts | 51 +++- .../src/preview/preview-html.generated.ts | 5 + packages/svelte/tests/fixtures/charts.svelte | 108 ++++++++ packages/svelte/tests/fixtures/charts.tsx | 109 ++++++++ packages/svelte/tests/parity.test.tsx | 16 ++ packages/svelte/tests/parser.test.ts | 240 ++++++++++++++++++ packages/svelte/tests/wasm-smoke.test.ts | 25 ++ 17 files changed, 818 insertions(+), 147 deletions(-) create mode 100644 packages/shared/src/charts.ts create mode 100644 packages/svelte/src/components/AreaChart.svelte create mode 100644 packages/svelte/src/components/BarChart.svelte create mode 100644 packages/svelte/src/components/DotPlot.svelte create mode 100644 packages/svelte/src/components/LineChart.svelte create mode 100644 packages/svelte/src/components/PieChart.svelte create mode 100644 packages/svelte/src/preview/preview-html.generated.ts create mode 100644 packages/svelte/tests/fixtures/charts.svelte create mode 100644 packages/svelte/tests/fixtures/charts.tsx diff --git a/packages/react/src/serialize.ts b/packages/react/src/serialize.ts index 8f78eec..ab71d2a 100644 --- a/packages/react/src/serialize.ts +++ b/packages/react/src/serialize.ts @@ -1,7 +1,7 @@ import { type ReactElement, type ReactNode, isValidElement, Children, Fragment } from 'react'; import { Document, Page, View, Text, H1, H2, H3, H4, H5, H6, OrderedList, UnorderedList, ListItem, Strong, Em, Code, Link, Image, Table, Row, Cell, Fixed, Svg, QrCode, Barcode, Canvas, Watermark, PageBreak, BarChart, LineChart, PieChart, AreaChart, DotPlot, TextField, Checkbox, Dropdown, RadioButton } from './components.js'; import { Font } from './font.js'; -import { mapStyle, parseColor, expandEdges, mapColumnWidth, mergeFonts, recordCanvasOperations, mapListMarker, HEADING_DEFAULTS, STRONG_DEFAULTS, EM_DEFAULTS, CODE_DEFAULTS, LINK_DEFAULTS } from '@formepdf/shared'; +import { mapStyle, parseColor, expandEdges, mapColumnWidth, mergeFonts, recordCanvasOperations, mapListMarker, HEADING_DEFAULTS, STRONG_DEFAULTS, EM_DEFAULTS, CODE_DEFAULTS, LINK_DEFAULTS, buildBarChartKind, buildLineChartKind, buildPieChartKind, buildAreaChartKind, buildDotPlotKind } from '@formepdf/shared'; import { isRefMarker, getRefPath, isEachMarker, getEachPath, getEachTemplate, @@ -949,19 +949,8 @@ function serializeWatermark(element: ReactElement): FormeNode { function serializeBarChart(element: ReactElement): FormeNode { const props = element.props as BarChartProps; - const kind: FormeNodeKind = { - type: 'BarChart', - data: props.data.map(d => ({ label: d.label, value: d.value, color: d.color })), - width: props.width, - height: props.height, - show_labels: props.showLabels ?? true, - show_values: props.showValues ?? false, - show_grid: props.showGrid ?? false, - } as FormeNodeKind; - if (props.color !== undefined) (kind as Record).color = props.color; - if (props.title !== undefined) (kind as Record).title = props.title; return { - kind, + kind: buildBarChartKind(props), style: mapStyle(props.style), children: [], sourceLocation: extractSourceLocation(element), @@ -970,18 +959,8 @@ function serializeBarChart(element: ReactElement): FormeNode { function serializeLineChart(element: ReactElement): FormeNode { const props = element.props as LineChartProps; - const kind: FormeNodeKind = { - type: 'LineChart', - series: props.series.map(s => ({ name: s.name, data: s.data, color: s.color })), - labels: props.labels, - width: props.width, - height: props.height, - show_points: props.showPoints ?? false, - show_grid: props.showGrid ?? false, - } as FormeNodeKind; - if (props.title !== undefined) (kind as Record).title = props.title; return { - kind, + kind: buildLineChartKind(props), style: mapStyle(props.style), children: [], sourceLocation: extractSourceLocation(element), @@ -990,17 +969,8 @@ function serializeLineChart(element: ReactElement): FormeNode { function serializePieChart(element: ReactElement): FormeNode { const props = element.props as PieChartProps; - const kind: FormeNodeKind = { - type: 'PieChart', - data: props.data.map(d => ({ label: d.label, value: d.value, color: d.color })), - width: props.width, - height: props.height, - donut: props.donut ?? false, - show_legend: props.showLegend ?? false, - } as FormeNodeKind; - if (props.title !== undefined) (kind as Record).title = props.title; return { - kind, + kind: buildPieChartKind(props), style: mapStyle(props.style), children: [], sourceLocation: extractSourceLocation(element), @@ -1009,17 +979,8 @@ function serializePieChart(element: ReactElement): FormeNode { function serializeAreaChart(element: ReactElement): FormeNode { const props = element.props as AreaChartProps; - const kind: FormeNodeKind = { - type: 'AreaChart', - series: props.series.map(s => ({ name: s.name, data: s.data, color: s.color })), - labels: props.labels, - width: props.width, - height: props.height, - show_grid: props.showGrid ?? false, - } as FormeNodeKind; - if (props.title !== undefined) (kind as Record).title = props.title; return { - kind, + kind: buildAreaChartKind(props), style: mapStyle(props.style), children: [], sourceLocation: extractSourceLocation(element), @@ -1028,22 +989,8 @@ function serializeAreaChart(element: ReactElement): FormeNode { function serializeDotPlot(element: ReactElement): FormeNode { const props = element.props as DotPlotProps; - const kind: FormeNodeKind = { - type: 'DotPlot', - groups: props.groups.map(g => ({ name: g.name, color: g.color, data: g.data })), - width: props.width, - height: props.height, - show_legend: props.showLegend ?? false, - dot_size: props.dotSize ?? 4, - } as FormeNodeKind; - if (props.xMin !== undefined) (kind as Record).x_min = props.xMin; - if (props.xMax !== undefined) (kind as Record).x_max = props.xMax; - if (props.yMin !== undefined) (kind as Record).y_min = props.yMin; - if (props.yMax !== undefined) (kind as Record).y_max = props.yMax; - if (props.xLabel !== undefined) (kind as Record).x_label = props.xLabel; - if (props.yLabel !== undefined) (kind as Record).y_label = props.yLabel; return { - kind, + kind: buildDotPlotKind(props), style: mapStyle(props.style), children: [], sourceLocation: extractSourceLocation(element), diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 1b36b30..db4bdb9 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -5,9 +5,6 @@ import type { Edges, ColumnDef, BarcodeFormat, - ChartDataPoint, - ChartSeries, - DotPlotGroup, CanvasContext, CertificationConfig, } from '@formepdf/shared'; @@ -28,6 +25,11 @@ export type { PieDataPoint, ChartSeries, DotPlotGroup, + BarChartProps, + LineChartProps, + PieChartProps, + AreaChartProps, + DotPlotProps, CanvasContext, CanvasOp, TextRun, @@ -271,90 +273,6 @@ export interface BarcodeProps { style?: Style; } -export interface BarChartProps { - width: number; - height: number; - data: ChartDataPoint[]; - /** Bar color. Default: "#1a365d". */ - color?: string; - /** Show X-axis labels below bars. Default: true. */ - showLabels?: boolean; - /** Show horizontal grid lines. Default: false. */ - showGrid?: boolean; - /** Show value labels above bars. Default: false. */ - showValues?: boolean; - /** Chart title. */ - title?: string; - style?: Style; -} - -export interface LineChartProps { - width: number; - height: number; - /** Multi-series data. */ - series: ChartSeries[]; - /** X-axis labels. */ - labels: string[]; - /** Show dots at data points. Default: false. */ - showPoints?: boolean; - /** Show horizontal grid lines. Default: false. */ - showGrid?: boolean; - /** Chart title. */ - title?: string; - style?: Style; -} - -export interface PieChartProps { - width: number; - height: number; - data: ChartDataPoint[]; - /** Render as donut chart. Default: false. */ - donut?: boolean; - /** Show legend. Default: false. */ - showLegend?: boolean; - /** Chart title. */ - title?: string; - style?: Style; -} - -export interface AreaChartProps { - width: number; - height: number; - /** Multi-series data. */ - series: ChartSeries[]; - /** X-axis labels. */ - labels: string[]; - /** Show horizontal grid lines. Default: false. */ - showGrid?: boolean; - /** Chart title. */ - title?: string; - style?: Style; -} - -export interface DotPlotProps { - width: number; - height: number; - /** Groups of (x, y) data points. */ - groups: DotPlotGroup[]; - /** Minimum X value. Auto-computed if omitted. */ - xMin?: number; - /** Maximum X value. Auto-computed if omitted. */ - xMax?: number; - /** Minimum Y value. Auto-computed if omitted. */ - yMin?: number; - /** Maximum Y value. Auto-computed if omitted. */ - yMax?: number; - /** X-axis label. */ - xLabel?: string; - /** Y-axis label. */ - yLabel?: string; - /** Show legend. Default: false. */ - showLegend?: boolean; - /** Dot radius in points. Default: 4. */ - dotSize?: number; - style?: Style; -} - export interface TextFieldProps { /** Unique field name (used as the PDF field identifier). */ name: string; diff --git a/packages/shared/src/charts.ts b/packages/shared/src/charts.ts new file mode 100644 index 0000000..4413fee --- /dev/null +++ b/packages/shared/src/charts.ts @@ -0,0 +1,185 @@ +/** + * Chart prop types and document-model kind builders shared by the + * authoring adapters. + * + * The camelCase-to-snake_case prop mapping, including every default, + * lives here so the adapters cannot drift: react and svelte both + * serialize a chart by calling the same builder. Adapters remain + * responsible for style mapping (`mapStyle`) and any adapter-specific + * envelope (source locations, children). + */ + +import type { + ChartDataPoint, + ChartSeries, + DotPlotGroup, + FormeNodeKind, + Style, +} from './types.js'; + +export interface BarChartProps { + width: number; + height: number; + data: ChartDataPoint[]; + /** Bar color. Default: "#1a365d". */ + color?: string; + /** Show X-axis labels below bars. Default: true. */ + showLabels?: boolean; + /** Show horizontal grid lines. Default: false. */ + showGrid?: boolean; + /** Show value labels above bars. Default: false. */ + showValues?: boolean; + /** Chart title. */ + title?: string; + style?: Style; +} + +export interface LineChartProps { + width: number; + height: number; + /** Multi-series data. */ + series: ChartSeries[]; + /** X-axis labels. */ + labels: string[]; + /** Show dots at data points. Default: false. */ + showPoints?: boolean; + /** Show horizontal grid lines. Default: false. */ + showGrid?: boolean; + /** Chart title. */ + title?: string; + style?: Style; +} + +export interface PieChartProps { + width: number; + height: number; + data: ChartDataPoint[]; + /** Render as donut chart. Default: false. */ + donut?: boolean; + /** Show legend. Default: false. */ + showLegend?: boolean; + /** Chart title. */ + title?: string; + style?: Style; +} + +export interface AreaChartProps { + width: number; + height: number; + /** Multi-series data. */ + series: ChartSeries[]; + /** X-axis labels. */ + labels: string[]; + /** Show horizontal grid lines. Default: false. */ + showGrid?: boolean; + /** Chart title. */ + title?: string; + style?: Style; +} + +export interface DotPlotProps { + width: number; + height: number; + /** Groups of (x, y) data points. */ + groups: DotPlotGroup[]; + /** Minimum X value. Auto-computed if omitted. */ + xMin?: number; + /** Maximum X value. Auto-computed if omitted. */ + xMax?: number; + /** Minimum Y value. Auto-computed if omitted. */ + yMin?: number; + /** Maximum Y value. Auto-computed if omitted. */ + yMax?: number; + /** X-axis label. */ + xLabel?: string; + /** Y-axis label. */ + yLabel?: string; + /** Show legend. Default: false. */ + showLegend?: boolean; + /** Dot radius in points. Default: 4. */ + dotSize?: number; + style?: Style; +} + +/** Copy data points so the kind owns plain data (per-datum color stays optional). */ +function mapDataPoints(data: ChartDataPoint[]): ChartDataPoint[] { + return data.map(d => ({ label: d.label, value: d.value, color: d.color })); +} + +/** Build the document-model kind for a ``. */ +export function buildBarChartKind(props: BarChartProps): FormeNodeKind { + const kind: Extract = { + type: 'BarChart', + data: mapDataPoints(props.data), + width: props.width, + height: props.height, + show_labels: props.showLabels ?? true, + show_values: props.showValues ?? false, + show_grid: props.showGrid ?? false, + }; + if (props.color !== undefined) kind.color = props.color; + if (props.title !== undefined) kind.title = props.title; + return kind; +} + +/** Build the document-model kind for a ``. */ +export function buildLineChartKind(props: LineChartProps): FormeNodeKind { + const kind: Extract = { + type: 'LineChart', + series: props.series.map(s => ({ name: s.name, data: s.data, color: s.color })), + labels: props.labels, + width: props.width, + height: props.height, + show_points: props.showPoints ?? false, + show_grid: props.showGrid ?? false, + }; + if (props.title !== undefined) kind.title = props.title; + return kind; +} + +/** Build the document-model kind for a ``. */ +export function buildPieChartKind(props: PieChartProps): FormeNodeKind { + const kind: Extract = { + type: 'PieChart', + data: mapDataPoints(props.data), + width: props.width, + height: props.height, + donut: props.donut ?? false, + show_legend: props.showLegend ?? false, + }; + if (props.title !== undefined) kind.title = props.title; + return kind; +} + +/** Build the document-model kind for an ``. */ +export function buildAreaChartKind(props: AreaChartProps): FormeNodeKind { + const kind: Extract = { + type: 'AreaChart', + series: props.series.map(s => ({ name: s.name, data: s.data, color: s.color })), + labels: props.labels, + width: props.width, + height: props.height, + show_grid: props.showGrid ?? false, + }; + if (props.title !== undefined) kind.title = props.title; + return kind; +} + +/** Build the document-model kind for a ``. */ +export function buildDotPlotKind(props: DotPlotProps): FormeNodeKind { + const kind: Extract = { + type: 'DotPlot', + groups: props.groups.map(g => ({ name: g.name, color: g.color, data: g.data })), + width: props.width, + height: props.height, + show_legend: props.showLegend ?? false, + dot_size: props.dotSize ?? 4, + }; + if (props.xMin !== undefined) kind.x_min = props.xMin; + if (props.xMax !== undefined) kind.x_max = props.xMax; + if (props.yMin !== undefined) kind.y_min = props.yMin; + if (props.yMax !== undefined) kind.y_max = props.yMax; + if (props.xLabel !== undefined) kind.x_label = props.xLabel; + if (props.yLabel !== undefined) kind.y_label = props.yLabel; + return kind; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5c90caa..0ac3fa3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -18,6 +18,22 @@ export { mapListMarker, } from './semantics.js'; +// Chart kind builders (shared camelCase-to-snake_case prop mapping) +export { + buildBarChartKind, + buildLineChartKind, + buildPieChartKind, + buildAreaChartKind, + buildDotPlotKind, +} from './charts.js'; +export type { + BarChartProps, + LineChartProps, + PieChartProps, + AreaChartProps, + DotPlotProps, +} from './charts.js'; + // Types export type { // Developer-facing diff --git a/packages/svelte/src/components/AreaChart.svelte b/packages/svelte/src/components/AreaChart.svelte new file mode 100644 index 0000000..cfa3328 --- /dev/null +++ b/packages/svelte/src/components/AreaChart.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/svelte/src/components/BarChart.svelte b/packages/svelte/src/components/BarChart.svelte new file mode 100644 index 0000000..b7f1047 --- /dev/null +++ b/packages/svelte/src/components/BarChart.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/svelte/src/components/DotPlot.svelte b/packages/svelte/src/components/DotPlot.svelte new file mode 100644 index 0000000..6d625f3 --- /dev/null +++ b/packages/svelte/src/components/DotPlot.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/svelte/src/components/LineChart.svelte b/packages/svelte/src/components/LineChart.svelte new file mode 100644 index 0000000..afa1943 --- /dev/null +++ b/packages/svelte/src/components/LineChart.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/svelte/src/components/PieChart.svelte b/packages/svelte/src/components/PieChart.svelte new file mode 100644 index 0000000..6eca0ba --- /dev/null +++ b/packages/svelte/src/components/PieChart.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index a9a5a5b..46c1852 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -14,6 +14,11 @@ export { default as Barcode } from './components/Barcode.svelte'; export { default as Canvas } from './components/Canvas.svelte'; export { default as Watermark } from './components/Watermark.svelte'; export { default as PageBreak } from './components/PageBreak.svelte'; +export { default as BarChart } from './components/BarChart.svelte'; +export { default as LineChart } from './components/LineChart.svelte'; +export { default as PieChart } from './components/PieChart.svelte'; +export { default as AreaChart } from './components/AreaChart.svelte'; +export { default as DotPlot } from './components/DotPlot.svelte'; // Page-number placeholders export { PAGE_NUMBER, TOTAL_PAGES } from './constants.js'; @@ -33,6 +38,14 @@ export type { TextRun, ColumnDef, BarcodeFormat, + ChartDataPoint, + ChartSeries, + DotPlotGroup, + BarChartProps, + LineChartProps, + PieChartProps, + AreaChartProps, + DotPlotProps, CanvasContext, CanvasOp, GridTrackSize, diff --git a/packages/svelte/src/parser.ts b/packages/svelte/src/parser.ts index f42eb63..22c0dbd 100644 --- a/packages/svelte/src/parser.ts +++ b/packages/svelte/src/parser.ts @@ -29,7 +29,17 @@ import { parseFragment } from 'parse5'; import type { DefaultTreeAdapterMap } from 'parse5'; -import { expandEdges, mapColumnWidth, mapStyle, parseColor } from '@formepdf/shared'; +import { + buildAreaChartKind, + buildBarChartKind, + buildDotPlotKind, + buildLineChartKind, + buildPieChartKind, + expandEdges, + mapColumnWidth, + mapStyle, + parseColor, +} from '@formepdf/shared'; import type { BarcodeFormat, CanvasOp, @@ -47,6 +57,13 @@ import type { TextRun, } from '@formepdf/shared'; +/** The slice of a chart placeholder's props the parser handles itself: + * every chart carries an optional style; the rest is chart-specific + * and typed by its shared kind builder. */ +interface ChartPlaceholderProps { + style?: Style; +} + type P5Node = DefaultTreeAdapterMap['childNode']; type P5Element = DefaultTreeAdapterMap['element']; @@ -201,6 +218,16 @@ function parseElement(element: P5Element, parent: ParentContext): FormeNode | nu return parseCanvas(element); case 'forme-watermark': return parseWatermark(element); + case 'forme-bar-chart': + return parseChart(element, 'BarChart', buildBarChartKind); + case 'forme-line-chart': + return parseChart(element, 'LineChart', buildLineChartKind); + case 'forme-pie-chart': + return parseChart(element, 'PieChart', buildPieChartKind); + case 'forme-area-chart': + return parseChart(element, 'AreaChart', buildAreaChartKind); + case 'forme-dot-plot': + return parseChart(element, 'DotPlot', buildDotPlotKind); case 'forme-page-break': return { kind: { type: 'PageBreak' }, style: {}, children: [] }; case 'forme-page': @@ -653,6 +680,28 @@ function parseWatermark(element: P5Element): FormeNode { }; } +// ─── 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 ──────────────────────────────────────── /** 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/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/parity.test.tsx b/packages/svelte/tests/parity.test.tsx index 8ec74dd..2a93b7a 100644 --- a/packages/svelte/tests/parity.test.tsx +++ b/packages/svelte/tests/parity.test.tsx @@ -13,6 +13,7 @@ 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'; // @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 @@ -27,6 +28,8 @@ import TableSvelte from './fixtures/table.svelte'; 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'; describe('cross-adapter parity', () => { it('hello-world: interpolation, #each/#if vs map/&&', async () => { @@ -85,6 +88,19 @@ describe('cross-adapter parity', () => { expect(svelteDoc).toEqual(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(); + expect(svelteDoc).toEqual(reactDoc); + }); + + it('charts with default props (defaulted chart options included)', async () => { + const svelteDoc = await serialize(ChartsSvelte); + const reactDoc = serializeReact(); + expect(svelteDoc).toEqual(reactDoc); + }); + it('fixed-page-numbers: header/footer, placeholder constants', async () => { const svelteDoc = await serialize(FixedPageNumbersSvelte, { props: { paragraphs: 5 } }); const reactDoc = serializeReact(); diff --git a/packages/svelte/tests/parser.test.ts b/packages/svelte/tests/parser.test.ts index 37f9fa9..383537c 100644 --- a/packages/svelte/tests/parser.test.ts +++ b/packages/svelte/tests/parser.test.ts @@ -696,6 +696,246 @@ describe('page break', () => { }); }); +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('errors', () => { it('rejects a Page nested outside Document', () => { expect(() => diff --git a/packages/svelte/tests/wasm-smoke.test.ts b/packages/svelte/tests/wasm-smoke.test.ts index 92b9a91..e31a23d 100644 --- a/packages/svelte/tests/wasm-smoke.test.ts +++ b/packages/svelte/tests/wasm-smoke.test.ts @@ -17,6 +17,8 @@ import TableFixture from './fixtures/table.svelte'; import MediaFixture from './fixtures/media.svelte'; // @ts-expect-error .svelte fixtures have no type declarations in tests import VectorExtras from './fixtures/vector-extras.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import ChartsFixture from './fixtures/charts.svelte'; /** * Concatenate every stream object in the PDF, inflating the @@ -114,6 +116,29 @@ describe('WASM smoke', () => { expect(text).toContain('(Second page)'); }); + it('renders a dashboard with all five chart types', async () => { + const json = await render(ChartsFixture); + const pdf = await renderPdf(json); + + expect(pdf).toBeInstanceOf(Uint8Array); + const header = new TextDecoder().decode(pdf.slice(0, 5)); + expect(header).toBe('%PDF-'); + const text = decompressedStreams(pdf); + // Axis/legend labels and titles are drawn as literal text with the + // built-in Helvetica: one recognizable string per chart type. + expect(text).toContain('(Revenue by quarter)'); + expect(text).toContain('(Traffic sources)'); + expect(text).toContain('(Monthly actives)'); + expect(text).toContain('(Server load)'); + // The dot plot draws its x-axis label (the engine currently never + // draws y_label, though it round-trips through serialization). + expect(text).toContain('(Dose)'); + // Chart geometry lands as vector ops: bars as rect fills, pie + // sectors as bezier curves. + expect((text.match(/\bre\b/g) ?? []).length).toBeGreaterThan(5); + expect(text).toContain(' c\n'); + }); + it('substitutes PAGE_NUMBER / TOTAL_PAGES in a multi-page footer', async () => { const json = await render(FixedPageNumbers, { props: { paragraphs: 40 } }); const pdf = await renderPdf(json); From 72606d480bbf837152884d70563fda1fa1e92ba4 Mon Sep 17 00:00:00 2001 From: Chris Joseph <95969273+cmjoseph07@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:48:59 -0500 Subject: [PATCH 08/18] feat(svelte): add form field components for fillable PDFs TextField, Checkbox, Dropdown, and RadioButton serialize to their AcroForm node kinds with the same defaults and conditional props as the react adapter. Covered by parser tests, cross-adapter parity fixtures (registration form), and a WASM smoke test asserting the rendered PDF contains the AcroForm field dictionaries. --- .../svelte/src/components/Checkbox.svelte | 22 +++ .../svelte/src/components/Dropdown.svelte | 26 ++++ .../svelte/src/components/RadioButton.svelte | 24 ++++ .../svelte/src/components/TextField.svelte | 32 +++++ packages/svelte/src/index.ts | 4 + packages/svelte/src/parser.ts | 134 +++++++++++++++++- .../svelte/tests/fixtures/form-fields.svelte | 58 ++++++++ .../svelte/tests/fixtures/form-fields.tsx | 59 ++++++++ packages/svelte/tests/parity.test.tsx | 15 ++ packages/svelte/tests/parser.test.ts | 131 +++++++++++++++++ packages/svelte/tests/wasm-smoke.test.ts | 31 +++- 11 files changed, 530 insertions(+), 6 deletions(-) create mode 100644 packages/svelte/src/components/Checkbox.svelte create mode 100644 packages/svelte/src/components/Dropdown.svelte create mode 100644 packages/svelte/src/components/RadioButton.svelte create mode 100644 packages/svelte/src/components/TextField.svelte create mode 100644 packages/svelte/tests/fixtures/form-fields.svelte create mode 100644 packages/svelte/tests/fixtures/form-fields.tsx diff --git a/packages/svelte/src/components/Checkbox.svelte b/packages/svelte/src/components/Checkbox.svelte new file mode 100644 index 0000000..de13e3d --- /dev/null +++ b/packages/svelte/src/components/Checkbox.svelte @@ -0,0 +1,22 @@ + + + diff --git a/packages/svelte/src/components/Dropdown.svelte b/packages/svelte/src/components/Dropdown.svelte new file mode 100644 index 0000000..4e04016 --- /dev/null +++ b/packages/svelte/src/components/Dropdown.svelte @@ -0,0 +1,26 @@ + + + diff --git a/packages/svelte/src/components/RadioButton.svelte b/packages/svelte/src/components/RadioButton.svelte new file mode 100644 index 0000000..549d8b3 --- /dev/null +++ b/packages/svelte/src/components/RadioButton.svelte @@ -0,0 +1,24 @@ + + + diff --git a/packages/svelte/src/components/TextField.svelte b/packages/svelte/src/components/TextField.svelte new file mode 100644 index 0000000..271ef26 --- /dev/null +++ b/packages/svelte/src/components/TextField.svelte @@ -0,0 +1,32 @@ + + + diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 46c1852..b955265 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -19,6 +19,10 @@ export { default as LineChart } from './components/LineChart.svelte'; export { default as PieChart } from './components/PieChart.svelte'; export { default as AreaChart } from './components/AreaChart.svelte'; export { default as DotPlot } from './components/DotPlot.svelte'; +export { default as TextField } from './components/TextField.svelte'; +export { default as Checkbox } from './components/Checkbox.svelte'; +export { default as Dropdown } from './components/Dropdown.svelte'; +export { default as RadioButton } from './components/RadioButton.svelte'; // Page-number placeholders export { PAGE_NUMBER, TOTAL_PAGES } from './constants.js'; diff --git a/packages/svelte/src/parser.ts b/packages/svelte/src/parser.ts index 22c0dbd..630224b 100644 --- a/packages/svelte/src/parser.ts +++ b/packages/svelte/src/parser.ts @@ -4,7 +4,7 @@ * The Forme Svelte components render namespaced placeholder tags * (``, ``, ``, ``) * with a single `props` attribute holding the JSON-encoded component - * props. This module parses that markup — the emitting components and + * props. This module parses that markup - the emitting components and * this parser are a matched pair inside this package. The placeholder * tag/attribute vocabulary is an INTERNAL contract, not a public * format: it may change in any release without notice. @@ -16,7 +16,7 @@ * Svelte compiler trims fragment edges and collapses inter-element * whitespace before we ever see the markup, so the remaining rules * are applied here (see `cleanJsxText`). Known divergences from JSX, - * where the compiler destroys the distinction before serialization — + * where the compiler destroys the distinction before serialization - * each matches how Svelte itself renders the template to the DOM: * * - Same-line leading/trailing spaces inside `` are trimmed @@ -228,6 +228,14 @@ function parseElement(element: P5Element, parent: ParentContext): FormeNode | nu return parseChart(element, 'AreaChart', buildAreaChartKind); case 'forme-dot-plot': return parseChart(element, 'DotPlot', buildDotPlotKind); + case 'forme-text-field': + return parseTextField(element); + case 'forme-checkbox': + return parseCheckbox(element); + case 'forme-dropdown': + return parseDropdown(element); + case 'forme-radio-button': + return parseRadioButton(element); case 'forme-page-break': return { kind: { type: 'PageBreak' }, style: {}, children: [] }; case 'forme-page': @@ -394,7 +402,7 @@ function parseText(element: P5Element): FormeNode { * `` (returns null otherwise). Plain text chunks become unstyled * runs; each nested `` becomes one run carrying its own * style/href with all its descendant text merged (deeper nesting - * flattens — a run has exactly one style). Elements other than + * flattens - a run has exactly one style). Elements other than * `` are skipped but still split the surrounding text into * separate runs, as react's per-child loop does. */ @@ -680,6 +688,124 @@ function parseWatermark(element: P5Element): FormeNode { }; } +// ─── 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 ────────────────────────────────────────────────────────── /** @@ -705,7 +831,7 @@ function parseChart

( // ─── Whitespace normalization ──────────────────────────────────────── /** - * Normalize template text to JSX-equivalent semantics — the same + * 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, 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/parity.test.tsx b/packages/svelte/tests/parity.test.tsx index 2a93b7a..b852e4f 100644 --- a/packages/svelte/tests/parity.test.tsx +++ b/packages/svelte/tests/parity.test.tsx @@ -14,6 +14,7 @@ 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'; // @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 @@ -30,6 +31,8 @@ import MediaSvelte from './fixtures/media.svelte'; 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'; describe('cross-adapter parity', () => { it('hello-world: interpolation, #each/#if vs map/&&', async () => { @@ -101,6 +104,18 @@ describe('cross-adapter parity', () => { expect(svelteDoc).toEqual(reactDoc); }); + it('form-fields: TextField flags, Dropdown options, radio group', async () => { + const svelteDoc = await serialize(FormFieldsSvelte, { props: { plan: 'team' } }); + const reactDoc = serializeReact(); + expect(svelteDoc).toEqual(reactDoc); + }); + + it('form-fields with default props (radio group default selection)', async () => { + const svelteDoc = await serialize(FormFieldsSvelte); + const reactDoc = serializeReact(); + expect(svelteDoc).toEqual(reactDoc); + }); + it('fixed-page-numbers: header/footer, placeholder constants', async () => { const svelteDoc = await serialize(FixedPageNumbersSvelte, { props: { paragraphs: 5 } }); const reactDoc = serializeReact(); diff --git a/packages/svelte/tests/parser.test.ts b/packages/svelte/tests/parser.test.ts index 383537c..11b474b 100644 --- a/packages/svelte/tests/parser.test.ts +++ b/packages/svelte/tests/parser.test.ts @@ -936,6 +936,137 @@ describe('charts', () => { }); }); +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(() => diff --git a/packages/svelte/tests/wasm-smoke.test.ts b/packages/svelte/tests/wasm-smoke.test.ts index e31a23d..da6c198 100644 --- a/packages/svelte/tests/wasm-smoke.test.ts +++ b/packages/svelte/tests/wasm-smoke.test.ts @@ -1,6 +1,6 @@ /** * WASM smoke test: the serialized document model renders to real PDF - * bytes through @formepdf/core (devDependency — the adapter itself + * bytes through @formepdf/core (devDependency - the adapter itself * never depends on WASM). */ import { inflateSync } from 'node:zlib'; @@ -19,6 +19,8 @@ import MediaFixture from './fixtures/media.svelte'; import VectorExtras from './fixtures/vector-extras.svelte'; // @ts-expect-error .svelte fixtures have no type declarations in tests import ChartsFixture from './fixtures/charts.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import FormFieldsFixture from './fixtures/form-fields.svelte'; /** * Concatenate every stream object in the PDF, inflating the @@ -87,7 +89,7 @@ describe('WASM smoke', () => { const header = new TextDecoder().decode(pdf.slice(0, 5)); expect(header).toBe('%PDF-'); // QR modules and barcode bars are drawn as rectangle ops (`re`) in - // the content stream — a media-free page has nowhere near this + // the content stream - a media-free page has nowhere near this // many. The data-URI image becomes an XObject (`/Im0 Do`). const text = decompressedStreams(pdf); const rectOps = (text.match(/\bre\b/g) ?? []).length; @@ -139,6 +141,31 @@ describe('WASM smoke', () => { expect(text).toContain(' c\n'); }); + it('renders a registration form with AcroForm fields', async () => { + const json = await render(FormFieldsFixture); + const pdf = await renderPdf(json); + + const header = new TextDecoder().decode(pdf.slice(0, 5)); + expect(header).toBe('%PDF-'); + // AcroForm widgets are plain (uncompressed) annotation dictionaries: + // the catalog carries /AcroForm, each field a /T (name) entry with + // its field type (/FT /Tx text, /Btn button, /Ch choice). + const raw = Buffer.from(pdf).toString('latin1'); + expect(raw).toContain('/AcroForm'); + for (const name of ['full_name', 'bio', 'access_code', 'agree_terms', 'newsletter', 'country', 'plan_type']) { + expect(raw).toContain(`/T (${name})`); + } + expect(raw).toContain('/FT /Tx'); + expect(raw).toContain('/FT /Btn'); + expect(raw).toContain('/FT /Ch'); + // The radio group serializes as one field with three kid widgets + // exporting /free, /pro, and /team states. + expect(raw).toContain('/T (plan)'); + expect(raw).toContain('/free'); + expect(raw).toContain('/pro'); + expect(raw).toContain('/team'); + }); + it('substitutes PAGE_NUMBER / TOTAL_PAGES in a multi-page footer', async () => { const json = await render(FixedPageNumbers, { props: { paragraphs: 40 } }); const pdf = await renderPdf(json); From a7d63d8a6d16e9132710bd06f5dc0ee9ee83b332 Mon Sep 17 00:00:00 2001 From: Chris Joseph <95969273+cmjoseph07@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:29:38 -0500 Subject: [PATCH 09/18] style(svelte): replace em dashes with plain dashes --- packages/svelte/src/components/Image.svelte | 4 ++-- packages/svelte/src/components/Svg.svelte | 4 ++-- packages/svelte/src/constants.ts | 2 +- packages/svelte/src/encode.ts | 2 +- packages/svelte/src/serialize.ts | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/svelte/src/components/Image.svelte b/packages/svelte/src/components/Image.svelte index cba1e08..4501071 100644 --- a/packages/svelte/src/components/Image.svelte +++ b/packages/svelte/src/components/Image.svelte @@ -5,7 +5,7 @@ interface Props { /** Image source: a `data:image/...;base64,...` URI, a path relative * to the template file, or an absolute file path. Passed through - * unresolved — resolution happens at render time. */ + * unresolved - resolution happens at render time. */ src: string; /** Display width in points. If only one dimension is given, the * other is computed from the image's aspect ratio. */ @@ -13,7 +13,7 @@ /** Display height in points. */ height?: number; style?: Style; - /** Optional hyperlink URL — makes the image clickable. */ + /** Optional hyperlink URL - makes the image clickable. */ href?: string; /** Alt text for accessibility. */ alt?: string; diff --git a/packages/svelte/src/components/Svg.svelte b/packages/svelte/src/components/Svg.svelte index 6405cc8..d41ddde 100644 --- a/packages/svelte/src/components/Svg.svelte +++ b/packages/svelte/src/components/Svg.svelte @@ -9,12 +9,12 @@ height: number; /** SVG viewBox string (e.g., "0 0 100 100"). */ viewBox?: string; - /** SVG markup string — the inner content, not the outer `` tag. + /** SVG markup string - the inner content, not the outer `` tag. * Supported shapes: rect, circle, ellipse, line, path, polygon, * polyline. */ content?: string; style?: Style; - /** Optional hyperlink URL — makes the SVG clickable. */ + /** Optional hyperlink URL - makes the SVG clickable. */ href?: string; /** Alt text for accessibility. */ alt?: string; diff --git a/packages/svelte/src/constants.ts b/packages/svelte/src/constants.ts index 964c72c..f9360f9 100644 --- a/packages/svelte/src/constants.ts +++ b/packages/svelte/src/constants.ts @@ -4,7 +4,7 @@ * The engine substitutes `{{pageNumber}}` and `{{totalPages}}` in text * content when it serializes each page. In JSX the braces can be typed * as a string literal (`{'{{pageNumber}}'}`), but in a Svelte template - * `{{pageNumber}}` parses as an object-literal expression — so the + * `{{pageNumber}}` parses as an object-literal expression - so the * documented way to emit the placeholders is interpolating these * constants: * diff --git a/packages/svelte/src/encode.ts b/packages/svelte/src/encode.ts index 2c5ff7a..42fdd0f 100644 --- a/packages/svelte/src/encode.ts +++ b/packages/svelte/src/encode.ts @@ -3,7 +3,7 @@ * * Each Forme Svelte component renders one placeholder tag whose * `props` attribute carries the component's props as JSON (part of - * the internal emitter/parser contract — see `parser.ts`). Values + * the internal emitter/parser contract - see `parser.ts`). Values * that cannot survive the JSON round-trip fail loudly here, naming * the component and prop, rather than silently producing a wrong PDF. */ diff --git a/packages/svelte/src/serialize.ts b/packages/svelte/src/serialize.ts index b1b21f3..b8e02b4 100644 --- a/packages/svelte/src/serialize.ts +++ b/packages/svelte/src/serialize.ts @@ -5,11 +5,11 @@ * (`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. + * 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 — + * 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. */ From bb5de07116e0096d3789a9d37e09a1506bd56cd8 Mon Sep 17 00:00:00 2001 From: Chris Joseph <95969273+cmjoseph07@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:10:35 -0500 Subject: [PATCH 10/18] feat(svelte): add font registration, StyleSheet parity, and tw() support - Re-export Font from the shared store singleton; merges with globals, document fonts winning on family:weight:italic - Tunnel Uint8Array font sources through a base64 marker in the props attribute so byte sources survive serialization as in react; reserved-key collisions fail loudly at encode time - Add fonts and tailwind parity fixtures, font merge/override tests, and a WASM smoke render embedding NotoSans --- package-lock.json | 1 + packages/svelte/package.json | 1 + .../svelte/src/components/Document.svelte | 4 +- packages/svelte/src/encode.ts | 60 +++++++++++++- packages/svelte/src/index.ts | 5 ++ packages/svelte/src/parser.ts | 14 +++- packages/svelte/tests/encode.test.ts | 26 +++++- packages/svelte/tests/fixtures/fonts.svelte | 29 +++++++ packages/svelte/tests/fixtures/fonts.tsx | 30 +++++++ .../svelte/tests/fixtures/tailwind.svelte | 26 ++++++ packages/svelte/tests/fixtures/tailwind.tsx | 27 +++++++ packages/svelte/tests/parity.test.tsx | 46 ++++++++++- packages/svelte/tests/serialize.test.ts | 79 ++++++++++++++++++- packages/svelte/tests/wasm-smoke.test.ts | 28 ++++++- 14 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 packages/svelte/tests/fixtures/fonts.svelte create mode 100644 packages/svelte/tests/fixtures/fonts.tsx create mode 100644 packages/svelte/tests/fixtures/tailwind.svelte create mode 100644 packages/svelte/tests/fixtures/tailwind.tsx diff --git a/package-lock.json b/package-lock.json index f3e2dba..d684b6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6440,6 +6440,7 @@ "devDependencies": { "@formepdf/core": "0.10.4", "@formepdf/react": "0.10.4", + "@formepdf/tailwind": "0.10.4", "@sveltejs/package": "^2.3.10", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@types/react": "^19.0.0", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 5398bcc..8be54cf 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -29,6 +29,7 @@ "devDependencies": { "@formepdf/core": "0.10.4", "@formepdf/react": "0.10.4", + "@formepdf/tailwind": "0.10.4", "@sveltejs/package": "^2.3.10", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@types/react": "^19.0.0", diff --git a/packages/svelte/src/components/Document.svelte b/packages/svelte/src/components/Document.svelte index 85a926b..84698e7 100644 --- a/packages/svelte/src/components/Document.svelte +++ b/packages/svelte/src/components/Document.svelte @@ -1,6 +1,6 @@ `, + * ``, ``, ``) - 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 === 'forme-text')) return 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 = () => { @@ -429,7 +489,11 @@ function textRunsOf(nodes: P5Node[]): TextRun[] | null { // 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 !== '') runs.push({ content }); + 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) { @@ -437,12 +501,15 @@ function textRunsOf(nodes: P5Node[]): TextRun[] | null { buffer += node.value; } else if (isElement(node)) { flush(); - if (node.tagName !== 'forme-text') continue; - const props = decodeProps(node, 'Text') as TextProps; - const run: TextRun = { content: textContentOf(node.childNodes) }; - if (props.style) run.style = mapStyle(props.style); - if (props.href) run.href = props.href; - runs.push(run); + 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. @@ -451,6 +518,108 @@ function textRunsOf(nodes: P5Node[]): TextRun[] | null { 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 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/parity.test.tsx b/packages/svelte/tests/parity.test.tsx index 76ac350..f58ff6d 100644 --- a/packages/svelte/tests/parity.test.tsx +++ b/packages/svelte/tests/parity.test.tsx @@ -17,6 +17,7 @@ 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 @@ -39,93 +40,135 @@ import FormFieldsSvelte from './fixtures/form-fields.svelte'; 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(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('hello-world with default props', async () => { const svelteDoc = await serialize(HelloWorldSvelte); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + 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(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('text-runs: nested spans, run styles, boundary whitespace', async () => { const svelteDoc = await serialize(TextRunsSvelte, { props: { price: 42 } }); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('table: 50-row #each, header row, column widths, spans, view cells', async () => { const svelteDoc = await serialize(TableSvelte); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + 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(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('media with default props', async () => { const svelteDoc = await serialize(MediaSvelte); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + 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(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('vector-extras with default props', async () => { const svelteDoc = await serialize(VectorExtrasSvelte); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + 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(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('charts with default props (defaulted chart options included)', async () => { const svelteDoc = await serialize(ChartsSvelte); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('form-fields: TextField flags, Dropdown options, radio group', async () => { const svelteDoc = await serialize(FormFieldsSvelte, { props: { plan: 'team' } }); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('form-fields with default props (radio group default selection)', async () => { const svelteDoc = await serialize(FormFieldsSvelte); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('fixed-page-numbers: header/footer, placeholder constants', async () => { const svelteDoc = await serialize(FixedPageNumbersSvelte, { props: { paragraphs: 5 } }); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('fonts: Font.register globals merge with document fonts, byte srcs intact', async () => { @@ -136,7 +179,7 @@ describe('cross-adapter parity', () => { try { const svelteDoc = await serialize(FontsSvelte); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + 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'); @@ -151,18 +194,30 @@ describe('cross-adapter parity', () => { it('fonts with default props (no globals registered)', async () => { const svelteDoc = await serialize(FontsSvelte); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('tailwind: templates styled entirely with tw() classes', async () => { const svelteDoc = await serialize(TailwindSvelte, { props: { total: '$99.00' } }); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + expectParity(svelteDoc, reactDoc); }); it('tailwind with default props', async () => { const svelteDoc = await serialize(TailwindSvelte); const reactDoc = serializeReact(); - expect(svelteDoc).toEqual(reactDoc); + 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 index 11b474b..b55dd9b 100644 --- a/packages/svelte/tests/parser.test.ts +++ b/packages/svelte/tests/parser.test.ts @@ -258,12 +258,17 @@ describe('text runs', () => { ).toEqual([{ content: 'a' }, { content: 'b' }]); }); - it('flattens deeper nesting into the run of the outermost span', () => { + it('deeper nesting produces separate runs with accumulated styles', () => { expect( textKind( - `x abc` + `x abc` ).runs - ).toEqual([{ content: 'x ' }, { content: 'abc' }]); + ).toEqual([ + { content: 'x ' }, + { content: 'a' }, + { content: 'b', style: { fontWeight: 700 } }, + { content: 'c' }, + ]); }); it('merges text across SSR block anchor comments within a chunk', () => { @@ -273,6 +278,169 @@ describe('text runs', () => { }); }); +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(``); diff --git a/packages/svelte/tests/wasm-smoke.test.ts b/packages/svelte/tests/wasm-smoke.test.ts index 31a062c..ed6f0c3 100644 --- a/packages/svelte/tests/wasm-smoke.test.ts +++ b/packages/svelte/tests/wasm-smoke.test.ts @@ -24,6 +24,8 @@ import ChartsFixture from './fixtures/charts.svelte'; import FormFieldsFixture from './fixtures/form-fields.svelte'; // @ts-expect-error .svelte fixtures have no type declarations in tests import FontsFixture from './fixtures/fonts.svelte'; +// @ts-expect-error .svelte fixtures have no type declarations in tests +import SemanticsFixture from './fixtures/semantics.svelte'; describe('WASM smoke', () => { it('renders a serialized .svelte template to valid PDF bytes', async () => { @@ -179,4 +181,21 @@ describe('WASM smoke', () => { expect(text).not.toContain('{{pageNumber}}'); expect(text).not.toContain('{{totalPages}}'); }); + + it('renders headings, lists, inline formatting, and transforms', async () => { + const json = await render(SemanticsFixture, { props: { productName: 'Forme PDF' } }); + const pdf = await renderPdf(json); + + const header = new TextDecoder().decode(pdf.slice(0, 5)); + expect(header).toBe('%PDF-'); + const text = decompressedStreams(pdf); + // Heading and inline-run content is drawn. + expect(text).toContain('Getting started with Forme PDF'); + expect(text).toContain('(both)'); + // Ordered list markers respect type + start (lower-roman from iii). + expect(text).toContain('(iii.)'); + expect(text).toContain('(iv.)'); + // The transformed view emits a transform matrix (cm operator). + expect(text).toMatch(/[\d.-]+ [\d.-]+ [\d.-]+ [\d.-]+ [\d.-]+ [\d.-]+ cm/); + }); }); From 886bda67c5a5e8fe4703fb1f6610961c1f6973db Mon Sep 17 00:00:00 2001 From: Chris Joseph <95969273+cmjoseph07@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:40:40 -0500 Subject: [PATCH 16/18] fix(svelte): review fixes - Page style prop, preview 503 hygiene, CI coverage - Page components in both adapters now type the style prop the serializers already handled - formePreview 503 responses return a generic message in production instead of the raw render error (dev keeps the full message - the preview page shows template errors in the browser) - encode.ts documents why undefined JSON drops a prop (functions have no JSON representation and must not emit invalid JSON) - CI builds, svelte-checks, and tests @formepdf/svelte --- .github/workflows/ci.yml | 11 +++++++++++ packages/react/src/types.ts | 2 ++ packages/svelte/src/components/Page.svelte | 4 +++- packages/svelte/src/encode.ts | 3 +++ packages/svelte/src/preview/index.ts | 9 ++++++++- 5 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7852b3f..a0218fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,12 @@ jobs: - 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 @@ -68,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/packages/react/src/types.ts b/packages/react/src/types.ts index db4bdb9..92f2727 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -87,6 +87,8 @@ export interface DocumentProps { export interface PageProps { size?: 'A4' | 'A3' | 'A5' | 'Letter' | 'Legal' | 'Tabloid' | { width: number; height: number }; margin?: number | string | number[] | Edges; + /** Style applied to the page's content area (background, padding, default text styles). */ + style?: Style; /** Optional background image painted behind the page's content. URL, * file path, or `data:image/...;base64,` URI. */ backgroundImage?: string; diff --git a/packages/svelte/src/components/Page.svelte b/packages/svelte/src/components/Page.svelte index d38797b..488d3d8 100644 --- a/packages/svelte/src/components/Page.svelte +++ b/packages/svelte/src/components/Page.svelte @@ -1,11 +1,13 @@