diff --git a/README.md b/README.md
index fcdeb308..208c99d4 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://github.com/ExaDev/documents.js) [](https://www.npmjs.com/package/documents.js) [](https://github.com/ExaDev/documents.js/releases/latest) [](https://github.com/ExaDev/documents.js/actions)
-> Converts between any two compatible document formats through a shared content/layout pivot. docx, pptx, odt, odp, ods, odg, xlsx, csv (TSV is the same format with a tab delimiter), and markdown all read into and build from the same `ContentDocument`/`LayoutDocument` model, with PDF as the one format every variant can reach. A composition engine (`convertDocument`) routes 91 (source, target) pairs across the nine content formats and PDF, including eighteen PDF-pivot round trips (the seven layout-engine formats, plus xlsx and csv composing through ods), twenty-two cross-format bridge functions (same-variant direct copies, cross-variant semantic transforms, and PDF-composed), plus special-case conversions for `.odm` master documents, `.odb` database front-ends (HSQLDB and Firebird, four storage tiers), standalone `.odf` formula documents, and a bounded SQL/rpt-formula engine for `.odb` reports. Also includes: read-and-write live-view editors for all six editable formats, docx comment/footnote/header-footer exposure via `readDocxExtras`, real font resolution (source-embedded faces ahead of caller-supplied, vendored substitutes, and the standard 14), a hand-written MathML typesetting engine with embedded-font PDF rendering and a matching MathML ⇄ OMML translator, and a fully hand-written PDF codec. Built on [ooxml.js](https://github.com/ExaDev/ooxml.js), [odf.js](https://github.com/ExaDev/odf.js), [pdf-codec](https://github.com/ExaDev/pdf-codec), [markdown-codec](https://github.com/ExaDev/markdown-codec), and [document-schema.js](https://github.com/ExaDev/document-schema.js).
+> Converts between any two compatible document formats through a shared content/layout pivot. docx, pptx, odt, odp, ods, odg, xlsx, csv (TSV is the same format with a tab delimiter), svg, and markdown all read into and build from the same `ContentDocument`/`LayoutDocument` model, with PDF as the one format every variant can reach. A composition engine (`convertDocument`) routes 111 (source, target) pairs across the ten content formats and PDF, including twenty PDF-pivot round trips (the eight layout-engine formats, plus xlsx and csv composing through ods), twenty-four cross-format bridge functions (same-variant direct copies, cross-variant semantic transforms, and PDF-composed), plus special-case conversions for `.odm` master documents, `.odb` database front-ends (HSQLDB and Firebird, four storage tiers), standalone `.odf` formula documents, and a bounded SQL/rpt-formula engine for `.odb` reports. Also includes: read-and-write live-view editors for all six editable formats, docx comment/footnote/header-footer exposure via `readDocxExtras`, real font resolution (source-embedded faces ahead of caller-supplied, vendored substitutes, and the standard 14), a hand-written MathML typesetting engine with embedded-font PDF rendering and a matching MathML ⇄ OMML translator, and a fully hand-written PDF codec. Built on [ooxml.js](https://github.com/ExaDev/ooxml.js), [odf.js](https://github.com/ExaDev/odf.js), [pdf-codec](https://github.com/ExaDev/pdf-codec), [markdown-codec](https://github.com/ExaDev/markdown-codec), and [document-schema.js](https://github.com/ExaDev/document-schema.js).
`documents.js` extends `ooxml.js` in two directions `ooxml.js` deliberately does not cover: full PDF support (parsing and generating, via `pdf-codec`), and a read-**and-write** manipulation API for docx/pptx content — `ooxml.js`'s own typed readers are one-way. The PDF codec is hand-written against ISO 32000-1, with no external PDF library as a dependency — see [Fidelity](#fidelity) and pdf-codec's own README for the honest trade-off (not as robust against adversarial PDFs as a 15+-year-hardened library; fully auditable and dependency-free instead). `src/mathml/` (the MathML typesetting engine) stays in this package and is hand-written too, for the same supply-chain reason.
@@ -72,7 +72,7 @@ npm install documents.js
### The generic entry point: `convertDocument`
-A single function, `convertDocument`, sits behind every named conversion and reaches every pair the composition engine can route — all 91 supported (source, target) combinations. The named functions below are thin one-line forwarders to it; they remain the ergonomic layer for a caller who wants a fixed pair and autocomplete discovery, while `convertDocument` is the first-class entry point for a caller working from a runtime format pair (CLI, MCP tool, matrix enumeration).
+A single function, `convertDocument`, sits behind every named conversion and reaches every pair the composition engine can route — all 111 supported (source, target) combinations. The named functions below are thin one-line forwarders to it; they remain the ergonomic layer for a caller who wants a fixed pair and autocomplete discovery, while `convertDocument` is the first-class entry point for a caller working from a runtime format pair (CLI, MCP tool, matrix enumeration).
```ts
import { convertDocument } from 'documents.js';
@@ -89,10 +89,10 @@ const odtBytes = convertDocument('docx', 'odt', docxBytes, { onMathDiagnostic: (
### PDF-pivot conversions
-The fourteen round-trip ergonomic conversions between the formats with their own layout engine and PDF (docx/pptx/odt/odp/ods/odg/markdown ⇄ PDF, all round-tripping both ways), plus `xlsxToPdf`/`pdfToXlsx` and `csvToPdf`/`pdfToCsv` (each composing its ods bridge with the ods⇄pdf layout pair internally — neither xlsx nor csv has a layout engine of its own):
+The sixteen round-trip ergonomic conversions between the formats with their own layout engine and PDF (docx/pptx/odt/odp/ods/odg/markdown/svg ⇄ PDF, all round-tripping both ways), plus `xlsxToPdf`/`pdfToXlsx` and `csvToPdf`/`pdfToCsv` (each composing its ods bridge with the ods⇄pdf layout pair internally — neither xlsx nor csv has a layout engine of its own):
```ts
-import { csvToPdf, docxToPdf, markdownToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pdfToCsv, pdfToDocx, pdfToMarkdown, pdfToOdg, pdfToOdp, pdfToOds, pdfToOdt, pdfToPptx, pdfToXlsx, pptxToPdf, xlsxToPdf } from 'documents.js';
+import { csvToPdf, docxToPdf, markdownToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pdfToCsv, pdfToDocx, pdfToMarkdown, pdfToOdg, pdfToOdp, pdfToOds, pdfToOdt, pdfToPptx, pdfToSvg, pdfToXlsx, pptxToPdf, svgToPdf, xlsxToPdf } from 'documents.js';
const pdfBytes = docxToPdf(docxBytes);
const docxBytes2 = pdfToDocx(pdfBytes);
@@ -120,13 +120,16 @@ const markdownBytes2 = pdfToMarkdown(pdfFromMarkdown); // the lossiest conversio
const pdfFromCsv = csvToPdf(csvBytes); // composes csvToOds -> odsToPdf internally
const csvBytes2 = pdfToCsv(pdfFromCsv); // composes pdfToOds -> odsToCsv internally; recovers what was printed, then heuristically re-types it
+
+const pdfFromSvg = svgToPdf(svgBytes); // reads the six shape primitives into a drawing ContentDocument, then the same drawing layout engine odgToPdf feeds renders it
+const svgBytes2 = pdfToSvg(pdfFromSvg); // readPdf -> reconstructDrawing -> buildSvgText: vector geometry recovers near-1:1, while recovered text boxes sit outside the svg writer's vector-only scope (reported per shape, never silently dropped)
```
Each accepts an optional `signal` (`AbortSignal`) and either `onSubstitution` (X → PDF, called per character not representable in a standard-14 font) or `sink` (PDF → X, called per recoverable parse diagnostic). Every X → PDF conversion additionally accepts `fonts` (extra `ProvidedFont` faces) and `onFontSubstitution` (per family+weight+style that resolved to something else). Neither is needed for the common case — see [Fonts](#fonts).
### Cross-format bridges
-Twenty-two bridge functions across eleven pairs bypass the PDF pivot where a direct path exists. Seven same-variant direct-copy pairs (`odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`, `csvToOds`/`odsToCsv`, `csvToXlsx`/`xlsxToCsv`, `markdownToDocx`/`docxToMarkdown`, `markdownToOdt`/`odtToMarkdown`) compose a direct `readXContent` → `buildYPackage` pivot copy — the csv pairs are one hop to its spreadsheet siblings, so csv never needs PDF to reach ods or xlsx. Two cross-variant semantic-transform pairs (`docxToPptx`/`pptxToDocx`, `odtToOdp`/`odpToOdt`) go through `src/convert/variant-bridges.ts`. Two PDF-composed pairs (`xlsxToMarkdown`/`markdownToXlsx`, `csvToMarkdown`/`markdownToCsv`) route through PDF internally — the lossiest conversions in the package.
+Twenty-four bridge functions across twelve pairs bypass the PDF pivot where a direct path exists. Eight same-variant direct-copy pairs (`odtToDocx`/`docxToOdt`, `odpToPptx`/`pptxToOdp`, `odsToXlsx`/`xlsxToOds`, `csvToOds`/`odsToCsv`, `csvToXlsx`/`xlsxToCsv`, `svgToOdg`/`odgToSvg`, `markdownToDocx`/`docxToMarkdown`, `markdownToOdt`/`odtToMarkdown`) compose a direct `readXContent` → `buildYPackage` pivot copy — the csv pairs are one hop to its spreadsheet siblings, so csv never needs PDF to reach ods or xlsx, and `svgToOdg`/`odgToSvg` bridge svg to its drawing sibling odg the same way. Two cross-variant semantic-transform pairs (`docxToPptx`/`pptxToDocx`, `odtToOdp`/`odpToOdt`) go through `src/convert/variant-bridges.ts`. Two PDF-composed pairs (`xlsxToMarkdown`/`markdownToXlsx`, `csvToMarkdown`/`markdownToCsv`) route through PDF internally — the lossiest conversions in the package.
```ts
import { odtToDocx, docxToOdt, markdownToDocx, docxToMarkdown } from 'documents.js';
@@ -138,7 +141,7 @@ const docxFromMarkdown = markdownToDocx(markdownBytes);
const markdownBytes3 = docxToMarkdown(docxFromMarkdown); // colour, font family/size, and explicit alignment have no markdown source construct -- dropped on this hop
```
-Each takes an optional `{ signal }` — no `onSubstitution`/`sink`, since there is no font substitution or PDF-parse degradation. `odtToDocx`/`markdownToDocx`/`docxToOdt`/`docxToMarkdown` additionally take `onMathDiagnostic`, called per formula construct that degraded crossing the bridge. The csv-sourced bridges (`csvToOds`, `csvToXlsx`, `csvToMarkdown`, `csvToPdf`) take `{ delimiter }` — `'\t'` parses the same format as TSV, since a delimiter is a parse option, not a different document format — and `onCellTypeInference`, the per-decision audit channel the read shares with `pdfToOds`. The csv-target bridges (`odsToCsv`, `xlsxToCsv`, `markdownToCsv`, `pdfToCsv`) take `{ delimiter, sheet }`: csv has no second sheet, so writing a multi-sheet source refuses with `CsvSheetNotSpecifiedError` naming every sheet until a caller selects one.
+Each takes an optional `{ signal }` — no `onSubstitution`/`sink`, since there is no font substitution or PDF-parse degradation. `odtToDocx`/`markdownToDocx`/`docxToOdt`/`docxToMarkdown` additionally take `onMathDiagnostic`, called per formula construct that degraded crossing the bridge. The csv-sourced bridges (`csvToOds`, `csvToXlsx`, `csvToMarkdown`, `csvToPdf`) take `{ delimiter }` — `'\t'` parses the same format as TSV, since a delimiter is a parse option, not a different document format — and `onCellTypeInference`, the per-decision audit channel the read shares with `pdfToOds`. The csv-target bridges (`odsToCsv`, `xlsxToCsv`, `markdownToCsv`, `pdfToCsv`) take `{ delimiter, sheet }`: csv has no second sheet, so writing a multi-sheet source refuses with `CsvSheetNotSpecifiedError` naming every sheet until a caller selects one. The svg-sourced bridges (`svgToOdg`, `svgToPdf`) take `onSvgDiagnostic`, the reader's per-scope-limit channel; the svg-target bridges (`odgToSvg`, `pdfToSvg`) take `{ page, onSvgDiagnostic }`: an svg is a single drawing, so writing a multi-page source refuses with `SvgMultiPageNotSpecifiedError` naming the page count until `{ page }` selects one (an index, because drawing pages are anonymous where sheets are named).
### The `DocumentConverter` port
@@ -154,12 +157,12 @@ const { document, diagnostics } = await converter.convert(
);
```
-`DocumentFormat` includes `docx`/`pptx`/`xlsx`/`odt`/`odp`/`ods`/`odg`/`odf`/`csv`/`markdown`/`pdf` — eleven members. The port's `conversions` list is derived from `resolveCompositionPlan` plus the `odf`→`pdf` special case — 91 pairs total. `DocumentFormat` is inferred from `DocumentFormatSchema` (a real Zod schema); `DOCUMENT_FORMATS` is exported as a plain array derived from the same schema:
+`DocumentFormat` includes `docx`/`pptx`/`xlsx`/`odt`/`odp`/`ods`/`odg`/`svg`/`odf`/`csv`/`markdown`/`pdf` — twelve members. The port's `conversions` list is derived from `resolveCompositionPlan` plus the `odf`→`pdf` special case — 111 pairs total. `DocumentFormat` is inferred from `DocumentFormatSchema` (a real Zod schema); `DOCUMENT_FORMATS` is exported as a plain array derived from the same schema:
```ts
import { DOCUMENT_FORMATS, DocumentFormatSchema } from 'documents.js';
-console.log(DOCUMENT_FORMATS); // ['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'odf', 'csv', 'markdown', 'pdf']
+console.log(DOCUMENT_FORMATS); // ['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'svg', 'odf', 'csv', 'markdown', 'pdf']
DocumentFormatSchema.parse(userSuppliedFormat); // throws a ZodError for anything outside that list
```
@@ -205,7 +208,7 @@ const docxBytesAgain = buildDocumentBytes(captured, 'docx');
### Package decode/encode, metadata, and deep imports
-`decodeDocumentPackage`/`encodeDocumentPackage` dispatch docx/pptx/xlsx through `ooxml.js`'s OPC codec and odt/odp/ods/odg/odf through `odf.js`'s ODF codec, throwing `UnsupportedPackageFormatError` for `markdown`/`csv`/`pdf` (none of the three is a package — they are plain text and bytes respectively). `decodeOdbPackage` is the `.odb`-specific sibling (`.odb` is not a `DocumentFormat` member):
+`decodeDocumentPackage`/`encodeDocumentPackage` dispatch docx/pptx/xlsx through `ooxml.js`'s OPC codec and odt/odp/ods/odg/odf through `odf.js`'s ODF codec, throwing `UnsupportedPackageFormatError` for `markdown`/`csv`/`svg`/`pdf` (none of the four is a package — the first three are plain text, pdf is bytes). `decodeOdbPackage` is the `.odb`-specific sibling (`.odb` is not a `DocumentFormat` member):
```ts
import { decodeDocumentPackage, decodeOdbPackage, encodeDocumentPackage } from 'documents.js';
@@ -215,7 +218,7 @@ const docxBytesAgain = encodeDocumentPackage('docx', pkg);
const odbPkg = decodeOdbPackage(odbBytes);
```
-`readDocumentMetadata`/`setDocumentMetadata` read or patch metadata across any `DocumentFormat`. `setDocumentMetadata` patches in place (source/target formats must match); `odf` is rejected in both directions, and `csv` is rejected in both directions too (RFC 4180 text has no metadata container) — `readDocumentMetadata('csv', ...)` answers an empty `LayoutMetadata` for the same reason. `readDocumentMetadata('xlsx', ...)` is a named exception: it renders via `xlsxToPdf` and reads the PDF's metadata, because a direct read and the PDF-preview path genuinely disagree on `createdIso`/`modifiedIso`/`producer`.
+`readDocumentMetadata`/`setDocumentMetadata` read or patch metadata across any `DocumentFormat`. `setDocumentMetadata` patches in place (source/target formats must match); `odf` is rejected in both directions, and `csv` is rejected in both directions too (RFC 4180 text has no metadata container) — `readDocumentMetadata('csv', ...)` answers an empty `LayoutMetadata` for the same reason. `svg` reads its root `
` as `metadata.title` and is rejected as a `setDocumentMetadata` source/target for the mirror-image reason: `` is svg's whole metadata surface, so any other override would be silently dropped by the rebuild. `readDocumentMetadata('xlsx', ...)` is a named exception: it renders via `xlsxToPdf` and reads the PDF's metadata, because a direct read and the PDF-preview path genuinely disagree on `createdIso`/`modifiedIso`/`producer`.
```ts
import { readDocumentMetadata, setDocumentMetadata } from 'documents.js';
@@ -233,7 +236,7 @@ import { buildOdtPackage } from 'documents.js/edit/odt/content';
### Reading and building xlsx content directly
-Every other content format has its own standalone `readXContent`-shaped entry point (`readDocxContent`, `readPptxContent`, `readOdtContent`, `readOdpContent`, `readOdsContent`, `readOdgContent`) — xlsx is no longer the exception. `readXlsxContent`/`buildXlsxPackage` are `ooxml.js`'s own spreadsheet `ContentDocument` read/build pair — the same one the `ods⇄xlsx` bridge and every xlsx metadata-rebuild path already use internally — re-exported here directly rather than wrapped, since `readXlsxContent` already produces the right shape on its own. csv's `readCsvContent`/`buildCsvText` are the same kind of directly-exported stage pair, one level further in: they operate on RFC 4180 text rather than a decoded package (see `src/csv/` under Architecture).
+Every other content format has its own standalone `readXContent`-shaped entry point (`readDocxContent`, `readPptxContent`, `readOdtContent`, `readOdpContent`, `readOdsContent`, `readOdgContent`) — xlsx is no longer the exception. `readXlsxContent`/`buildXlsxPackage` are `ooxml.js`'s own spreadsheet `ContentDocument` read/build pair — the same one the `ods⇄xlsx` bridge and every xlsx metadata-rebuild path already use internally — re-exported here directly rather than wrapped, since `readXlsxContent` already produces the right shape on its own. csv's `readCsvContent`/`buildCsvText` are the same kind of directly-exported stage pair, one level further in: they operate on RFC 4180 text rather than a decoded package (see `src/csv/` under Architecture). svg's `readSvgContent`/`buildSvgText` are the drawing-variant counterpart of csv's pair, operating on SVG text rather than a decoded package (see `src/svg/` under Architecture).
```ts
import { buildXlsxPackage, decodeDocumentPackage, encodeDocumentPackage, readXlsxContent } from 'documents.js';
@@ -329,7 +332,7 @@ const layout = readPdf(pdfBytes); // -> LayoutDocument: pages of positioned text
const bytes = writePdf(layout);
```
-The ten PDF round trips and fourteen PDF-bypassing bridge directions are also available as schema-validated [`z.codec()`](https://zod.dev) pairs (`pdfCodec`, `docxPdfCodec`, `pptxPdfCodec`, `odtPdfCodec`, `odpPdfCodec`, `odsPdfCodec`, `odgPdfCodec`, `xlsxPdfCodec`, `csvPdfCodec`, `markdownPdfCodec`, `odtDocxCodec`, `odpPptxCodec`, `odsXlsxCodec`, `odsCsvCodec`, `xlsxCsvCodec`, `markdownDocxCodec`, `markdownOdtCodec`) — the no-options form, adding automatic two-way schema validation. The two PDF-composed pairs have codec forms too (`xlsxMarkdownCodec`, `csvMarkdownCodec`):
+The eleven PDF round trips and sixteen PDF-bypassing bridge directions are also available as schema-validated [`z.codec()`](https://zod.dev) pairs (`pdfCodec`, `docxPdfCodec`, `pptxPdfCodec`, `odtPdfCodec`, `odpPdfCodec`, `odsPdfCodec`, `odgPdfCodec`, `svgPdfCodec`, `xlsxPdfCodec`, `csvPdfCodec`, `markdownPdfCodec`, `odtDocxCodec`, `odpPptxCodec`, `odsXlsxCodec`, `odsCsvCodec`, `xlsxCsvCodec`, `odgSvgCodec`, `markdownDocxCodec`, `markdownOdtCodec`) — the no-options form, adding automatic two-way schema validation. The two PDF-composed pairs have codec forms too (`xlsxMarkdownCodec`, `csvMarkdownCodec`):
```ts
import { z } from 'zod';
@@ -508,6 +511,7 @@ The package is layered from generic primitives outward to the two conversion dir
- **`src/odf/`** — ODF-side counterparts: `readOdtContent`/`readOdpContent`/`readOdsContent`/`readOdgContent` are thin adapters over `odf.js`. `formula/read.ts`/`formula/detect.ts` handle embedded formula detection (genuinely new work with no `odf.js`-side equivalent).
- **`src/markdown/`** — third adapter family, via `markdown-codec`. `readMarkdownContent` passes `readMarkdown`'s result straight through (it already produces a full `ContentDocument`). `buildMarkdownText` wraps `writeMarkdown`. `text.ts` is the byte↔text boundary. `MarkdownEditor` holds a mutable in-memory `ContentDocument`.
- **`src/csv/`** — fourth adapter family, sharing the spreadsheet variant with xlsx/ods. `records.ts` is the RFC 4180 record parser/writer (one shared `quoteCsvField`, also used by the `.odb` CSV exporter); `text.ts` is the byte↔text boundary, rejecting malformed UTF-8; `read.ts` turns records into a spreadsheet `ContentDocument` (first record as verbatim string header, data cells through the same cell-typing heuristic `pdfToOds` uses); `write.ts` turns one sheet of a spreadsheet `ContentDocument` back into records via each cell's `displayText`. TSV is the same format with `{ delimiter: '\t' }` on either side.
+- **`src/svg/`** — fifth adapter family, sharing the drawing variant with odg. `text.ts` is the byte↔text boundary, rejecting malformed UTF-8; `read.ts` maps the six SVG shape primitives (rect/circle/ellipse/line/polyline/polygon/path) onto a one-page drawing `ContentDocument`, with transform lists composed as 2×3 affines and CSS lengths and the viewBox map resolved into page points; `write.ts` writes the six primitives back out, one shape element each; `path.ts` is the full SVG path-data grammar (M/L/H/V/C/S/Z plus Q/T/A and the relative forms — S/Q/T convert exactly, A is the one bounded approximation at ≤90° per cubic); `transform.ts` parses and composes the transform attribute and classifies the result by frame representability; `units.ts` resolves CSS length units and the viewBox; `paint.ts` resolves fill/stroke presentation attributes and dash styles; `diagnostics.ts` is the shared scope-limit vocabulary.
- **`src/layout/`** — the pure conversion algorithms: `engine.ts` (wordprocessing → layout: flow, line-breaking, pagination), `slides.ts` (presentation → layout: direct placement), `sheets.ts` (spreadsheet → layout: grid, print settings, the first algorithm accepting `AbortSignal`), `drawing.ts` (drawing → layout: vector primitives + shape reuse), `reconstruct.ts` (layout → content: baseline clustering for wordprocessing/presentation, near-1:1 mapping for drawing, gridline-lattice-or-text-clustering for spreadsheet).
- **`src/hsqldb/`** — `.odb` decoders, four tiers: `script.ts` (TEXT-script DDL/DML parser), `rowformat.ts`/`cache.ts` (CACHED binary row-store), `binary-script.ts` (BINARY/COMPRESSED whole-script). All import only `document-schema.js` — no odf.js knowledge.
- **`src/firebird/`** — Tier 3: gbak logical-backup reader. `reader.ts` (attribute framing + RLE decompression + XDR decoding), `schema.ts`/`data.ts` (table/row walking). No ratified spec — built against Firebird's own engine source.
@@ -555,8 +559,15 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
- **`readPdf` recovers rect/ellipse/line as their own `LayoutRect`/`LayoutEllipse`/`LayoutLine` kinds** via pdf-codec's shape-pattern detection — an axis-aligned closed four-corner subpath is a rect, four kappa-ratio cubics at cardinal points is an ellipse, an open single straight stroke is a line. A false positive changes kind, never geometry. Off-axis rotations, freeform curves, and multi-subpath figures narrow to `LayoutPath`.
- **`pdfToOds` re-types cells heuristically — this is probabilistic, not a fidelity guarantee.** A rendered PDF never carries a cell's typed value, only the printed string. Re-typing fires only where the string has exactly one defensible reading: the decimal must be exactly representable as a JS number; separators must be unambiguous (`"1,234"` is declined — competing European reading is 1.234); leading zeros decline (`"007"`); dates must self-state their component roles (ISO or named month accepted; `"01/02/2024"` declined). `TRUE`/`FALSE` re-type as booleans; `Yes`/`No` are declined. `displayText` always carries the rendered string verbatim. `onCellTypeInference` reports every decision. A formula is never claimed.
- **The csv read shares `pdfToOds`'s cell-typing heuristic, with the same decision-only audit channel.** The first record is a verbatim string header (never re-typed, even when it looks like data); data cells re-type through `inferCellValue` exactly as the PDF reconstructor does — declines keep the plain string, `displayText` always carries the raw field text, and `onCellTypeInference` fires per decision, staying silent for header cells and no-candidate text. The parser drops blank records, so a record of one empty field alone cannot round-trip. Writing csv takes exactly one sheet: a multi-sheet source refuses with `CsvSheetNotSpecifiedError` naming every sheet until `{ sheet }` selects one. TSV is not a separate format — `{ delimiter: '\t' }` on either side parses or writes the same grid.
+- **The svg read's scope limits are named diagnostics, never silent drops.** Text, images, `use` references, gradients/patterns, CSS style blocks, and out-of-scope opacity are each reported through `onSvgDiagnostic` with a code from `SVG_DIAGNOSTIC_CODES` (`svg/text-unsupported`, `svg/image-unsupported`, `svg/use-unsupported`, `svg/gradient-unsupported`, `svg/css-style-ignored`, `svg/opacity-ignored`, …) — the same contract as markdown's construct-mapping vocabulary. A plain vector SVG (the six shape primitives, transforms, paint) reads silently.
+- **An absent SVG fill paints black — the SVG spec default, and the one visible svg⇄odg asymmetry.** The svg reader turns a missing `fill` attribute into a black fill; the svg writer leaves the drawing frame's absent fill unset rather than second-guessing it. Round-tripping odg→svg→odg therefore converts an unfilled odg shape into a black-filled one, mirroring what a browser would render from the same markup.
+- **A rootless size falls back to the CSS default, and a stretched viewBox says so.** When neither `width`/`height` nor a `viewBox` is present, the read assumes the CSS default 300×150px viewport ({225, 112.5}pt) and reports `svg/default-size-assumed`; when `width`/`height` and the viewBox disagree in aspect ratio, the read maps through the stretched viewport and reports `svg/preserve-aspect-ratio-stretched` rather than silently re-proportioning the geometry.
+- **Writing svg takes exactly one page.** An svg is a single drawing, so a multi-page source refuses with `SvgMultiPageNotSpecifiedError` naming the page count until `{ page }` selects one (an index, because drawing pages are anonymous where csv's sheets are named — the same contract one variant over).
+- **svg→csv and svg→markdown honestly produce empty output.** The svg read has no text in scope, and neither csv nor markdown has a vocabulary for vectors, so the composition routes (via PDF into the spreadsheet/text readers) yield a document with nothing to emit — pinned as expected-empty in the round-trip matrix rather than dressed up as a conversion.
+- **A rotated rect or ellipse stays a frame, with `rotationDeg`.** The read composes the transform list into one 2×3 affine and classifies it: an axis-aligned map (any scale, mirrors included) folds into the frame; a similarity rotation keeps the frame and records `rotationDeg` about the frame's centre; a shear or rotation-composed non-uniform scale narrows to a path. The affine itself is exact in every case — only which container carries it changes.
+- **The path grammar's one approximation is the elliptical arc.** `A` converts endpoint-to-centre parameterisation exactly (F.6.5, with the F.6.5.6 radii correction), then approximates each arc segment with kappa-bounded cubics at ≤90° per cubic; S/Q/T convert exactly (a quadratic elevates to an exact cubic, T reflects the previous quadratic's own control).
- **`reconstructWordprocessing`/`reconstructPresentation` recover vector primitives too**, in a nested drawing document — a rule under a heading, an underline, a cell background are all recovered as vectors (intended — discarding real content because it might be incidental is ruled out). A table's gridlines are excluded from vector recovery when the lattice claims them.
-- **Recovered vectors round-trip through all four readers** — `buildDocxPackage`/`buildPptxPackage` write real DrawingML; `buildOdtPackage`/`buildOdpPackage` write real `draw:rect`/`draw:ellipse`/`draw:line`/`draw:path`. The six PDF-bypassing bridges carry vector geometry across too.
+- **Recovered vectors round-trip through all five vector-writing readers** — `buildDocxPackage`/`buildPptxPackage` write real DrawingML; `buildOdtPackage`/`buildOdpPackage` write real `draw:rect`/`draw:ellipse`/`draw:line`/`draw:path`; `buildSvgText` writes real SVG shape elements. The PDF-bypassing bridges between vector-carrying formats (odt⇄docx, odp⇄pptx, odt⇄odp, svg⇄odg) carry vector geometry across too.
- **Each format wraps a vector shape differently.** OOXML: pptx gets a plain `p:sp`; docx gets a `w:drawing`/`wp:anchor` with `behindDoc="1"`/`wp:wrapNone` carrying a `wps:wsp`. ODF: odp appends to `draw:page`; odt anchors in a `text:p` with `style:horizontal-rel`/`style:vertical-rel="page"` (page-absolute coordinates) and `style:run-through="background"`.
- **`ContentStroke.style` is not written by vector writers.** `LayoutLine`/`LayoutPath` carry the enum, but neither ODF nor DrawingML vector writers read it — a hand-built vector with `stroke.style` paints solid. Cell borders are a separate path that does set the style.
- **`pdfToOds` recovers what was printed, not what was entered.** `reconstructSpreadsheet` tries a real gridline lattice first (`MIN_GRIDLINE_COUNT_PER_AXIS = 3`), using line positions directly as cell boundaries; absent one, clusters text into a grid from geometry. Column widths/row heights are measured, never invented. No print range/scale/repeat-rows/manual-breaks are inferred.
@@ -623,21 +634,22 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
Read as **row → column**. `✓` lossless, `~` bounded, `✗` lossy, `✗✗` severe, `→` one-way, `–` no conversion. `.odm`/`.odb` sit outside this table.
-| ↓ from \ to → | docx | pptx | xlsx | odt | odp | ods | odg | odf | markdown | csv | pdf |
-| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
-| **docx** | — | ~ | – | ✓ | – | – | – | – | ✗ | ✗ | ~ |
-| **pptx** | ~ | — | – | – | ✓ | – | – | – | – | ✗ | ~ |
-| **xlsx** | – | – | — | – | – | ~ | – | – | ✗✗ | ~ | ~ |
-| **odt** | ✓ | – | – | — | ~ | – | – | – | ✗ | ✗ | ~ |
-| **odp** | – | ✓ | – | ~ | — | – | – | – | – | ✗ | ~ |
-| **ods** | – | – | ~ | – | – | — | – | – | – | ~ | ~ |
-| **odg** | – | – | – | – | – | – | — | – | – | ✗ | ~ |
-| **odf** | – | – | – | – | – | – | – | — | – | – | → |
-| **markdown** | ~ | – | ✗✗ | ~ | – | – | – | – | — | ✗✗ | ~ |
-| **csv** | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ | – | ✗✗ | — | ~ |
-| **pdf** | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | – | ✗✗ | ✗ | — |
-
-91 of 110 directional pairs are routable. The `ContentDocument`/`LayoutDocument` pivots are the hub, not PDF — eighteen bridges bypass PDF entirely.
+| ↓ from \ to → | docx | pptx | xlsx | odt | odp | ods | odg | svg | odf | markdown | csv | pdf |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| **docx** | — | ~ | – | ✓ | – | – | – | ✗ | – | ✗ | ✗ | ~ |
+| **pptx** | ~ | — | – | – | ✓ | – | – | ✗ | – | – | ✗ | ~ |
+| **xlsx** | – | – | — | – | – | ~ | – | ✗ | – | ✗✗ | ~ | ~ |
+| **odt** | ✓ | – | – | — | ~ | – | – | ✗ | – | ✗ | ✗ | ~ |
+| **odp** | – | ✓ | – | ~ | — | – | – | ✗ | – | – | ✗ | ~ |
+| **ods** | – | – | ~ | – | – | — | – | ✗ | – | – | ~ | ~ |
+| **odg** | – | – | – | – | – | – | — | ✓ | – | – | ✗ | ~ |
+| **svg** | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | — | – | ✗✗ | ✗✗ | ~ |
+| **odf** | – | – | – | – | – | – | – | – | — | – | – | → |
+| **markdown** | ~ | – | ✗✗ | ~ | – | – | – | ✗✗ | – | — | ✗✗ | ~ |
+| **csv** | ✗ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ | – | ✗✗ | — | ~ |
+| **pdf** | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | – | ✗✗ | ✗ | — |
+
+111 of 132 directional pairs are routable. The `ContentDocument`/`LayoutDocument` pivots are the hub, not PDF — twenty bridges bypass PDF entirely.
**X → PDF** is a genuine layout render: positioned text, images, tables, lists, vector primitives, styled through the full cascade. It is a faithful visual approximation, not pixel-identical — closeness depends on font availability.
@@ -647,11 +659,13 @@ Read as **row → column**. `✓` lossless, `~` bounded, `✗` lossy, `✗✗` s
**PDF → odg** is near-1:1 mapping (no clustering needed). Kind narrows upstream: rotated rects, freeform curves, multi-subpath figures become `path`.
+**svg ⇄ PDF and PDF → svg** lay out through the same drawing engine odg feeds, so `svgToPdf` is bounded only by the svg read's documented scope; `pdfToSvg` reuses `pdfToOdg`'s near-1:1 vector recovery writing SVG shape elements instead. Recovered text boxes sit outside the svg writer's vector-only scope — reported per shape via `onSvgDiagnostic`, never silently dropped — and svg→csv/svg→markdown honestly produce empty output (no text in the read's scope, no vector vocabulary in the target).
+
**PDF → ods** recovers what was printed, not what was entered. The printed string always survives in `displayText`; re-typed `value` is explicitly probabilistic inference.
**`markdownToPdf`/`pdfToMarkdown`** is the lossiest round trip: `markdownToPdf` is faithful, but `pdfToMarkdown` stacks reconstruction lossiness PLUS markdown's coarser vocabulary (no colour, font, size, alignment). The PDF-composed markdown bridges (`xlsxToMarkdown`/`markdownToXlsx`, `csvToMarkdown`/`markdownToCsv`) stack the same two losses in both directions — hence their `✗✗` cells.
-**The five same-variant bridge pairs** (odt⇄docx, odp⇄pptx, ods⇄xlsx, csv⇄ods, csv⇄xlsx) bypass PDF entirely — no layout engine, no reconstruction. Text, styling, tables, lists, rotated shapes survive completely. `ods⇄xlsx` has small format-boundary limits (time cells, formula dialects). Embedded formulas survive `odtToDocx` as real OOXML math. The csv pairs are bounded by what csv itself carries: toward ods/xlsx nothing the csv had is lost, while writing to csv collapses each cell to its `displayText` — formulas become their rendered values, formatting disappears, and a multi-sheet source must name the sheet it wants.
+**The six same-variant bridge pairs** (odt⇄docx, odp⇄pptx, ods⇄xlsx, csv⇄ods, csv⇄xlsx, svg⇄odg) bypass PDF entirely — no layout engine, no reconstruction. Text, styling, tables, lists, rotated shapes survive completely. `ods⇄xlsx` has small format-boundary limits (time cells, formula dialects). Embedded formulas survive `odtToDocx` as real OOXML math. The csv pairs are bounded by what csv itself carries: toward ods/xlsx nothing the csv had is lost, while writing to csv collapses each cell to its `displayText` — formulas become their rendered values, formatting disappears, and a multi-sheet source must name the sheet it wants. The svg pair carries the six vector primitives losslessly in both directions; its one asymmetry is paint defaults — SVG's absent-fill-is-black versus a drawing frame's no-fill.
**The two markdown bridge pairs** bypass PDF too, but markdown's grammar has no construct for colour/font/size/alignment — `docxToMarkdown`/`odtToMarkdown` drop them (format-boundary loss, not approximation).
diff --git a/src/codecs/registry.ts b/src/codecs/registry.ts
index cb090740..2a2b6a34 100644
--- a/src/codecs/registry.ts
+++ b/src/codecs/registry.ts
@@ -15,6 +15,9 @@ import { buildMarkdownText } from '../markdown/write';
import { decodeCsvText, encodeCsvText } from '../csv/text';
import { readCsvContent } from '../csv/read';
import { buildCsvText } from '../csv/write';
+import { decodeSvgText, encodeSvgText } from '../svg/text';
+import { readSvgContent } from '../svg/read';
+import { buildSvgText } from '../svg/write';
import { readOdfFormulaContent } from '../odf/formula/read';
import { readOdgContent } from '../odf/odg/read';
import { readOdpContent } from '../odf/odp/read';
@@ -126,6 +129,13 @@ export const DOCUMENT_FORMAT_CODECS: Readonly encodeCsvText(buildCsvText(content)),
},
},
+ // The svg entry is the csv entry's structural twin: decode straight from bytes to text (no package), read into a drawing ContentDocument, write the reverse. DocumentCodecOptions carries no page/onSvgDiagnostic, so this codec writes page 0 of a drawing with no diagnostic channel -- a caller wanting a different page of a multi-page document or the reader's scope-limit diagnostics uses the named conversions (convert.ts's svgToPdf/odgToSvg and their kin), which thread { page, onSvgDiagnostic } through UnifiedConversionOptions; a multi-page write through THIS codec throws buildSvgText's own SvgMultiPageNotSpecifiedError rather than silently truncating. decodeSvgText is a fatal decoder with no loop of its own, so no separate signal check is needed -- the same reasoning as the csv entry beside it.
+ svg: {
+ content: {
+ read: (bytes) => readSvgContent(decodeSvgText(bytes)),
+ write: (content) => encodeSvgText(buildSvgText(content)),
+ },
+ },
pdf: {
layout: {
read: (bytes, options) => readPdf(requireArrayBufferBytes(bytes), { signal: options?.signal }),
diff --git a/src/convert/capability.test.ts b/src/convert/capability.test.ts
index c125756b..84554f49 100644
--- a/src/convert/capability.test.ts
+++ b/src/convert/capability.test.ts
@@ -18,7 +18,7 @@ describe('FORMAT_CAPABILITIES', () => {
expect(new Set(byVariant.get('wordprocessing'))).toEqual(new Set(['docx', 'odt', 'markdown']));
expect(new Set(byVariant.get('presentation'))).toEqual(new Set(['pptx', 'odp']));
expect(new Set(byVariant.get('spreadsheet'))).toEqual(new Set(['xlsx', 'ods', 'csv']));
- expect(new Set(byVariant.get('drawing'))).toEqual(new Set(['odg']));
+ expect(new Set(byVariant.get('drawing'))).toEqual(new Set(['odg', 'svg']));
});
it('marks xlsx and csv as the spreadsheet members with no layout path of their own (ods carries the layout edge)', () => {
@@ -30,6 +30,11 @@ describe('FORMAT_CAPABILITIES', () => {
expect(FORMAT_CAPABILITIES.ods.hasLayoutPath).toBe(true);
});
+ it('marks svg as the drawing family\'s plain-text member with its own layout path (a sibling of odg, not an ods-style composed member)', () => {
+ expect(FORMAT_CAPABILITIES.svg.variant).toBe('drawing');
+ expect(FORMAT_CAPABILITIES.svg.hasLayoutPath).toBe(true);
+ });
+
it('has no undefined-variant format other than pdf and odf reporting a layout path', () => {
for (const capability of Object.values(FORMAT_CAPABILITIES)) {
if (capability.variant === undefined) {
@@ -104,6 +109,33 @@ describe('resolveCompositionPlan', () => {
expect(plan!.hops.map((h) => h.executor)).toEqual(['fromPdf', 'bridge']);
});
+ it('routes svg -> pdf as a single toPdf hop, since svg rides the drawing layout engine odg feeds', () => {
+ const plan = resolveCompositionPlan('svg', 'pdf');
+ expect(plan).toBeDefined();
+ expect(plan!.hops).toHaveLength(1);
+ expect(plan!.hops[0]!.executor).toBe('toPdf');
+ expect(plan!.hops[0]!.from).toBe('svg');
+ expect(plan!.hops[0]!.to).toBe('pdf');
+ });
+
+ it('routes pdf -> svg as a single fromPdf hop', () => {
+ const plan = resolveCompositionPlan('pdf', 'svg');
+ expect(plan).toBeDefined();
+ expect(plan!.hops).toHaveLength(1);
+ expect(plan!.hops[0]!.executor).toBe('fromPdf');
+ expect(plan!.hops[0]!.from).toBe('pdf');
+ expect(plan!.hops[0]!.to).toBe('svg');
+ });
+
+ it('routes svg -> odg as a single same-variant bridge hop (the drawing family\'s plain-text member)', () => {
+ const plan = resolveCompositionPlan('svg', 'odg');
+ expect(plan).toBeDefined();
+ expect(plan!.hops).toHaveLength(1);
+ expect(plan!.hops[0]!.executor).toBe('bridge');
+ expect(plan!.hops[0]!.from).toBe('svg');
+ expect(plan!.hops[0]!.to).toBe('odg');
+ });
+
it('composes csv -> markdown through ods and pdf (three hops), mirroring the xlsx -> markdown last-resort route', () => {
const plan = resolveCompositionPlan('csv', 'markdown');
expect(plan).toBeDefined();
diff --git a/src/convert/capability.ts b/src/convert/capability.ts
index ff81a7f3..6b792dc5 100644
--- a/src/convert/capability.ts
+++ b/src/convert/capability.ts
@@ -1,6 +1,6 @@
import type { DocumentFormat } from './port';
-// This module models the real ContentDocument-variant compatibility this family already has (wordprocessing = {docx, odt, markdown}, presentation = {pptx, odp}, spreadsheet = {xlsx, ods, csv}, drawing = {odg alone}) plus which nodes have a direct layout-engine path to/from LayoutDocument (FORMAT_CAPABILITIES below). The composition engine (src/convert/composition.ts) consumes FORMAT_CAPABILITIES' variant declarations to build its composition graph, and UnsupportedConversionError is thrown by convertDocument/local.ts for any pair the pathfinder cannot route. The former DIRECT_EDGES list and resolveConversionPath resolver have been superseded by the pathfinder (resolveCompositionPlan in composition.ts), which derives every resolvable pair from the same registry data.
+// This module models the real ContentDocument-variant compatibility this family already has (wordprocessing = {docx, odt, markdown}, presentation = {pptx, odp}, spreadsheet = {xlsx, ods, csv}, drawing = {odg, svg}) plus which nodes have a direct layout-engine path to/from LayoutDocument (FORMAT_CAPABILITIES below). The composition engine (src/convert/composition.ts) consumes FORMAT_CAPABILITIES' variant declarations to build its composition graph, and UnsupportedConversionError is thrown by convertDocument/local.ts for any pair the pathfinder cannot route. The former DIRECT_EDGES list and resolveConversionPath resolver have been superseded by the pathfinder (resolveCompositionPlan in composition.ts), which derives every resolvable pair from the same registry data.
// All five of document-schema.js's own ContentDocument kinds. 'formula' is a genuine member rather than a forward-looking one: readOdfFormulaContent produces a real `{kind:'formula', ...}` ContentDocument and odfToPdf consumes one, so `odf` below models it. Unlike the other four, it is a variant of exactly ONE format -- there is no second 'formula'-variant format to bridge it to, which is why a shared variant does not by itself imply a bridge edge exists.
export type ContentVariant = 'wordprocessing' | 'presentation' | 'spreadsheet' | 'drawing' | 'formula';
@@ -24,6 +24,8 @@ export const FORMAT_CAPABILITIES: Readonly pdf goes through the ods bridge + ods's layout engine. TSV is this same member with { delimiter: '\t' }, not a separate format -- see port.ts's own csv comment.
csv: { format: 'csv', variant: 'spreadsheet', hasLayoutPath: false },
odg: { format: 'odg', variant: 'drawing', hasLayoutPath: true },
+ // svg shares the drawing ContentDocument variant with odg (readSvgContent/buildSvgText, src/svg/) and has a genuine layout-engine edge of its own: svgToPdf feeds the drawing ContentDocument it reads straight into the same convertDrawingToLayout engine odgToPdf already uses, so hasLayoutPath is true -- unlike csv's text-only entry, plain SVG text still describes real page geometry (a root viewBox is a page size), and the drawing layout engine renders it.
+ svg: { format: 'svg', variant: 'drawing', hasLayoutPath: true },
// odf reads into the 'formula' ContentDocument variant (readOdfFormulaContent), but hasLayoutPath stays false: odfToPdf renders its formula through writePdf's own separate formula positioning rather than a ContentDocument -> LayoutDocument layout engine, and there is no reverse pdf -> odf at all (see odfToPdf's own module comment in convert.ts).
odf: { format: 'odf', variant: 'formula', hasLayoutPath: false },
// markdown shares the wordprocessing variant with docx/odt (readMarkdownContent produces the identical WordprocessingContentDocument shape -- see convert.ts's own top-of-file comment) and has a genuine layout-engine edge of its own (markdownToPdf/pdfToMarkdown both reuse convertWordprocessingToLayout/reconstructWordprocessing unmodified), unlike xlsx above.
diff --git a/src/convert/codec.ts b/src/convert/codec.ts
index 9e6f4089..e041cd44 100644
--- a/src/convert/codec.ts
+++ b/src/convert/codec.ts
@@ -1,5 +1,5 @@
import { z } from 'zod';
-import { CsvBytesSchema, DocxBytesSchema, MarkdownBytesSchema, OdgBytesSchema, OdpBytesSchema, OdsBytesSchema, OdtBytesSchema, PdfBytesSchema, PptxBytesSchema, XlsxBytesSchema } from '../model/bytes';
+import { CsvBytesSchema, DocxBytesSchema, MarkdownBytesSchema, OdgBytesSchema, OdpBytesSchema, OdsBytesSchema, OdtBytesSchema, PdfBytesSchema, PptxBytesSchema, SvgBytesSchema, XlsxBytesSchema } from '../model/bytes';
import {
csvToMarkdown,
csvToOds,
@@ -13,6 +13,7 @@ import {
markdownToOdt,
markdownToPdf,
odgToPdf,
+ odgToSvg,
odpToPdf,
odpToPptx,
odsToCsv,
@@ -29,9 +30,12 @@ import {
pdfToOds,
pdfToOdt,
pdfToPptx,
+ pdfToSvg,
pdfToXlsx,
pptxToOdp,
pptxToPdf,
+ svgToOdg,
+ svgToPdf,
xlsxToCsv,
xlsxToOds,
xlsxToPdf,
@@ -82,6 +86,12 @@ export const markdownPdfCodec = z.codec(MarkdownBytesSchema, PdfBytesSchema, {
encode: (pdfBytes) => pdfToMarkdown(pdfBytes),
});
+// svg bytes <-> PDF bytes: a schema-validated z.codec() pair over svgToPdf/pdfToSvg (convert.ts), which -- like markdownPdfCodec above and unlike csvPdfCodec/xlsxPdfCodec below -- DOES lay its source out directly (svgToPdf feeds the drawing ContentDocument readSvgContent produced into the same convertDrawingToLayout engine odgPdfCodec feeds). Decode-side lossiness is the reader's documented scope limits (text, gradients, filters, images, CSS, and degrade under diagnostics, never silently); encode-side is reconstructDrawing's near-1:1 mapping with the kind-narrowing every pdf-to-content recovery performs. Still the no-options form only: svgToPdf accepts onSvgDiagnostic and pdfToSvg accepts page/onSvgDiagnostic, neither of which z.codec()'s fixed decode(input)/encode(output) signature has room for -- and the encode leg WILL throw SvgMultiPageNotSpecifiedError on a multi-page PDF, since page selection is exactly such an option.
+export const svgPdfCodec = z.codec(SvgBytesSchema, PdfBytesSchema, {
+ decode: (svgBytes) => svgToPdf(svgBytes),
+ encode: (pdfBytes) => pdfToSvg(pdfBytes),
+});
+
// odt bytes <-> docx bytes, odp bytes <-> pptx bytes, ods bytes <-> xlsx bytes, markdown bytes <-> docx bytes, and markdown bytes <-> odt bytes: schema-validated z.codec() pairs over the cross-format bridge functions (convert.ts), which unlike every PDF-pivot codec above bypass PDF entirely -- see convert.ts's own module comment on that section. The blanket "not round-trip-lossless doesn't apply to these" claim this comment used to make here is genuinely false for the two markdown pairs below, and is now stated precisely rather than glossed over: odtDocxCodec/odpPptxCodec/odsXlsxCodec decode/encode a direct ContentDocument pivot copy with no layout or reconstruction step, so those three really do carry no PDF-pivot-style lossiness of their own -- but markdownDocxCodec/markdownOdtCodec still lose everything CommonMark/GFM itself cannot represent (colour, font family/size, explicit alignment, page geometry) on the DECODE side (markdown -> docx/odt), simply because that information was never in the markdown source to begin with; ENCODE (docx/odt -> markdown) then discards it a second time on the way back down, same as it always would. That is not the PDF-pivot's geometry-based reconstruction lossiness -- there is still no layout engine and no geometric guessing anywhere in either direction -- but it is real, format-boundary lossiness all the same, and pretending otherwise here would misdescribe what markdownDocxCodec/markdownOdtCodec actually preserve. Still the no-options form only, for the same reason as above: odtToDocx et al. (and now markdownToDocx et al.) accept a signal option z.codec()'s fixed decode(input)/encode(output) signature has no room for.
export const odtDocxCodec = z.codec(OdtBytesSchema, DocxBytesSchema, {
decode: (odtBytes) => odtToDocx(odtBytes),
@@ -131,6 +141,12 @@ export const xlsxCsvCodec = z.codec(XlsxBytesSchema, CsvBytesSchema, {
encode: (csvBytes) => csvToXlsx(csvBytes),
});
+// odg bytes <-> svg bytes: a schema-validated z.codec() pair over the same-variant drawing bridge (convert.ts) -- a direct ContentDocument pivot copy with no layout engine and no reconstruction, exactly like odsXlsxCodec above. Both sides speak the identical six-primitive ContentVector vocabulary, so geometry crosses losslessly; the one boundary is paint defaults (an SVG shape with no fill attribute reads as black-filled, the SVG specification's own default). The no-options encode leg throws SvgMultiPageNotSpecifiedError on a multi-page odg rather than silently truncating -- the identical contract the registry codec (src/codecs/registry.ts) documents for its own svg write.
+export const odgSvgCodec = z.codec(OdgBytesSchema, SvgBytesSchema, {
+ decode: (odgBytes) => odgToSvg(odgBytes),
+ encode: (svgBytes) => svgToOdg(svgBytes),
+});
+
// csv bytes <-> markdown bytes: the no-options form over the two pdf-composed bridge functions (convert.ts), routing csv -> ods -> pdf -> markdown and markdown -> pdf -> ods -> csv. Lossy in the same stacked way as xlsxMarkdownCodec above -- the spreadsheet render and the markdown reconstruction each add their own loss -- with csv read's heuristic re-typing on top on the decode side.
export const csvMarkdownCodec = z.codec(CsvBytesSchema, MarkdownBytesSchema, {
decode: (csvBytes) => csvToMarkdown(csvBytes),
diff --git a/src/convert/composition.ts b/src/convert/composition.ts
index 6a297259..1164d79d 100644
--- a/src/convert/composition.ts
+++ b/src/convert/composition.ts
@@ -26,6 +26,10 @@ import { readMarkdownContent } from '../markdown/read';
import { decodeCsvText, encodeCsvText } from '../csv/text';
import { readCsvContent } from '../csv/read';
import { buildCsvText } from '../csv/write';
+import type { SvgDiagnosticSink } from '../svg/diagnostics';
+import { decodeSvgText, encodeSvgText } from '../svg/text';
+import { readSvgContent } from '../svg/read';
+import { buildSvgText } from '../svg/write';
import type { CellTypeInferenceSink } from '../layout/cell-typing';
import { convertDrawingToLayout } from '../layout/drawing';
import { convertWordprocessingToLayout } from '../layout/engine';
@@ -57,20 +61,22 @@ export interface UnifiedConversionOptions {
readonly sink?: PdfDiagnosticSink;
readonly onMathDiagnostic?: (diagnostic: OmmlDiagnostic, context: { readonly sourcePath?: string }) => void;
readonly images?: MarkdownImageResolver;
- // The csv hops' own knobs, on the shared shape for the same reason onMathDiagnostic (docx/pptx only) and images (markdown only) already sit here: the pathfinder picks the hops, so per-format options must ride the one options object every hop receives, and each hop's registry closure reads only the fields it knows -- csv read consumes delimiter/onCellTypeInference, csv build consumes delimiter/sheet, every non-csv hop ignores all three.
+ // The csv hops' own knobs, on the shared shape for the same reason onMathDiagnostic (docx/pptx only) and images (markdown only) already sit here: the pathfinder picks the hops, so per-format options must ride the one options object every hop receives, and each hop's registry closure reads only the fields it knows -- csv read consumes delimiter/onCellTypeInference, csv build consumes delimiter/sheet, every non-csv hop ignores all three. The svg row is the drawing-family counterpart: svg read consumes onSvgDiagnostic (the reader's scope-limit/degrade channel), svg build consumes page/onSvgDiagnostic, every non-svg hop ignores both.
readonly delimiter?: string;
readonly sheet?: string;
readonly onCellTypeInference?: CellTypeInferenceSink;
+ readonly page?: number;
+ readonly onSvgDiagnostic?: SvgDiagnosticSink;
readonly clock?: ClockPort;
}
// --- Registry: declarative per-format primitive wiring -----------------------------------------
-// The nine content formats this engine routes between (pdf is the layout pivot, reached via toPdf/fromPdf edges; odf is special, excluded entirely -- see the module doc).
-export type ContentFormat = 'docx' | 'pptx' | 'xlsx' | 'odt' | 'odp' | 'ods' | 'odg' | 'csv' | 'markdown';
+// The ten content formats this engine routes between (pdf is the layout pivot, reached via toPdf/fromPdf edges; odf is special, excluded entirely -- see the module doc).
+export type ContentFormat = 'docx' | 'pptx' | 'xlsx' | 'odt' | 'odp' | 'ods' | 'odg' | 'svg' | 'csv' | 'markdown';
// The explicit, typed list of content formats, kept in sync with FORMAT_NODES' own keys. Used for iteration in the graph builder in place of `Object.keys(FORMAT_NODES)` (which returns `string[]` and would need a cast back to ContentFormat), so the registry stays cast-free end to end.
-const CONTENT_FORMATS: readonly ContentFormat[] = ['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'csv', 'markdown'];
+const CONTENT_FORMATS: readonly ContentFormat[] = ['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'svg', 'csv', 'markdown'];
// The four ContentDocument variants a layout engine exists for. 'formula' is the fifth ContentVariant member but has no layout engine of its own (odfToPdf renders through writePdf's formula positioning, not a ContentDocument -> LayoutDocument pass), so it is excluded from this engine's layout/reconstruct registries.
type LayoutVariant = Exclude;
@@ -86,10 +92,10 @@ interface PackageFormatNode {
readonly hasSourcePackage: true;
}
-// The plain-text half of the union: markdown and csv both decode straight from bytes to a string and read/build through their own text-level codecs -- no zip package, no font embedding, no source-package concept at all. family names the text dialect so a format can never be a member of both halves. build takes options because csv's build consumes { delimiter, sheet } from UnifiedConversionOptions; markdown's build ignores them.
+// The plain-text half of the union: markdown, csv, and svg all decode straight from bytes to a string and read/build through their own text-level codecs -- no zip package, no font embedding, no source-package concept at all. family names the text dialect so a format can never be a member of both halves. build takes options because csv's build consumes { delimiter, sheet } and svg's build consumes { page, onSvgDiagnostic } from UnifiedConversionOptions; markdown's build ignores them.
interface TextFormatNode {
readonly variant: LayoutVariant;
- readonly family: 'markdown' | 'csv';
+ readonly family: 'markdown' | 'csv' | 'svg';
readonly decode: (bytes: Uint8Array) => string;
readonly read: (text: string, options?: UnifiedConversionOptions) => ContentDocument;
readonly build: (content: ContentDocument, options?: UnifiedConversionOptions) => string;
@@ -104,7 +110,7 @@ function isTextFormatNode(node: FormatNode): node is TextFormatNode {
return !node.hasSourcePackage;
}
-// The single source of truth for "which primitives does each format use". read/build closures thread their own per-format option subset internally: docx and pptx read/build both pull onMathDiagnostic (mirroring readDocxContent's/readPptxContent's own `{ onMathDiagnostic }` and buildDocxPackage's/buildPptxPackage's own option -- ExaDev/documents.js#563 gave pptx the identical OMML degrade-diagnostic channel docx already had), markdown read pulls signal/images (mirroring readMarkdownContent's ReadMarkdownOptions), csv read pulls delimiter/onCellTypeInference and csv build pulls delimiter/sheet (mirroring readCsvContent's ReadCsvContentOptions and buildCsvText's BuildCsvTextOptions), and every other format's read/build accept and ignore the thread. docxToPdf's openDocx(bytes).toPackage() and decodeOoxmlPackage(bytes) produce the identical Package (openDocx wraps decodeOoxmlPackage and toPackage returns it unmutated), so decode uses the package codec directly for uniformity -- byte-identical to docxToPdf at every downstream call site.
+// The single source of truth for "which primitives does each format use". read/build closures thread their own per-format option subset internally: docx and pptx read/build both pull onMathDiagnostic (mirroring readDocxContent's/readPptxContent's own `{ onMathDiagnostic }` and buildDocxPackage's/buildPptxPackage's own option -- ExaDev/documents.js#563 gave pptx the identical OMML degrade-diagnostic channel docx already had), markdown read pulls signal/images (mirroring readMarkdownContent's ReadMarkdownOptions), csv read pulls delimiter/onCellTypeInference and csv build pulls delimiter/sheet (mirroring readCsvContent's ReadCsvContentOptions and buildCsvText's BuildCsvTextOptions), svg read pulls onSvgDiagnostic and svg build pulls page/onSvgDiagnostic (mirroring readSvgContent's ReadSvgContentOptions and buildSvgText's BuildSvgTextOptions), and every other format's read/build accept and ignore the thread. docxToPdf's openDocx(bytes).toPackage() and decodeOoxmlPackage(bytes) produce the identical Package (openDocx wraps decodeOoxmlPackage and toPackage returns it unmutated), so decode uses the package codec directly for uniformity -- byte-identical to docxToPdf at every downstream call site.
export const FORMAT_NODES: Readonly> = {
docx: {
variant: 'wordprocessing',
@@ -169,6 +175,16 @@ export const FORMAT_NODES: Readonly> = {
encode: (pkg) => encodeOdfPackage(pkg),
hasSourcePackage: true,
},
+ // svg reads into the same drawing ContentDocument variant odg does, so the two form a same-variant bridge pair (cost 1) and svg additionally rides the drawing layout engine through its own toPdf/fromPdf edges -- a text format with a genuine layout path, the one combination csv's entry does not have. read pulls onSvgDiagnostic and build pulls page/onSvgDiagnostic (mirroring readSvgContent's ReadSvgContentOptions and buildSvgText's BuildSvgTextOptions, src/svg/), so a multi-page drawing reached through the build leg throws SvgMultiPageNotSpecifiedError exactly as a direct buildSvgText call would until a caller selects a page.
+ svg: {
+ variant: 'drawing',
+ family: 'svg',
+ decode: (bytes) => decodeSvgText(bytes),
+ read: (text, options) => readSvgContent(text, { onSvgDiagnostic: options?.onSvgDiagnostic }),
+ build: (content, options) => buildSvgText(content, { page: options?.page, onSvgDiagnostic: options?.onSvgDiagnostic }),
+ encode: (text) => encodeSvgText(text),
+ hasSourcePackage: false,
+ },
markdown: {
variant: 'wordprocessing',
family: 'markdown',
@@ -189,8 +205,8 @@ export const FORMAT_NODES: Readonly> = {
},
};
-// The formats that have a direct layout-engine path to/from PDF (convertXToLayout + writePdf). xlsx and csv are deliberately absent: neither has a layout engine of its own, so the pathfinder routes each <-> pdf through ods instead (e.g. csv -> ods bridge, then ods -> pdf toPdf), reproducing the composed route xlsxToPdf/pdfToXlsx already hard-code in convert.ts.
-const LAYOUT_CAPABLE: ReadonlySet = new Set(['docx', 'pptx', 'odt', 'odp', 'ods', 'odg', 'markdown']);
+// The formats that have a direct layout-engine path to/from PDF (convertXToLayout + writePdf). xlsx and csv are deliberately absent: neither has a layout engine of its own, so the pathfinder routes each <-> pdf through ods instead (e.g. csv -> ods bridge, then ods -> pdf toPdf), reproducing the composed route xlsxToPdf/pdfToXlsx already hard-code in convert.ts. svg is present: its read half produces a drawing ContentDocument whose page geometry comes from the svg root's own viewBox/width/height, and convertDrawingToLayout renders it unmodified.
+const LAYOUT_CAPABLE: ReadonlySet = new Set(['docx', 'pptx', 'odt', 'odp', 'ods', 'odg', 'svg', 'markdown']);
// Cross-variant transforms keyed by `${fromVariant}->${toVariant}`. Each wrapper narrows its input with a runtime kind guard so the underlying transform receives its exact concrete variant type -- the same "no cast, narrow at the boundary" discipline every read/build closure above follows. Today wordprocessing <-> presentation and drawing <-> presentation transforms exist (src/convert/variant-bridges.ts); the pathfinder derives its cross-variant edges from this object's keys, so adding a transform here is the single change needed to teach both the pathfinder and the bridge executor a new variant crossing.
const TRANSFORMS: Readonly ContentDocument>> = {
@@ -445,7 +461,7 @@ function buildCompositionGraph(): ReadonlyMap = buildCompositionGraph();
-// Standard Dijkstra over the small (<= 10-node) composition graph. Returns the ordered node path from source to target, or undefined if source === target or target is unreachable.
+// Standard Dijkstra over the small (<= 11-node) composition graph. Returns the ordered node path from source to target, or undefined if source === target or target is unreachable.
function shortestPath(source: DocumentFormat, target: DocumentFormat): readonly DocumentFormat[] | undefined {
if (source === target) {
return undefined;
@@ -513,7 +529,7 @@ export function resolveCompositionPlan(source: DocumentFormat, target: DocumentF
return { hops };
}
-// Narrows a DocumentFormat to the ContentFormat union (the nine formats with a FORMAT_NODES entry). pdf and odf are excluded: pdf is the layout pivot reached only via toPdf/fromPdf edges, and odf is the special-case format this engine does not route at all. Used by convertDocument to narrow a hop's DocumentFormat endpoints to the ContentFormat the executors are typed against.
+// Narrows a DocumentFormat to the ContentFormat union (the ten formats with a FORMAT_NODES entry). pdf and odf are excluded: pdf is the layout pivot reached only via toPdf/fromPdf edges, and odf is the special-case format this engine does not route at all. Used by convertDocument to narrow a hop's DocumentFormat endpoints to the ContentFormat the executors are typed against.
function isContentFormat(format: DocumentFormat): format is ContentFormat {
return format !== 'pdf' && format !== 'odf';
}
diff --git a/src/convert/convert.ts b/src/convert/convert.ts
index d10c5af8..0ad79e19 100644
--- a/src/convert/convert.ts
+++ b/src/convert/convert.ts
@@ -31,8 +31,9 @@ import { resolveMetadataTimestamps } from '../model/metadata';
import type { ClockPort } from '../ports/clock';
import { convertDocument } from './composition';
import type { CellTypeInferenceSink } from '../layout/cell-typing';
+import type { SvgDiagnosticSink } from '../svg/diagnostics';
-// The ergonomic X <-> PDF conversions (docx/pptx/odt/odp/ods/odg/markdown/csv, all round-trip both ways). Each is a thin forwarder to convertDocument (src/convert/composition.ts), which resolves the composition plan for the pair and runs the real decode/read/layout/build/encode primitives -- reproducing the exact sequence and option-threading the hand-written body below used to inline. The four special cases further down this file (odfToPdf, odmToPdf, odbToXlsx, odbToCsv) stay as their real hand-written bodies, since none is a composition-graph pair (formula one-way, resolver callback, table extraction).
+// The ergonomic X <-> PDF conversions (docx/pptx/odt/odp/ods/odg/markdown/csv/svg, all round-trip both ways). Each is a thin forwarder to convertDocument (src/convert/composition.ts), which resolves the composition plan for the pair and runs the real decode/read/layout/build/encode primitives -- reproducing the exact sequence and option-threading the hand-written body below used to inline. The four special cases further down this file (odfToPdf, odmToPdf, odbToXlsx, odbToCsv) stay as their real hand-written bodies, since none is a composition-graph pair (formula one-way, resolver callback, table extraction).
// `fonts` and `onFontSubstitution` are inherited from DocumentFontRegistryOptions (src/fonts/registry.ts) rather than redeclared here, so the ergonomic conversions and createDocumentFontRegistry describe the same two options in exactly one place. Every X-to-PDF conversion below builds a real FontRegistry from the SOURCE PACKAGE'S OWN embedded faces first, then those caller-supplied faces, then pdf-codec's vendored Carlito/Caladea substitutes, then the standard 14 -- which is why a document that embeds nothing, and asks for no family a vendored substitute covers, still writes byte-identical output to the standard-font-only pipeline this package had before (see convert-fonts.test.ts's own byte-identity proof).
export interface DocumentToPdfOptions extends DocumentFontRegistryOptions {
@@ -99,11 +100,29 @@ export interface CsvWriteOptions {
readonly sheet?: string;
}
+// The two svg option groups the named svg conversions below intersect into the shared options type each uses, exactly the way CsvReadOptions/CsvWriteOptions above already do for csv: every svg-SOURCED conversion reads with { onSvgDiagnostic } (readSvgContent's own option, src/svg/read.ts) and every svg-TARGET one writes with { page, onSvgDiagnostic } (buildSvgText's own two, src/svg/write.ts).
+export interface SvgReadOptions {
+ // Called once per scope limit or bounded approximation the svg reader made while mapping SVG markup onto ContentVectors (src/svg/diagnostics.ts's own vocabulary) -- the audit channel for every degrade this reader performs, reported at the read boundary where it happens.
+ readonly onSvgDiagnostic?: SvgDiagnosticSink;
+}
+
+export interface SvgWriteOptions {
+ // Selects which page of a multi-page drawing document to write as svg. Required whenever the document has more than one page -- omitting it then throws SvgMultiPageNotSpecifiedError, rather than guessing (the same contract CsvWriteOptions.sheet holds for sheets, carried by index here because drawing pages are anonymous). May be omitted when the document has exactly one page.
+ readonly page?: number;
+ // Called once per construct the svg writer could not express in SVG markup and skipped or degraded (a ContentShape, a 'double' stroke style) -- the write-side mirror of SvgReadOptions.onSvgDiagnostic, sharing the same diagnostic vocabulary.
+ readonly onSvgDiagnostic?: SvgDiagnosticSink;
+}
+
// csv bytes -> PDF bytes: csv has no layout engine of its own (exactly like xlsx), so convertDocument's pathfinder resolves this as [csv -> ods bridge, ods -> pdf toPdf] -- the reader parses RFC 4180 text into a spreadsheet ContentDocument (first record as the header row, data cells re-typed by inferCellValue with onCellTypeInference auditing each decision), then ods's own layout engine renders it. `onDocument` reports the last hop's package under the composition engine's own "fires exactly once, on the last hop" convention: the odsToPdf hop's content+layout.
export function csvToPdf(bytes: Uint8Array, options?: DocumentToPdfOptions & CsvReadOptions): Uint8Array {
return convertDocument('csv', 'pdf', bytes, options);
}
+// svg bytes -> PDF bytes: svg HAS a layout engine path of its own (unlike csv/xlsx) -- convertDocument resolves this as a single [svg -> pdf toPdf] hop, readSvgContent mapping the six shape primitives onto a drawing ContentDocument and the same convertDrawingToLayout engine odgToPdf feeds rendering it. onSvgDiagnostic is the reader's scope-limit channel (text/gradients/images/CSS/use degrade under it, never silently); fonts/onFontSubstitution are accepted through the shared DocumentToPdfOptions and consulted for the drawing engine's shape-text pass exactly as they are for odg.
+export function svgToPdf(bytes: Uint8Array, options?: DocumentToPdfOptions & SvgReadOptions): Uint8Array {
+ return convertDocument('svg', 'pdf', bytes, options);
+}
+
const STANDALONE_FORMULA_SIZE_PT = 18; // larger than a typical embedded formula (see engine.ts's own formulaSizePtForFrame), since a standalone .odf's own formula is usually the whole document's content, not a small inline element.
const STANDALONE_FORMULA_MARGIN_PT = 72; // 1 inch
@@ -185,7 +204,12 @@ export function pdfToCsv(bytes: Uint8Array, options?: PdfToDocument
return convertDocument('pdf', 'csv', bytes, options);
}
-// The same-variant cross-format bridges (odt<->docx, odp<->pptx, ods<->xlsx, csv<->ods, csv<->xlsx, and -- further down this section -- markdown<->docx, markdown<->odt), each bypassing PDF entirely. Every conversion above this point pivots through a LayoutDocument; these pairs don't have that problem: both formats in each pair already read into and build from the identical ContentDocument variant, so the bridge is nothing more than reader -> writer, with no layout engine, no font measurement, and no geometry-based reconstruction in between. Each forwarder below hands the pair to convertDocument (src/convert/composition.ts), whose pathfinder resolves it as a single same-variant bridge hop and runs the identical decode/read/build/encode sequence.
+// pdf bytes -> svg bytes: the reverse of svgToPdf above -- convertDocument resolves this as a single [pdf -> svg fromPdf] hop, reconstructDrawing mapping readPdf's recovered vector items near-1:1 into a drawing ContentDocument and buildSvgText writing the six shape primitives back out. `page` selects which page of a multi-page PDF becomes the (single-page) svg, the same caller decision CsvWriteOptions.sheet holds for sheets; omitting it on a multi-page PDF throws SvgMultiPageNotSpecifiedError rather than truncating.
+export function pdfToSvg(bytes: Uint8Array, options?: PdfToDocumentOptions & SvgWriteOptions): Uint8Array {
+ return convertDocument('pdf', 'svg', bytes, options);
+}
+
+// The same-variant cross-format bridges (odt<->docx, odp<->pptx, ods<->xlsx, csv<->ods, csv<->xlsx, svg<->odg, and -- further down this section -- markdown<->docx, markdown<->odt), each bypassing PDF entirely. Every conversion above this point pivots through a LayoutDocument; these pairs don't have that problem: both formats in each pair already read into and build from the identical ContentDocument variant, so the bridge is nothing more than reader -> writer, with no layout engine, no font measurement, and no geometry-based reconstruction in between. Each forwarder below hands the pair to convertDocument (src/convert/composition.ts), whose pathfinder resolves it as a single same-variant bridge hop and runs the identical decode/read/build/encode sequence.
export interface DocumentBridgeOptions {
readonly signal?: AbortSignal;
// Called exactly once, synchronously, with the DocumentPackage this bridge built internally, before the function returns its bytes -- mirroring DocumentToPdfOptions/PdfToDocumentOptions's own onDocument. A bridge never runs a layout engine (see this section's own top-of-block comment), so `layout` is always left undefined here -- DocumentPackageSchema already models layout as optional for exactly this case, and running a layout conversion purely to populate a field no caller asked for would be wasted work.
@@ -278,6 +302,18 @@ export function odsToCsv(bytes: Uint8Array, options?: DocumentBridg
return convertDocument('ods', 'csv', bytes, options);
}
+// svg <-> odg: svg is the drawing family's plain-text member -- it reads into and builds from the same drawing ContentDocument variant as odg (see capability.ts), so these resolve as single same-variant bridge hops, the svg side decoding/encoding plain text with no package in between. The svg-sourced direction intersects SvgReadOptions (onSvgDiagnostic reaches readSvgContent's own scope-limit channel) and the svg-target one SvgWriteOptions (page/onSvgDiagnostic reach buildSvgText's own writer); every non-svg field of DocumentBridgeOptions threads exactly as it does for the bridges above. Geometry crosses losslessly in both directions because both sides speak the identical six-primitive ContentVector vocabulary -- the one honest asymmetry is paint defaults (an SVG shape with no fill attribute reads as black-filled, the SVG specification's own default).
+
+// Forwards to convertDocument (src/convert/composition.ts).
+export function svgToOdg(bytes: Uint8Array, options?: DocumentBridgeOptions & SvgReadOptions): Uint8Array {
+ return convertDocument('svg', 'odg', bytes, options);
+}
+
+// Forwards to convertDocument (src/convert/composition.ts).
+export function odgToSvg(bytes: Uint8Array, options?: DocumentBridgeOptions & SvgWriteOptions): Uint8Array {
+ return convertDocument('odg', 'svg', bytes, options);
+}
+
// Four cross-variant content bridges (wordprocessing <-> presentation), two pairs: docx <-> pptx and odt <-> odp. These cross a VARIANT BOUNDARY via a real semantic transform (src/convert/variant-bridges.ts), which convertDocument's pathfinder resolves as a single cross-variant bridge hop. Both directions are APPROXIMATIONS -- a flow document has no real slide boundaries, and a deck has no flow -- but the blocks themselves (paragraphs, tables, images, list membership, run styling) survive intact through both transforms.
// Forwards to convertDocument (src/convert/composition.ts).
diff --git a/src/convert/document-fonts.ts b/src/convert/document-fonts.ts
index d57f4dc9..69e16ae0 100644
--- a/src/convert/document-fonts.ts
+++ b/src/convert/document-fonts.ts
@@ -7,7 +7,7 @@ import type { DocumentFormat } from './port';
// Lives beside convert.ts's own DocumentFormat-dispatching functions (buildDocumentBytes, odbReportToPdf, ...) rather than in src/fonts/registry.ts alongside extractSourceFonts/FontSourcePackage: this package's own dependency-direction convention (see the README's Architecture section) states that `fonts` imports no local module at all, so it can sit beside `layout` rather than under it -- importing DocumentFormat from src/convert/port.ts here would break that invariant for no real gain, since `convert` already legitimately depends on `fonts` in the other direction.
-// The subset of DocumentFormat that can declare a source-embedded font face at all: docx/pptx via OOXML's own fontTable.xml/embeddedFontLst, odt/odp/ods/odg via ODF's own office:font-face-decls. xlsx has no OOXML font-embedding vocabulary of its own; pdf/markdown/csv carry no source-package concept to embed a font declaration in; a standalone .odf formula document embeds only the STIX Two Math font pdf-codec itself carries, never a caller-resolvable face; .odb has no font concept at all (and is not a DocumentFormat member regardless).
+// The subset of DocumentFormat that can declare a source-embedded font face at all: docx/pptx via OOXML's own fontTable.xml/embeddedFontLst, odt/odp/ods/odg via ODF's own office:font-face-decls. xlsx has no OOXML font-embedding vocabulary of its own; pdf/markdown/csv/svg carry no source-package concept to embed a font declaration in (svg is plain text -- its fonts are renderer-side, not embedded); a standalone .odf formula document embeds only the STIX Two Math font pdf-codec itself carries, never a caller-resolvable face; .odb has no font concept at all (and is not a DocumentFormat member regardless).
const FONT_SOURCE_FORMATS: Readonly> = {
docx: true,
pptx: true,
@@ -21,7 +21,7 @@ function isFontSourceFormat(format: DocumentFormat): format is keyof typeof FONT
return format in FONT_SOURCE_FORMATS;
}
-// A recognised DocumentFormat that nonetheless has no source-embedded-font concept at all (xlsx, pdf, markdown, csv, odf) -- a named class, matching this package's own convention for every other "recognised but unsupported" input across the .odb/odm surface (OdbTableNotSpecifiedError, OdbUnsupportedFormatError, ...), so a caller can narrow on it with instanceof rather than string-matching a thrown Error's own message.
+// A recognised DocumentFormat that nonetheless has no source-embedded-font concept at all (xlsx, pdf, markdown, csv, svg, odf) -- a named class, matching this package's own convention for every other "recognised but unsupported" input across the .odb/odm surface (OdbTableNotSpecifiedError, OdbUnsupportedFormatError, ...), so a caller can narrow on it with instanceof rather than string-matching a thrown Error's own message.
export class UnsupportedFontSourceFormatError extends Error {
readonly format: DocumentFormat;
diff --git a/src/convert/local.test.ts b/src/convert/local.test.ts
index 27235a04..a82c4ec3 100644
--- a/src/convert/local.test.ts
+++ b/src/convert/local.test.ts
@@ -33,9 +33,9 @@ function buildSamplePptx(text: string): Uint8Array {
describe('createLocalDocumentConverter: shape', () => {
it('reports contractVersion and the supported conversion pairs', () => {
const converter = createLocalDocumentConverter();
- // 5, not 4: convert()'s own ConversionOptions gained clock (a ClockPort), forwarded to every X-to-PDF conversion's /CreationDate and /ModDate stamping -- see port.ts's own contractVersion comment on what does and does not warrant a bump.
- expect(converter.contractVersion).toBe(5);
- // SUPPORTED_CONVERSIONS is now derived from the composition pathfinder (resolveCompositionPlan) rather than a hand-maintained DIRECT_EDGES list. The pathfinder routes every pair of non-odf formats (each reaches all 9 others within the 3-hop cap), plus the special-case odf -> pdf pair -- 91 pairs total, sorted by source then target for determinism. csv joins as a full spreadsheet-variant member: same-variant bridges to ods/xlsx directly, everything else composed through the identical ods pivot xlsx uses.
+ // 6, not 5: convert()'s own ConversionOptions gained page, forwarded to any svg-target hop (drawing pages are anonymous, so an index selects the page the way sheet names a sheet) -- see port.ts's own contractVersion comment on what does and does not warrant a bump.
+ expect(converter.contractVersion).toBe(6);
+ // SUPPORTED_CONVERSIONS is now derived from the composition pathfinder (resolveCompositionPlan) rather than a hand-maintained DIRECT_EDGES list. The pathfinder routes every pair of non-odf formats (each reaches all 10 others within the 3-hop cap), plus the special-case odf -> pdf pair -- 111 pairs total, sorted by source then target for determinism. csv joins as a full spreadsheet-variant member: same-variant bridges to ods/xlsx directly, everything else composed through the identical ods pivot xlsx uses. svg joins as the drawing family's plain-text member the same way: a same-variant bridge to odg directly plus its own pdf layout pair, everything else composed through those two edges.
expect(converter.conversions).toEqual([
{ source: 'csv', target: 'docx' },
{ source: 'csv', target: 'markdown' },
@@ -45,6 +45,7 @@ describe('createLocalDocumentConverter: shape', () => {
{ source: 'csv', target: 'odt' },
{ source: 'csv', target: 'pdf' },
{ source: 'csv', target: 'pptx' },
+ { source: 'csv', target: 'svg' },
{ source: 'csv', target: 'xlsx' },
{ source: 'docx', target: 'csv' },
{ source: 'docx', target: 'markdown' },
@@ -54,6 +55,7 @@ describe('createLocalDocumentConverter: shape', () => {
{ source: 'docx', target: 'odt' },
{ source: 'docx', target: 'pdf' },
{ source: 'docx', target: 'pptx' },
+ { source: 'docx', target: 'svg' },
{ source: 'docx', target: 'xlsx' },
{ source: 'markdown', target: 'csv' },
{ source: 'markdown', target: 'docx' },
@@ -63,6 +65,7 @@ describe('createLocalDocumentConverter: shape', () => {
{ source: 'markdown', target: 'odt' },
{ source: 'markdown', target: 'pdf' },
{ source: 'markdown', target: 'pptx' },
+ { source: 'markdown', target: 'svg' },
{ source: 'markdown', target: 'xlsx' },
{ source: 'odf', target: 'pdf' },
{ source: 'odg', target: 'csv' },
@@ -73,6 +76,7 @@ describe('createLocalDocumentConverter: shape', () => {
{ source: 'odg', target: 'odt' },
{ source: 'odg', target: 'pdf' },
{ source: 'odg', target: 'pptx' },
+ { source: 'odg', target: 'svg' },
{ source: 'odg', target: 'xlsx' },
{ source: 'odp', target: 'csv' },
{ source: 'odp', target: 'docx' },
@@ -82,6 +86,7 @@ describe('createLocalDocumentConverter: shape', () => {
{ source: 'odp', target: 'odt' },
{ source: 'odp', target: 'pdf' },
{ source: 'odp', target: 'pptx' },
+ { source: 'odp', target: 'svg' },
{ source: 'odp', target: 'xlsx' },
{ source: 'ods', target: 'csv' },
{ source: 'ods', target: 'docx' },
@@ -91,6 +96,7 @@ describe('createLocalDocumentConverter: shape', () => {
{ source: 'ods', target: 'odt' },
{ source: 'ods', target: 'pdf' },
{ source: 'ods', target: 'pptx' },
+ { source: 'ods', target: 'svg' },
{ source: 'ods', target: 'xlsx' },
{ source: 'odt', target: 'csv' },
{ source: 'odt', target: 'docx' },
@@ -100,6 +106,7 @@ describe('createLocalDocumentConverter: shape', () => {
{ source: 'odt', target: 'ods' },
{ source: 'odt', target: 'pdf' },
{ source: 'odt', target: 'pptx' },
+ { source: 'odt', target: 'svg' },
{ source: 'odt', target: 'xlsx' },
{ source: 'pdf', target: 'csv' },
{ source: 'pdf', target: 'docx' },
@@ -109,6 +116,7 @@ describe('createLocalDocumentConverter: shape', () => {
{ source: 'pdf', target: 'ods' },
{ source: 'pdf', target: 'odt' },
{ source: 'pdf', target: 'pptx' },
+ { source: 'pdf', target: 'svg' },
{ source: 'pdf', target: 'xlsx' },
{ source: 'pptx', target: 'csv' },
{ source: 'pptx', target: 'docx' },
@@ -118,7 +126,18 @@ describe('createLocalDocumentConverter: shape', () => {
{ source: 'pptx', target: 'ods' },
{ source: 'pptx', target: 'odt' },
{ source: 'pptx', target: 'pdf' },
+ { source: 'pptx', target: 'svg' },
{ source: 'pptx', target: 'xlsx' },
+ { source: 'svg', target: 'csv' },
+ { source: 'svg', target: 'docx' },
+ { source: 'svg', target: 'markdown' },
+ { source: 'svg', target: 'odg' },
+ { source: 'svg', target: 'odp' },
+ { source: 'svg', target: 'ods' },
+ { source: 'svg', target: 'odt' },
+ { source: 'svg', target: 'pdf' },
+ { source: 'svg', target: 'pptx' },
+ { source: 'svg', target: 'xlsx' },
{ source: 'xlsx', target: 'csv' },
{ source: 'xlsx', target: 'docx' },
{ source: 'xlsx', target: 'markdown' },
@@ -128,6 +147,7 @@ describe('createLocalDocumentConverter: shape', () => {
{ source: 'xlsx', target: 'odt' },
{ source: 'xlsx', target: 'pdf' },
{ source: 'xlsx', target: 'pptx' },
+ { source: 'xlsx', target: 'svg' },
]);
});
@@ -139,6 +159,10 @@ describe('createLocalDocumentConverter: shape', () => {
expect(converter.conversions).toContainEqual({ source: 'pdf', target: 'csv' });
expect(converter.conversions).toContainEqual({ source: 'xlsx', target: 'pdf' });
expect(converter.conversions).toContainEqual({ source: 'pdf', target: 'xlsx' });
+ // svg <-> pdf is a DIRECT layout pair, not a composed one (svg rides the drawing layout engine the way odg does), pinned here alongside the composed pairs so the distinction survives any future reordering.
+ expect(converter.conversions).toContainEqual({ source: 'svg', target: 'pdf' });
+ expect(converter.conversions).toContainEqual({ source: 'pdf', target: 'svg' });
+ expect(converter.conversions).toContainEqual({ source: 'pdf', target: 'xlsx' });
});
});
diff --git a/src/convert/local.ts b/src/convert/local.ts
index 44b2d7a4..bbe6dde4 100644
--- a/src/convert/local.ts
+++ b/src/convert/local.ts
@@ -47,8 +47,8 @@ function fromPdfDiagnostic(diagnostic: PdfDiagnostic): Diagnostic {
export function createLocalDocumentConverter(): DocumentConverter {
return {
- // 2 added ConversionResult's optional `package` field (see port.ts), which the local implementation below populates from every conversion function's own onDocument callback; 3 added convert()'s own ConversionOptions.fonts/onFontSubstitution, which an implementation is now expected to honour for every conversion that lays text out; 4 added ConversionOptions.images (a MarkdownImageResolver), honoured by the markdown-sourced to-PDF and bridge edges; 5 added ConversionOptions.clock, forwarded to every X-to-PDF conversion's /CreationDate and /ModDate stamping.
- contractVersion: 5,
+ // 2 added ConversionResult's optional `package` field (see port.ts), which the local implementation below populates from every conversion function's own onDocument callback; 3 added convert()'s own ConversionOptions.fonts/onFontSubstitution, which an implementation is now expected to honour for every conversion that lays text out; 4 added ConversionOptions.images (a MarkdownImageResolver), honoured by the markdown-sourced to-PDF and bridge edges; 5 added ConversionOptions.clock, forwarded to every X-to-PDF conversion's /CreationDate and /ModDate stamping; 6 added ConversionOptions.page, forwarded to any svg-target hop (drawing pages are anonymous, so an index selects the page the way `sheet` names a sheet).
+ contractVersion: 6,
conversions: SUPPORTED_CONVERSIONS,
convert(request: ConversionRequest, options: ConversionOptions): Promise {
const { source, targetFormat } = request;
@@ -75,7 +75,7 @@ export function createLocalDocumentConverter(): DocumentConverter {
return Promise.reject(new UnsupportedConversionError(source.format, targetFormat));
}
- // convertDocument runs the resolved plan end to end, threading the port's ConversionOptions through to whichever hop consumes each field: fonts/onFontSubstitution/onSubstitution/clock reach any toPdf hop (the only kind that lays text out and resolves a face), sink reaches any fromPdf hop (the only kind that reads a PDF and can report parse diagnostics), delimiter/sheet reach any csv hop, and signal/images reach every hop. The onDocument callback captures the DocumentPackage the first content-producing hop builds, mirroring the per-edge onDocument wiring the previous direct-edge path threaded into each edge kind individually.
+ // convertDocument runs the resolved plan end to end, threading the port's ConversionOptions through to whichever hop consumes each field: fonts/onFontSubstitution/onSubstitution/clock reach any toPdf hop (the only kind that lays text out and resolves a face), sink reaches any fromPdf hop (the only kind that reads a PDF and can report parse diagnostics), delimiter/sheet reach any csv hop, page reaches any svg-target hop, and signal/images reach every hop. The onDocument callback captures the DocumentPackage the first content-producing hop builds, mirroring the per-edge onDocument wiring the previous direct-edge path threaded into each edge kind individually.
const bytes = convertDocument(source.format, targetFormat, source.bytes, {
signal: options.signal,
fonts: options.fonts,
@@ -85,6 +85,7 @@ export function createLocalDocumentConverter(): DocumentConverter {
images: options.images,
delimiter: options.delimiter,
sheet: options.sheet,
+ page: options.page,
clock: options.clock,
onDocument,
});
diff --git a/src/convert/port.test.ts b/src/convert/port.test.ts
index e6a4ff65..89c7063c 100644
--- a/src/convert/port.test.ts
+++ b/src/convert/port.test.ts
@@ -3,7 +3,7 @@ import { DOCUMENT_FORMATS, DocumentFormatSchema } from './port';
describe('DocumentFormatSchema / DOCUMENT_FORMATS', () => {
it('DOCUMENT_FORMATS lists every DocumentFormat member, matching the schema exactly', () => {
- expect(DOCUMENT_FORMATS).toEqual(['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'odf', 'csv', 'markdown', 'pdf']);
+ expect(DOCUMENT_FORMATS).toEqual(['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'svg', 'odf', 'csv', 'markdown', 'pdf']);
expect(DOCUMENT_FORMATS).toEqual(DocumentFormatSchema.options);
});
diff --git a/src/convert/port.ts b/src/convert/port.ts
index e1c7d3fb..2154ce98 100644
--- a/src/convert/port.ts
+++ b/src/convert/port.ts
@@ -6,10 +6,10 @@ import { z } from 'zod';
// The conversion behaviour modelled as a swappable port/contract, not a hard-wired function -- this workspace's standing "portable runtime and storage boundaries" convention, even though the only implementation today (local.ts) is entirely synchronous under the hood. `convert()` itself stays async and takes a mandatory abort signal regardless of that: it's a portability contract for a future non-local adapter (a remote conversion service, say), not a reflection of the local implementation's own synchronicity.
-// 'odf' (an ODF formula document) has exactly one direction wired into this port (odf -> pdf, via odfToPdf -- see local.ts) -- unlike every other member here, there is deliberately no pdf -> odf entry: odmToPdf's own README/gotcha explains why that reverse direction is not attempted (recovering structured MathML from rendered glyphs is a categorically different, OCR-adjacent problem, not a geometry-reconstruction one). 'markdown' shares the wordprocessing ContentDocument variant with docx/odt (see capability.ts's own FORMAT_CAPABILITIES.markdown) -- it has a genuine two-way layout-engine edge to/from pdf (markdownToPdf/pdfToMarkdown), plus direct same-variant bridges to docx and odt, exactly like odt already has to docx. 'csv' shares the spreadsheet ContentDocument variant with xlsx/ods -- like xlsx it has no layout engine of its own (the composition engine routes csv <-> pdf through the ods bridge), and TSV is the SAME member with { delimiter: '\t' } rather than a second enum entry, since a delimiter is a parse option, not a different document format.
+// 'odf' (an ODF formula document) has exactly one direction wired into this port (odf -> pdf, via odfToPdf -- see local.ts) -- unlike every other member here, there is deliberately no pdf -> odf entry: odmToPdf's own README/gotcha explains why that reverse direction is not attempted (recovering structured MathML from rendered glyphs is a categorically different, OCR-adjacent problem, not a geometry-reconstruction one). 'markdown' shares the wordprocessing ContentDocument variant with docx/odt (see capability.ts's own FORMAT_CAPABILITIES.markdown) -- it has a genuine two-way layout-engine edge to/from pdf (markdownToPdf/pdfToMarkdown), plus direct same-variant bridges to docx and odt, exactly like odt already has to docx. 'csv' shares the spreadsheet ContentDocument variant with xlsx/ods -- like xlsx it has no layout engine of its own (the composition engine routes csv <-> pdf through the ods bridge), and TSV is the SAME member with { delimiter: '\t' } rather than a second enum entry, since a delimiter is a parse option, not a different document format. 'svg' shares the drawing ContentDocument variant with odg -- unlike csv it DOES have a layout path of its own (svg -> pdf renders the read drawing ContentDocument through the same convertDrawingToLayout engine odg feeds), plus a same-variant bridge to odg and pdf-composed routes to everything else.
//
// Zod-first, matching this package's own convention (src/model/bytes.ts and every document-schema.js-sourced union re-exported above): the schema is the source of truth, DocumentFormat is inferred from it rather than hand-written, and DOCUMENT_FORMATS (below) is derived from the same schema rather than a second, independently-typed literal array that could drift out of sync with it.
-export const DocumentFormatSchema = z.enum(['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'odf', 'csv', 'markdown', 'pdf']);
+export const DocumentFormatSchema = z.enum(['docx', 'pptx', 'xlsx', 'odt', 'odp', 'ods', 'odg', 'svg', 'odf', 'csv', 'markdown', 'pdf']);
export type DocumentFormat = z.infer;
// Every DocumentFormat member as a plain readonly array, for a caller that wants to enumerate or validate against the full format set without constructing its own Zod schema -- e.g. a CLI's own usage-error text, or an MCP tool's JSON-schema `enum` input.
export const DOCUMENT_FORMATS: readonly DocumentFormat[] = DocumentFormatSchema.options;
@@ -52,6 +52,8 @@ export interface ConversionOptions {
readonly delimiter?: string;
// Selects which sheet of a multi-sheet spreadsheet a csv-TARGET hop writes -- csv has no second sheet, so writing one is a caller decision (see buildCsvText's own CsvSheetNotSpecifiedError). Every non-csv-target hop ignores it.
readonly sheet?: string;
+ // Selects which page of a multi-page drawing an svg-TARGET hop writes, by index -- svg has no second page, so writing one is a caller decision exactly the way a csv sheet is (see buildSvgText's own SvgMultiPageNotSpecifiedError; an index rather than a name because drawing pages are anonymous where sheets are named). Every non-svg-target hop ignores it.
+ readonly page?: number;
// An injectable clock forwarded to the X-to-PDF conversion's own /CreationDate and /ModDate stamping (see DocumentToPdfOptions.clock) -- a fixed instant is fully serialisable, so a remote adapter honours it the same way the local one does.
readonly clock?: ClockPort;
}
diff --git a/src/convert/roundtrip-matrix.test.ts b/src/convert/roundtrip-matrix.test.ts
index 672f60a8..57db195d 100644
--- a/src/convert/roundtrip-matrix.test.ts
+++ b/src/convert/roundtrip-matrix.test.ts
@@ -12,6 +12,8 @@ import { openPptx } from '../edit/pptx/editor';
import { decodeMarkdownText, encodeMarkdownText } from '../markdown/text';
import { readOdgContent } from '../odf/odg/read';
import { readOdsContent } from '../odf/ods/read';
+import { encodeSvgText } from '../svg/text';
+import { SvgMultiPageNotSpecifiedError } from '../svg/write';
import { FRACTION_FORMULA, odfFormulaBytes } from '../test-support/odf';
import { minimalOdgBytes } from '../test-support/odg';
import { richMarkdownText } from '../test-support/markdown';
@@ -462,6 +464,9 @@ function fixtureBytes(format: DocumentFormat): Uint8Array {
return minimalOdsBytes();
case 'odg':
return minimalOdgBytes();
+ case 'svg':
+ // A viewBox-sized root with a title, one filled rect, and one stroked path -- enough geometry that every svg-sourced sweep pair carries real vector content through the drawing variant, and a metadata title the round trips can genuinely recover.
+ return encodeSvgText('Sweep fixture ');
case 'xlsx':
return odsToXlsx(minimalOdsBytes());
case 'markdown':
@@ -496,6 +501,8 @@ function isValidOutput(format: DocumentFormat, bytes: Uint8Array):
case 'markdown':
case 'csv':
return bytes.length > 0;
+ case 'svg':
+ return new TextDecoder().decode(bytes).includes('):
const ALL_SUPPORTED_PAIRS = createLocalDocumentConverter().conversions;
+// svg -> csv and svg -> markdown are the one pair family whose honest output is EMPTY: svg's read scope is vector graphics only (text is out of scope by design, reported as svg/text-unsupported), and neither csv nor markdown has any vector vocabulary, so there is literally nothing these two targets can carry. The conversion still runs and still produces a valid zero-record csv / zero-block markdown -- pinned here as the pair's own expected result, with every text-carrying source keeping the non-empty requirement unchanged.
+const EMPTY_OUTPUT_PAIRS = new Set(['svg->csv', 'svg->markdown']);
+
describe.each(ALL_SUPPORTED_PAIRS.map((pair) => [`${pair.source}->${pair.target}`, pair] as const))('lightweight sweep: %s', (_label, pair) => {
it('produces valid output of the target format without throwing', async () => {
const converter = createLocalDocumentConverter();
const sourceBytes = fixtureBytes(pair.source);
- // A csv target whose intermediate content carries more than one sheet (odp -> csv reconstructs one sheet per slide) is a caller decision, not a conversion failure: without a selection the conversion refuses with CsvSheetNotSpecifiedError naming every sheet, and re-running it with { sheet: } produces the csv. Both halves of that contract are asserted here rather than swallowing the refusal. The async wrapper matters: the local converter throws synchronously inside convert() (its body runs the whole pipeline before constructing the return promise), so without it the refusal would bypass .catch entirely.
- const run = async (options: { sheet?: string } = {}) =>
+ // A csv target whose intermediate content carries more than one sheet (odp -> csv reconstructs one sheet per slide) is a caller decision, not a conversion failure: without a selection the conversion refuses with CsvSheetNotSpecifiedError naming every sheet, and re-running it with { sheet: } produces the csv. An svg target whose intermediate content carries more than one page (a multi-page pdf, one drawing page per slide) holds the identical contract one variant over: the refusal names SvgMultiPageNotSpecifiedError, and re-running it with { page: } produces the svg. Both halves of both contracts are asserted here rather than swallowing the refusal. The async wrapper matters: the local converter throws synchronously inside convert() (its body runs the whole pipeline before constructing the return promise), so without it the refusal would bypass .catch entirely.
+ const run = async (options: { sheet?: string; page?: number } = {}) =>
converter.convert({ source: { format: pair.source, bytes: sourceBytes }, targetFormat: pair.target }, { signal: new AbortController().signal, ...options });
- const result = await run().catch((error: unknown) => {
- if (!(error instanceof CsvSheetNotSpecifiedError)) {
- throw error;
- }
- expect(error.availableSheets.length).toBeGreaterThan(1);
- return run({ sheet: error.availableSheets[0]! });
- });
+ const result = await run()
+ .catch((error: unknown) => {
+ if (!(error instanceof CsvSheetNotSpecifiedError)) {
+ throw error;
+ }
+ expect(error.availableSheets.length).toBeGreaterThan(1);
+ return run({ sheet: error.availableSheets[0]! });
+ })
+ .catch((error: unknown) => {
+ if (!(error instanceof SvgMultiPageNotSpecifiedError)) {
+ throw error;
+ }
+ expect(error.pageCount).toBeGreaterThan(1);
+ return run({ page: 0 });
+ });
expect(result.document.format).toBe(pair.target);
- expect(isValidOutput(pair.target, result.document.bytes)).toBe(true);
+ expect(isValidOutput(pair.target, result.document.bytes) || (EMPTY_OUTPUT_PAIRS.has(edgeKey(pair)) && result.document.bytes.length === 0)).toBe(true);
});
});
diff --git a/src/index.ts b/src/index.ts
index 621ce5e0..a0f9d8b3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,4 +1,4 @@
-// documents.js's public surface: bidirectional conversion among docx/pptx/xlsx/odt/odp/ods/odg/markdown/csv and PDF (every content format's own PDF round trip plus the cross-format bridges), a read+write live-view editor for docx/pptx/odt/odp/ods/odg, and a hand-written PDF codec, built on ooxml.js's lossless OOXML core, odf.js's lossless ODF core, and markdown-codec.
+// documents.js's public surface: bidirectional conversion among docx/pptx/xlsx/odt/odp/ods/odg/markdown/csv/svg and PDF (every content format's own PDF round trip plus the cross-format bridges), a read+write live-view editor for docx/pptx/odt/odp/ods/odg, and a hand-written PDF codec, built on ooxml.js's lossless OOXML core, odf.js's lossless ODF core, and markdown-codec.
// --- ooxml.js's lossless OOXML core, re-exported so consumers need only this one dependency. Its own typed readers readDocx/readPptx, and the separate lossy cell-values-only readXlsx, are deliberately NOT re-exported here: readDocxContent/readPptxContent (below) already wrap readDocx/readPptx into ContentDocument, so exposing both the wrapper and the thing it wraps would be a trap -- two overlapping entry points to the same underlying read. readXlsxContent/buildXlsxPackage are the one exception -- re-exported directly further below, in the Format <-> ContentDocument readers section, rather than from here or behind a documents.js-local wrapper: unlike readDocx/readPptx, readXlsxContent already reads (and buildXlsxPackage already builds) a real spreadsheet ContentDocument on its own, so there is no wrapper to write and no second, overlapping entry point to trap a caller into picking the wrong one. readDocx's own comments/footnotes/headers/footers/numbering, which ContentDocument doesn't model at all, are not lost, though -- see readDocxExtras below, which exposes that data as its own real return type rather than by re-exporting readDocx itself. Comment/Footnote/NumberingDefinitions (ooxml.js's own types, reused by readDocxExtras' own return shape) are re-exported here since they're genuinely just data types, not a second entry point to the same read. ---
export {
@@ -170,8 +170,8 @@ export type { Color as LayoutColor } from 'document-schema.js';
export { COLOR_BLACK, rgbHexToColor } from 'document-schema.js';
export type { Alignment, LayoutFont } from 'document-schema.js';
export { DEFAULT_LAYOUT_FONT } from 'document-schema.js';
-// Magic-byte-validated Uint8Array schemas, so a caller passing the wrong format -- to these functions directly, or as the input/output schema half of a z.codec() below -- gets a clear Zod validation error instead of a confusing failure three layers down. The Odt/Ods/Odp/Odg schemas check the package's actual declared media type (see src/model/bytes.ts), a stronger check than Docx/PptxBytesSchema's generic ZIP-signature check. MarkdownBytesSchema and CsvBytesSchema are architecturally different from every other schema here -- each checks only well-formed UTF-8, since markdown and csv are plain text with no magic bytes or format-level header of their own to check (see src/model/bytes.ts's own comment).
-export { CsvBytesSchema, DocxBytesSchema, MarkdownBytesSchema, OdgBytesSchema, OdpBytesSchema, OdsBytesSchema, OdtBytesSchema, PdfBytesSchema, PptxBytesSchema, XlsxBytesSchema } from './model/bytes';
+// Magic-byte-validated Uint8Array schemas, so a caller passing the wrong format -- to these functions directly, or as the input/output schema half of a z.codec() below -- gets a clear Zod validation error instead of a confusing failure three layers down. The Odt/Ods/Odp/Odg schemas check the package's actual declared media type (see src/model/bytes.ts), a stronger check than Docx/PptxBytesSchema's generic ZIP-signature check. MarkdownBytesSchema and CsvBytesSchema are architecturally different from every other schema here -- each checks only well-formed UTF-8, since markdown and csv are plain text with no magic bytes or format-level header of their own to check (see src/model/bytes.ts's own comment). SvgBytesSchema sits in between: svg is plain text too, but it has a recognisable root element, so its schema additionally requires the decoded text to contain an ContentDocument -- the fifth adapter family, sharing the drawing variant with odg: decodeSvgText/encodeSvgText are the byte<->text boundary (rejecting malformed UTF-8 exactly as the csv/markdown boundaries do), readSvgContent maps the six SVG shape primitives (rect/circle/ellipse/line/polyline/polygon/path) onto a one-page drawing ContentDocument, and buildSvgText writes them back out. The errors are exported beside them so a caller composing the stages directly can branch on them by class: SvgInvalidUtf8Error (malformed bytes at the decode boundary), SvgMissingRootElementError (text with no degrade under a diagnostic, never silently).
+export { decodeSvgText, encodeSvgText, SvgInvalidUtf8Error } from './svg/text';
+export { readSvgContent } from './svg/read';
+export type { ReadSvgContentOptions } from './svg/read';
+export { SvgMissingRootElementError } from './svg/read';
+export { buildSvgText } from './svg/write';
+export type { BuildSvgTextOptions } from './svg/write';
+export { SvgMultiPageNotSpecifiedError, SvgPageNotFoundError, SvgUnsupportedDocumentKindError } from './svg/write';
+export { SVG_DIAGNOSTIC_CODES } from './svg/diagnostics';
+export type { SvgDiagnostic, SvgDiagnosticCode, SvgDiagnosticSink } from './svg/diagnostics';
// The one-way ContentDocument -> Markdown text renderer covering all five ContentDocument kinds, not just 'wordprocessing' -- buildMarkdownText/writeMarkdown above throw MarkdownUnsupportedDocumentKindError for the other four. renderContentDocumentToMarkdown delegates straight to buildMarkdownText for 'wordprocessing' and otherwise flattens slides/sheets/drawing pages/a bare formula into the same ContentBlock vocabulary first, reporting every degrade decision through its own onDiagnostic option (see src/markdown/render.ts's own module comment).
export type {
MarkdownRenderDiagnostic,
@@ -376,22 +386,22 @@ export { inferCellValue } from './layout/cell-typing';
export type { GridLattice } from './layout/lattice';
export { detectGridLattice } from './layout/lattice';
-// --- The ergonomic X <-> PDF conversions (docx/pptx/odt/odp/ods/odg/xlsx/markdown/csv <-> PDF, all round-trip both ways). xlsx<->pdf and csv<->pdf each compose their same-variant ods bridge with the ods<->pdf layout pair internally -- neither xlsx nor csv has a layout engine of its own -- but both are real, direct, single-call conversion pairs from a caller's own point of view. The csv pairs intersect convert.ts's own CsvReadOptions (delimiter, onCellTypeInference) / CsvWriteOptions (delimiter, sheet) into the shared options type each already uses -- the two option groups every csv-sourced/csv-targeted conversion consumes. markdown<->pdf (markdownToPdf/pdfToMarkdown) DOES lay markdown out directly, reusing convertWordprocessingToLayout/reconstructWordprocessing completely unmodified -- but pdfToMarkdown is the single lossiest conversion in the whole package (see convert.ts's own top-of-file comment and the README's Fidelity section). ---
-export type { CsvReadOptions, CsvWriteOptions, DocumentToPdfOptions, PdfToDocumentOptions } from './convert/convert';
-export { csvToPdf, docxToPdf, markdownToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pdfToCsv, pdfToDocx, pdfToMarkdown, pdfToOdg, pdfToOdp, pdfToOds, pdfToOdt, pdfToPptx, pdfToXlsx, pptxToPdf, xlsxToPdf } from './convert/convert';
+// --- The ergonomic X <-> PDF conversions (docx/pptx/odt/odp/ods/odg/xlsx/markdown/csv/svg <-> PDF, all round-trip both ways). xlsx<->pdf and csv<->pdf each compose their same-variant ods bridge with the ods<->pdf layout pair internally -- neither xlsx nor csv has a layout engine of its own -- but both are real, direct, single-call conversion pairs from a caller's own point of view. The csv pairs intersect convert.ts's own CsvReadOptions (delimiter, onCellTypeInference) / CsvWriteOptions (delimiter, sheet) into the shared options type each already uses -- the two option groups every csv-sourced/csv-targeted conversion consumes -- and the svg pairs intersect SvgReadOptions (onSvgDiagnostic) / SvgWriteOptions (page, onSvgDiagnostic) the same way. markdown<->pdf (markdownToPdf/pdfToMarkdown) DOES lay markdown out directly, reusing convertWordprocessingToLayout/reconstructWordprocessing completely unmodified -- but pdfToMarkdown is the single lossiest conversion in the whole package (see convert.ts's own top-of-file comment and the README's Fidelity section). svg<->pdf lays out directly too (the same convertDrawingToLayout/reconstructDrawing pair odg feeds), with the reader's scope limits as its only lossiness. ---
+export type { CsvReadOptions, CsvWriteOptions, DocumentToPdfOptions, PdfToDocumentOptions, SvgReadOptions, SvgWriteOptions } from './convert/convert';
+export { csvToPdf, docxToPdf, markdownToPdf, odgToPdf, odpToPdf, odsToPdf, odtToPdf, pdfToCsv, pdfToDocx, pdfToMarkdown, pdfToOdg, pdfToOdp, pdfToOds, pdfToOdt, pdfToPptx, pdfToSvg, pdfToXlsx, pptxToPdf, svgToPdf, xlsxToPdf } from './convert/convert';
// --- odf (a standalone ODF formula document) -> PDF: not one of the round-trip conversions above (there is no pdfToOdf -- see convert.ts's own module comment on odfToPdf for why: recovering structured MathML from rendered glyphs is a categorically different, OCR-adjacent problem, not a geometry-reconstruction one). Renders via src/mathml's layoutFormula and the embedded STIX Two Math font, the same pipeline the odt/odp embedded-formula paths use. ---
export { odfToPdf } from './convert/convert';
-// Schema-validated z.codec() pairs over the conversions above (docx/pptx/odt/odp/ods/odg/xlsx/markdown/csv bytes <-> PDF bytes), the no-extra-options form -- use the named conversion functions directly for cancellation, diagnostics, or the csv delimiter/sheet options.
-export { csvPdfCodec, docxPdfCodec, markdownPdfCodec, odgPdfCodec, odpPdfCodec, odsPdfCodec, odtPdfCodec, pptxPdfCodec, xlsxPdfCodec } from './convert/codec';
+// Schema-validated z.codec() pairs over the conversions above (docx/pptx/odt/odp/ods/odg/xlsx/markdown/csv/svg bytes <-> PDF bytes), the no-extra-options form -- use the named conversion functions directly for cancellation, diagnostics, the csv delimiter/sheet options, or the svg page/onSvgDiagnostic options.
+export { csvPdfCodec, docxPdfCodec, markdownPdfCodec, odgPdfCodec, odpPdfCodec, odsPdfCodec, odtPdfCodec, pptxPdfCodec, svgPdfCodec, xlsxPdfCodec } from './convert/codec';
-// --- The cross-format bridges: same-variant direct copies (odt<->docx, odp<->pptx, ods<->xlsx, csv<->ods, csv<->xlsx, markdown<->docx, markdown<->odt) and cross-variant semantic transforms (docx<->pptx, odt<->odp) bypass PDF entirely -- see convert.ts's own module comment on this section for why those carry substantially higher fidelity than the PDF-pivot conversions above. The remaining two pairs (xlsx<->markdown, csv<->markdown) are PDF-composed internally, the last-resort routes the pathfinder picks when no shorter path exists. ---
+// --- The cross-format bridges: same-variant direct copies (odt<->docx, odp<->pptx, ods<->xlsx, csv<->ods, csv<->xlsx, svg<->odg, markdown<->docx, markdown<->odt) and cross-variant semantic transforms (docx<->pptx, odt<->odp) bypass PDF entirely -- see convert.ts's own module comment on this section for why those carry substantially higher fidelity than the PDF-pivot conversions above. The remaining two pairs (xlsx<->markdown, csv<->markdown) are PDF-composed internally, the last-resort routes the pathfinder picks when no shorter path exists. ---
export type { DocumentBridgeOptions } from './convert/convert';
-export { csvToMarkdown, csvToOds, csvToXlsx, docxToMarkdown, docxToOdt, markdownToCsv, markdownToDocx, markdownToOdt, odpToPptx, odsToCsv, odsToXlsx, odtToDocx, odtToMarkdown, pptxToOdp, xlsxToCsv, xlsxToOds, docxToPptx, pptxToDocx, odtToOdp, odpToOdt, xlsxToMarkdown, markdownToXlsx } from './convert/convert';
+export { csvToMarkdown, csvToOds, csvToXlsx, docxToMarkdown, docxToOdt, markdownToCsv, markdownToDocx, markdownToOdt, odgToSvg, odpToPptx, odsToCsv, odsToXlsx, odtToDocx, odtToMarkdown, pptxToOdp, svgToOdg, xlsxToCsv, xlsxToOds, docxToPptx, pptxToDocx, odtToOdp, odpToOdt, xlsxToMarkdown, markdownToXlsx } from './convert/convert';
-// Schema-validated z.codec() pairs over the bridges above (odt bytes <-> docx bytes, odp bytes <-> pptx bytes, ods bytes <-> xlsx bytes, ods/xlsx bytes <-> csv bytes, markdown bytes <-> docx/odt bytes, csv bytes <-> markdown bytes), the no-extra-options form -- use the named bridge functions directly for cancellation or the csv delimiter/sheet options.
-export { csvMarkdownCodec, markdownDocxCodec, markdownOdtCodec, odsCsvCodec, odpPptxCodec, odsXlsxCodec, odtDocxCodec, xlsxCsvCodec, xlsxMarkdownCodec } from './convert/codec';
+// Schema-validated z.codec() pairs over the bridges above (odt bytes <-> docx bytes, odp bytes <-> pptx bytes, ods bytes <-> xlsx bytes, ods/xlsx bytes <-> csv bytes, odg bytes <-> svg bytes, markdown bytes <-> docx/odt bytes, csv bytes <-> markdown bytes), the no-extra-options form -- use the named bridge functions directly for cancellation, the csv delimiter/sheet options, or the svg page/onSvgDiagnostic options.
+export { csvMarkdownCodec, markdownDocxCodec, markdownOdtCodec, odgSvgCodec, odsCsvCodec, odpPptxCodec, odsXlsxCodec, odtDocxCodec, xlsxCsvCodec, xlsxMarkdownCodec } from './convert/codec';
// --- odm (ODF master document, multiple chapters) -> PDF, the one conversion in this package shaped differently from every other: a .odm's chapters are external references (odf.js's readOdm never inlines them -- see odmToPdf's own module comment), so producing a PDF needs a caller-supplied resolveSubDocument callback to hand back each chapter's own .odt bytes. Not part of the conversion or bridge groups above, and not wired into the DocumentConverter port below -- see odmToPdf's own module comment for why. ---
export type { OdmToPdfOptions } from './convert/convert';
@@ -462,13 +472,13 @@ export type { UnifiedConversionOptions, ConversionPlan, CompositionHop } from '.
// --- A DocumentPackage (content + its fused positions) -> any DocumentFormat's own bytes -- the reverse of what every ergonomic X-to-PDF/PDF-to-X conversion's own onDocument callback hands back -- plus the frames-to-layout inverse the pdf target rebuilds through (exported for a caller wanting the pdf-codec view of a package's positions without writing bytes). ---
export { buildDocumentBytes, layoutDocumentFromPackage } from './convert/from-package';
-// --- Raw package decode/encode, dispatched by DocumentFormat -- the format-aware counterpart to ooxml.js's/odf.js's own decodePackage/encodePackage, for a caller holding a format + bytes rather than already knowing which of the two underlying codecs applies. Covers docx/pptx/xlsx (ooxml.js's OPC container) and odt/odp/ods/odg/odf (odf.js's ODF container); markdown, csv, and pdf have no raw-package concept at all and throw UnsupportedPackageFormatError. decodeOdbPackage is the .odb-specific sibling: 'odb' is deliberately not a DocumentFormat member (see src/odb/'s own Architecture/Gotchas entries), but its bytes are an ordinary ODF package decoded by the identical odf.js decodePackage every readOdb*/odbTo* function in this package already starts from -- there is no encodeOdbPackage, since nothing here ever writes a new .odb file. ---
+// --- Raw package decode/encode, dispatched by DocumentFormat -- the format-aware counterpart to ooxml.js's/odf.js's own decodePackage/encodePackage, for a caller holding a format + bytes rather than already knowing which of the two underlying codecs applies. Covers docx/pptx/xlsx (ooxml.js's OPC container) and odt/odp/ods/odg/odf (odf.js's ODF container); markdown, csv, svg, and pdf have no raw-package concept at all and throw UnsupportedPackageFormatError. decodeOdbPackage is the .odb-specific sibling: 'odb' is deliberately not a DocumentFormat member (see src/odb/'s own Architecture/Gotchas entries), but its bytes are an ordinary ODF package decoded by the identical odf.js decodePackage every readOdb*/odbTo* function in this package already starts from -- there is no encodeOdbPackage, since nothing here ever writes a new .odb file. ---
export { decodeDocumentPackage, decodeOdbPackage, encodeDocumentPackage, UnsupportedPackageFormatError } from './package-codec';
// --- Every DocumentFormat's own source-embedded font faces, dispatched by format -- the DocumentFormat-aware counterpart to extractSourceFonts/FontSourcePackage above, for a caller holding a format + bytes rather than an already-decoded Package. ---
export { extractSourceFontsForFormat, UnsupportedFontSourceFormatError } from './convert/document-fonts';
-// --- Cross-format metadata read/write: a document's own title/author/subject/keywords/creator/producer/created/modified, resolved (and, for setDocumentMetadata, patched) by DocumentFormat across every format this package supports -- csv reads honestly empty (RFC 4180 text has no metadata container) and is rejected as a setDocumentMetadata source/target for exactly that reason. ---
+// --- Cross-format metadata read/write: a document's own title/author/subject/keywords/creator/producer/created/modified, resolved (and, for setDocumentMetadata, patched) by DocumentFormat across every format this package supports -- csv reads honestly empty (RFC 4180 text has no metadata container) and is rejected as a setDocumentMetadata source/target for exactly that reason; svg reads its root and is rejected on the write side because is its whole metadata surface, so any other override would be silently dropped. ---
export type { ReadDocumentMetadataOptions } from './metadata/read';
export { readDocumentMetadata } from './metadata/read';
export type { MetadataOverrides, SetDocumentMetadataOptions } from './metadata/write';
diff --git a/src/metadata/write.ts b/src/metadata/write.ts
index c92f0efb..6b3f6674 100644
--- a/src/metadata/write.ts
+++ b/src/metadata/write.ts
@@ -6,7 +6,7 @@ import type { DocumentCodecOptions } from '../codecs/registry';
import type { DocumentFormat } from '../convert/port';
import { throwIfAborted } from '../ports/abort';
-// Every format whose own ContentDocument setDocumentMetadata can patch a metadata field on and rebuild from scratch through -- the eight formats sharing the readXContent -> buildXPackage round trip. xlsx joined this set once DOCUMENT_FORMAT_CODECS.xlsx.content gained a real read/write pair (src/codecs/registry.ts): it now fits the identical shape docx/pptx/odt/odp/ods/odg/markdown already share, so there is no reason left to special-case it out. Deliberately does NOT include 'pdf': a PDF's metadata is patched directly on its own LayoutDocument (see setDocumentMetadata below), never through this ContentDocument rebuild path at all. Nor 'csv': a csv round trip technically exists through the registry codec, but RFC 4180 text has no metadata container at all -- a rebuild would "succeed" and silently drop the override -- so classifyWritePath rejects it explicitly below with that reason rather than letting it fall through to the generic format-mismatch message.
+// Every format whose own ContentDocument setDocumentMetadata can patch a metadata field on and rebuild from scratch through -- the eight formats sharing the readXContent -> buildXPackage round trip. xlsx joined this set once DOCUMENT_FORMAT_CODECS.xlsx.content gained a real read/write pair (src/codecs/registry.ts): it now fits the identical shape docx/pptx/odt/odp/ods/odg/markdown already share, so there is no reason left to special-case it out. Deliberately does NOT include 'pdf': a PDF's metadata is patched directly on its own LayoutDocument (see setDocumentMetadata below), never through this ContentDocument rebuild path at all. Nor 'csv': a csv round trip technically exists through the registry codec, but RFC 4180 text has no metadata container at all -- a rebuild would "succeed" and silently drop the override -- so classifyWritePath rejects it explicitly below with that reason rather than letting it fall through to the generic format-mismatch message. Nor 'svg': its round trip technically exists too, but this package's SVG metadata surface is the root element alone (mapped to/from metadata.title), so every other override would be silently dropped by the rebuild -- rejected below for the identical reason.
const REBUILD_FORMATS: Readonly> = {
docx: true,
pptx: true,
@@ -62,7 +62,7 @@ function mergeMetadata(current: LayoutMetadata, overrides: MetadataOverrides): L
type WritePath = { readonly kind: 'pdf' } | { readonly kind: 'rebuild'; readonly format: RebuildFormat } | { readonly errorMessage: string };
-// setDocumentMetadata deliberately does not convert format: its own job is patching metadata in place, not choosing a target format, so source and target must resolve to the identical format -- 'pdf' direct-patches its own LayoutDocument (no ContentDocument, no layout engine, genuinely lossless for everything else on the page), every other REBUILD_FORMATS member rebuilds a fresh package from its own ContentDocument (lossy wherever that format's own build function is -- see buildDocxPackage's own docx-extras gotcha: comments, footnotes, headers/footers, and numbering definitions do not survive the rebuild, since buildDocxPackage builds a fresh package from the ContentDocument alone, with no way to carry that data through). xlsx now rebuilds through this same path, via DOCUMENT_FORMAT_CODECS.xlsx.content (ooxml.js's readXlsxContent/buildXlsxPackage, src/codecs/registry.ts) -- it is no longer rejected. odf (a standalone formula document) is still rejected outright in both directions, since it has no write path back out at all. A caller wanting to change format and metadata together should convert first (e.g. via buildDocumentBytes or one of the ergonomic X-to-Y conversions), then call setDocumentMetadata on the result.
+// setDocumentMetadata deliberately does not convert format: its own job is patching metadata in place, not choosing a target format, so source and target must resolve to the identical format -- 'pdf' direct-patches its own LayoutDocument (no ContentDocument, no layout engine, genuinely lossless for everything else on the page), every other REBUILD_FORMATS member rebuilds a fresh package from its own ContentDocument (lossy wherever that format's own build function is -- see buildDocxPackage's own docx-extras gotcha: comments, footnotes, headers/footers, and numbering definitions do not survive the rebuild, since buildDocxPackage builds a fresh package from the ContentDocument alone, with no way to carry that data through). xlsx now rebuilds through this same path, via DOCUMENT_FORMAT_CODECS.xlsx.content (ooxml.js's readXlsxContent/buildXlsxPackage, src/codecs/registry.ts) -- it is no longer rejected. odf (a standalone formula document) is still rejected outright in both directions, since it has no write path back out at all, and csv and svg are rejected for the no-metadata-container reason REBUILD_FORMATS's own comment above gives. A caller wanting to change format and metadata together should convert first (e.g. via buildDocumentBytes or one of the ergonomic X-to-Y conversions), then call setDocumentMetadata on the result.
function classifyWritePath(source: DocumentFormat, target: DocumentFormat): WritePath {
if (source === 'pdf' && target === 'pdf') {
return { kind: 'pdf' };
@@ -73,6 +73,9 @@ function classifyWritePath(source: DocumentFormat, target: DocumentFormat): Writ
if (target === 'csv' || source === 'csv') {
return { errorMessage: "'csv' is not a supported setDocumentMetadata source or target -- RFC 4180 text has no metadata container, so a rebuild would silently drop the override. Convert to or from csv first, then patch metadata on the package format." };
}
+ if (target === 'svg' || source === 'svg') {
+ return { errorMessage: "'svg' is not a supported setDocumentMetadata source or target -- this package's SVG metadata surface is the root element alone, so an author/subject/keywords override would be silently dropped by the rebuild. Convert to or from svg first, then patch metadata on the package format." };
+ }
if (!isRebuildFormat(source) || !isRebuildFormat(target)) {
return { errorMessage: `setDocumentMetadata only patches metadata in place; it does not convert format -- source ('${source}') and target ('${target}') must be the same format (or both 'pdf'). Convert first if you need a different target format.` };
}
diff --git a/src/model/bytes.ts b/src/model/bytes.ts
index 9afab2e8..d6faaaeb 100644
--- a/src/model/bytes.ts
+++ b/src/model/bytes.ts
@@ -133,3 +133,14 @@ export const MarkdownBytesSchema = z.instanceof(Uint8Array).refine(isWellFormedU
// CsvBytesSchema rests on the identical no-magic-bytes architecture as MarkdownBytesSchema above: csv shares markdown's plain-text nature (no header, no reserved byte sequence -- RFC 4180 text is just fields and delimiters), so well-formed UTF-8 is the one honest bytes-level check, and the same fatal-decode refinement covers both. The parse errors that ARE specific to csv (an unterminated quoted field) surface as CsvParseError from parseCsvRecords, which is where the text is actually understood.
export const CsvBytesSchema = z.instanceof(Uint8Array).refine(isWellFormedUtf8Text, { message: 'not well-formed UTF-8 text' });
+
+// SvgBytesSchema shares the plain-text architecture above but can honestly check one step more structure: SVG, unlike csv/markdown, HAS a recognisable root -- an XML document whose outermost element is . The check is deliberately loose (a case-insensitive substring, not an XML parse, so a DOCTYPE, XML declaration, or comment ahead of the root still passes and trailing junk is left to the reader), because this schema's job is pre-flight rejection of obviously-wrong bytes, not validation. A fatal decode already proved well-formed UTF-8 by the time the substring is tested, so decoding it again here cannot mangle.
+export const SvgBytesSchema = z.instanceof(Uint8Array).refine(
+ (bytes) => {
+ if (!isWellFormedUtf8Text(bytes)) {
+ return false;
+ }
+ return new TextDecoder().decode(bytes).toLowerCase().includes('): Package {
if (isOoxmlPackageFormat(format)) {
return decodeOoxmlPackage(bytes);
diff --git a/src/svg/diagnostics.ts b/src/svg/diagnostics.ts
new file mode 100644
index 00000000..5194d76a
--- /dev/null
+++ b/src/svg/diagnostics.ts
@@ -0,0 +1,40 @@
+// The svg read path's degrade-with-diagnostic channel, following the same three-tier failure policy pdf-codec established: unprocessable input throws (non-UTF-8 bytes, malformed path data makes the whole element skippable rather than half-drawn), malformed-but-salvageable input degrades with a diagnostic, and out-of-scope features degrade with a diagnostic rather than being silently dropped. Every code below names one deliberate scope limit or one bounded approximation -- nothing fires for a plain SVG of vector shapes, which reads silently.
+
+export const SVG_DIAGNOSTIC_CODES = [
+ // A // element: SVG text has no ContentVector representation (the drawing variant's shapes vocabulary is ODF frame content), so the element is skipped and named.
+ 'svg/text-unsupported',
+ // An element: raster image placement is out of scope for this reader.
+ 'svg/image-unsupported',
+ // A element: reference resolution (the instance tree) is out of scope.
+ 'svg/use-unsupported',
+ // A fill or stroke resolved to url(#id): gradient/pattern paint servers are approximated as "no paint" and named, rather than rendered as a wrong solid colour.
+ 'svg/gradient-unsupported',
+ // An element this reader does not know at all: skipped, children not walked.
+ 'svg/element-unsupported',
+ // A recognised shape whose geometry is degenerate or invisible (zero size, a line with no stroke): skipped without error -- it paints nothing in a conforming renderer either.
+ 'svg/element-skipped',
+ // A style attribute (or a paint value only expressible through CSS syntax) was present and ignored: CSS cascade parsing is out of scope.
+ 'svg/css-style-ignored',
+ // A paint value this reader could not resolve (an unsupported colour function, a system colour keyword): the SVG default for that property applies and the value is named.
+ 'svg/paint-unsupported',
+ // An opacity/fill-opacity/stroke-opacity below 1, or a colour alpha, was ignored: this reader models no transparency.
+ 'svg/opacity-ignored',
+ // The root svg's viewBox and viewport aspect ratios differ under a preserveAspectRatio other than 'none', and this reader stretches the viewBox to the viewport rather than letterboxing.
+ 'svg/preserve-aspect-ratio-stretched',
+ // Neither width/height nor a usable viewBox was present, so the CSS default replaced element size (300x150 px) was assumed.
+ 'svg/default-size-assumed',
+ // The write side met a ContentShape (draw:frame text/image/table content) it cannot express as SVG vector markup: skipped and named.
+ 'svg/shape-unsupported',
+ // The write side met a ContentStroke style SVG has no construct for ('double' -- SVG strokes are single): written as solid and named.
+ 'svg/stroke-style-unsupported',
+] as const;
+
+export type SvgDiagnosticCode = (typeof SVG_DIAGNOSTIC_CODES)[number];
+
+export interface SvgDiagnostic {
+ readonly code: SvgDiagnosticCode;
+ // What was skipped or degraded, in source terms (the element name plus its id if present, the unparseable value) -- enough to locate it in the source file without this reader keeping a node identity.
+ readonly detail?: string;
+}
+
+export type SvgDiagnosticSink = (diagnostic: SvgDiagnostic) => void;
diff --git a/src/svg/paint.test.ts b/src/svg/paint.test.ts
new file mode 100644
index 00000000..dca0f3ca
--- /dev/null
+++ b/src/svg/paint.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+import { parseSvgColor, parseSvgDashStyle, parseSvgPaint } from './paint';
+
+describe('parseSvgColor', () => {
+ it('resolves the named keywords case-insensitively, normalised to the 0..1 float triplet', () => {
+ expect(parseSvgColor('red')).toEqual({ r: 1, g: 0, b: 0 });
+ expect(parseSvgColor('BLACK')).toEqual({ r: 0, g: 0, b: 0 });
+ expect(parseSvgColor('RebeccaPurple')).toEqual({ r: 102 / 255, g: 51 / 255, b: 153 / 255 });
+ });
+
+ it('reads the 3, 4, 6, and 8 digit hex forms, expanding each pair of nibbles', () => {
+ expect(parseSvgColor('#f00')).toEqual({ r: 1, g: 0, b: 0 });
+ expect(parseSvgColor('#f00f')).toEqual({ r: 1, g: 0, b: 0 });
+ expect(parseSvgColor('#ff0000')).toEqual({ r: 1, g: 0, b: 0 });
+ // The 8-digit form\'s alpha is parsed for validity but not returned -- transparency is the reader\'s own diagnostic channel, never a flattened colour.
+ expect(parseSvgColor('#ff000080')).toEqual({ r: 1, g: 0, b: 0 });
+ });
+
+ it('returns undefined for a hex digit count no form accepts', () => {
+ expect(parseSvgColor('#ff000')).toBeUndefined();
+ expect(parseSvgColor('#ff00000')).toBeUndefined();
+ expect(parseSvgColor('#zzz')).toBeUndefined();
+ });
+
+ it('reads rgb()/rgba() in both the 0-255 and percentage forms, clamping the channel range', () => {
+ expect(parseSvgColor('rgb(255, 0, 0)')).toEqual({ r: 1, g: 0, b: 0 });
+ expect(parseSvgColor('rgb(100%, 0%, 0%)')).toEqual({ r: 1, g: 0, b: 0 });
+ expect(parseSvgColor('rgba(300, -5, 0, 0.5)')).toEqual({ r: 1, g: 0, b: 0 });
+ expect(parseSvgColor('rgb(1 2 3)')).toEqual({ r: 1 / 255, g: 2 / 255, b: 3 / 255 });
+ });
+});
+
+describe('parseSvgPaint', () => {
+ it('reads none, currentColor, and url references as their own kinds', () => {
+ expect(parseSvgPaint('none')).toEqual({ kind: 'none' });
+ expect(parseSvgPaint('currentColor')).toEqual({ kind: 'currentColor' });
+ expect(parseSvgPaint('url(#grad1)')).toEqual({ kind: 'url', fragment: 'grad1' });
+ expect(parseSvgPaint("url('#grad1')")).toEqual({ kind: 'url', fragment: 'grad1' });
+ });
+
+ it('reads a colour paint and returns undefined for a value no form matches', () => {
+ expect(parseSvgPaint('blue')).toEqual({ kind: 'color', color: { r: 0, g: 0, b: 1 } });
+ expect(parseSvgPaint('not-a-paint')).toBeUndefined();
+ });
+});
+
+describe('parseSvgDashStyle', () => {
+ it('maps dash patterns onto the two stroke styles the schema\'s own enum carries', () => {
+ expect(parseSvgDashStyle(undefined)).toBeUndefined();
+ expect(parseSvgDashStyle('none')).toBeUndefined();
+ expect(parseSvgDashStyle('')).toBeUndefined();
+ // A pattern whose every on-length is at most one user unit reads as dots; anything dash-shaped reads as dashed.
+ expect(parseSvgDashStyle('6 4')).toBe('dashed');
+ expect(parseSvgDashStyle('1 3')).toBe('dotted');
+ expect(parseSvgDashStyle('0.5 1')).toBe('dotted');
+ expect(parseSvgDashStyle('5,3,5,3')).toBe('dashed');
+ });
+
+ it('returns undefined for a malformed value, which is also the attribute\'s solid-stroke default', () => {
+ expect(parseSvgDashStyle('x y')).toBeUndefined();
+ expect(parseSvgDashStyle('-1 2')).toBeUndefined();
+ });
+});
diff --git a/src/svg/paint.ts b/src/svg/paint.ts
new file mode 100644
index 00000000..4d83071d
--- /dev/null
+++ b/src/svg/paint.ts
@@ -0,0 +1,254 @@
+import type { Color } from 'document-schema.js';
+
+// SVG paint and colour parsing: the presentation-attribute vocabulary the reader consumes (fill, stroke, stroke-width, stroke-dasharray, fill-rule, opacity). CSS-wide syntax (the style attribute, selectors, inherited CSS rules) is deliberately out of scope -- a style attribute is reported through the svg/css-style-ignored diagnostic instead of being half-parsed, because a partial CSS implementation that honours some declarations and drops others silently misrepresents the document.
+
+// The CSS/SVG named-colour keywords (CSS Color Module Level 4's named-colour table, which SVG 2 incorporates wholesale). Keys are matched case-insensitively per CSS identifier rules; values are the sRGB 8-bit triplets normalised to the 0..1 floats ColorSchema carries.
+const NAMED_COLORS: Readonly> = {
+ aliceblue: [240, 248, 255],
+ antiquewhite: [250, 235, 215],
+ aqua: [0, 255, 255],
+ aquamarine: [127, 255, 212],
+ azure: [240, 255, 255],
+ beige: [245, 245, 220],
+ bisque: [255, 228, 196],
+ black: [0, 0, 0],
+ blanchedalmond: [255, 235, 205],
+ blue: [0, 0, 255],
+ blueviolet: [138, 43, 226],
+ brown: [165, 42, 42],
+ burlywood: [222, 184, 135],
+ cadetblue: [95, 158, 160],
+ chartreuse: [127, 255, 0],
+ chocolate: [210, 105, 30],
+ coral: [255, 127, 80],
+ cornflowerblue: [100, 149, 237],
+ cornsilk: [255, 248, 220],
+ crimson: [220, 20, 60],
+ cyan: [0, 255, 255],
+ darkblue: [0, 0, 139],
+ darkcyan: [0, 139, 139],
+ darkgoldenrod: [184, 134, 11],
+ darkgray: [169, 169, 169],
+ darkgreen: [0, 100, 0],
+ darkgrey: [169, 169, 169],
+ darkkhaki: [189, 183, 107],
+ darkmagenta: [139, 0, 139],
+ darkolivegreen: [85, 107, 47],
+ darkorange: [255, 140, 0],
+ darkorchid: [153, 50, 204],
+ darkred: [139, 0, 0],
+ darksalmon: [233, 150, 122],
+ darkseagreen: [143, 188, 143],
+ darkslateblue: [72, 61, 139],
+ darkslategray: [47, 79, 79],
+ darkslategrey: [47, 79, 79],
+ darkturquoise: [0, 206, 209],
+ darkviolet: [148, 0, 211],
+ deeppink: [255, 20, 147],
+ deepskyblue: [0, 191, 255],
+ dimgray: [105, 105, 105],
+ dimgrey: [105, 105, 105],
+ dodgerblue: [30, 144, 255],
+ firebrick: [178, 34, 34],
+ floralwhite: [255, 250, 240],
+ forestgreen: [34, 139, 34],
+ fuchsia: [255, 0, 255],
+ gainsboro: [220, 220, 220],
+ ghostwhite: [248, 248, 255],
+ gold: [255, 215, 0],
+ goldenrod: [218, 165, 32],
+ gray: [128, 128, 128],
+ green: [0, 128, 0],
+ greenyellow: [173, 255, 47],
+ grey: [128, 128, 128],
+ honeydew: [240, 255, 240],
+ hotpink: [255, 105, 180],
+ indianred: [205, 92, 92],
+ indigo: [75, 0, 130],
+ ivory: [255, 255, 240],
+ khaki: [240, 230, 140],
+ lavender: [230, 230, 250],
+ lavenderblush: [255, 240, 245],
+ lawngreen: [124, 252, 0],
+ lemonchiffon: [255, 250, 205],
+ lightblue: [173, 216, 230],
+ lightcoral: [240, 128, 128],
+ lightcyan: [224, 255, 255],
+ lightgoldenrodyellow: [250, 250, 210],
+ lightgray: [211, 211, 211],
+ lightgreen: [144, 238, 144],
+ lightgrey: [211, 211, 211],
+ lightpink: [255, 182, 193],
+ lightsalmon: [255, 160, 122],
+ lightseagreen: [32, 178, 170],
+ lightskyblue: [135, 206, 250],
+ lightslategray: [119, 136, 153],
+ lightslategrey: [119, 136, 153],
+ lightsteelblue: [176, 196, 222],
+ lightyellow: [255, 255, 224],
+ lime: [0, 255, 0],
+ limegreen: [50, 205, 50],
+ linen: [250, 240, 230],
+ magenta: [255, 0, 255],
+ maroon: [128, 0, 0],
+ mediumaquamarine: [102, 205, 170],
+ mediumblue: [0, 0, 205],
+ mediumorchid: [186, 85, 211],
+ mediumpurple: [147, 112, 219],
+ mediumseagreen: [60, 179, 113],
+ mediumslateblue: [123, 104, 238],
+ mediumspringgreen: [0, 250, 154],
+ mediumturquoise: [72, 209, 204],
+ mediumvioletred: [199, 21, 133],
+ midnightblue: [25, 25, 112],
+ mintcream: [245, 255, 250],
+ mistyrose: [255, 228, 225],
+ moccasin: [255, 228, 181],
+ navajowhite: [255, 222, 173],
+ navy: [0, 0, 128],
+ oldlace: [253, 245, 230],
+ olive: [128, 128, 0],
+ olivedrab: [107, 142, 35],
+ orange: [255, 165, 0],
+ orangered: [255, 69, 0],
+ orchid: [218, 112, 214],
+ palegoldenrod: [238, 232, 170],
+ palegreen: [152, 251, 152],
+ paleturquoise: [175, 238, 238],
+ palevioletred: [219, 112, 147],
+ papayawhip: [255, 239, 213],
+ peachpuff: [255, 218, 185],
+ peru: [205, 133, 63],
+ pink: [255, 192, 203],
+ plum: [221, 160, 221],
+ powderblue: [176, 224, 230],
+ purple: [128, 0, 128],
+ rebeccapurple: [102, 51, 153],
+ red: [255, 0, 0],
+ rosybrown: [188, 143, 143],
+ royalblue: [65, 105, 225],
+ saddlebrown: [139, 69, 19],
+ salmon: [250, 128, 114],
+ sandybrown: [244, 164, 96],
+ seagreen: [46, 139, 87],
+ seashell: [255, 245, 238],
+ sienna: [160, 82, 45],
+ silver: [192, 192, 192],
+ skyblue: [135, 206, 235],
+ slateblue: [106, 90, 205],
+ slategray: [112, 128, 144],
+ slategrey: [112, 128, 144],
+ snow: [255, 250, 250],
+ springgreen: [0, 255, 127],
+ steelblue: [70, 130, 180],
+ tan: [210, 180, 140],
+ teal: [0, 128, 128],
+ thistle: [216, 191, 216],
+ tomato: [255, 99, 71],
+ turquoise: [64, 224, 208],
+ violet: [238, 130, 238],
+ wheat: [245, 222, 179],
+ white: [255, 255, 255],
+ whitesmoke: [245, 245, 245],
+ yellow: [255, 255, 0],
+ yellowgreen: [154, 205, 50],
+};
+
+function from8Bit(r: number, g: number, b: number): Color {
+ return { r: r / 255, g: g / 255, b: b / 255 };
+}
+
+// One CSS colour value: a named keyword (case-insensitive), #rgb/#rgba/#rrggbb/#rrggbbaa hexadecimal, or rgb()/rgba() in either the 0-255 or percentage form. Alpha is parsed for validity but NOT returned -- this reader models no transparency, and the reader's own opacity diagnostic is the honest channel for that limit (a colour's alpha is reported there rather than silently flattened).
+const HEX_PATTERN = /^#([0-9a-fA-F]{3,8})$/;
+const FUNCTION_COLOR_PATTERN = /^rgba?\(\s*([^)]*)\)$/;
+const SPLIT_COMPONENTS = /[\s,]+/;
+
+export function parseSvgColor(raw: string): Color | undefined {
+ const value = raw.trim();
+ const named = NAMED_COLORS[value.toLowerCase()];
+ if (named !== undefined) {
+ return from8Bit(named[0], named[1], named[2]);
+ }
+ const hex = HEX_PATTERN.exec(value);
+ if (hex !== null) {
+ const digits = hex[1]!;
+ if (digits.length === 3 || digits.length === 4) {
+ const expand = (char: string) => Number.parseInt(char + char, 16);
+ return from8Bit(expand(digits[0]!), expand(digits[1]!), expand(digits[2]!));
+ }
+ if (digits.length === 6 || digits.length === 8) {
+ return from8Bit(Number.parseInt(digits.slice(0, 2), 16), Number.parseInt(digits.slice(2, 4), 16), Number.parseInt(digits.slice(4, 6), 16));
+ }
+ return undefined;
+ }
+ const fn = FUNCTION_COLOR_PATTERN.exec(value);
+ if (fn !== null) {
+ const parts = fn[1]!.trim().split(SPLIT_COMPONENTS).filter((part) => part !== '');
+ if (parts.length !== 3 && parts.length !== 4) {
+ return undefined;
+ }
+ const channel = (part: string): number | undefined => {
+ if (part.endsWith('%')) {
+ const pct = Number(part.slice(0, -1));
+ return Number.isFinite(pct) ? (pct / 100) * 255 : undefined;
+ }
+ const num = Number(part);
+ return Number.isFinite(num) ? Math.min(255, Math.max(0, num)) : undefined;
+ };
+ const r = channel(parts[0]!);
+ const g = channel(parts[1]!);
+ const b = channel(parts[2]!);
+ if (r === undefined || g === undefined || b === undefined) {
+ return undefined;
+ }
+ return from8Bit(r, g, b);
+ }
+ return undefined;
+}
+
+// One SVG value (SVG 2, "Fill Properties"): 'none', 'currentColor', a colour, or a url(#id) reference to a paint server (gradient/pattern). The url form carries the fragment so the caller can decide what to do with it (this reader reports it as the gradient diagnostic rather than pretending it is a colour); 'inherit' is folded into the caller's inheritance walk rather than resolved here.
+export type SvgPaint =
+ | { readonly kind: 'none' }
+ | { readonly kind: 'currentColor' }
+ | { readonly kind: 'color'; readonly color: Color }
+ | { readonly kind: 'url'; readonly fragment: string };
+
+const URL_PAINT_PATTERN = /^url\(\s*['"]?#([^'")\s]*)['"]?\s*\)$/;
+
+export function parseSvgPaint(raw: string): SvgPaint | undefined {
+ const value = raw.trim();
+ if (value === 'none') {
+ return { kind: 'none' };
+ }
+ if (value === 'currentColor') {
+ return { kind: 'currentColor' };
+ }
+ const url = URL_PAINT_PATTERN.exec(value);
+ if (url !== null) {
+ return { kind: 'url', fragment: url[1]! };
+ }
+ const color = parseSvgColor(value);
+ return color === undefined ? undefined : { kind: 'color', color };
+}
+
+// The stroke-dasharray vocabulary reduced to the two stroke styles ContentStroke's own enum carries: a pattern whose every on-length is at most one user unit reads as 'dotted' (dot-style patterns are "0.5 1", "1 3", "0 4"...), anything else dash-shaped reads as 'dashed'. 'none' (and a malformed value) is undefined -- a solid stroke, which is also the attribute's default.
+export type SvgDashStyle = 'dashed' | 'dotted';
+
+export function parseSvgDashStyle(raw: string | undefined): SvgDashStyle | undefined {
+ if (raw === undefined) {
+ return undefined;
+ }
+ const value = raw.trim();
+ if (value === '' || value === 'none') {
+ return undefined;
+ }
+ const numbers = value.split(/[\s,]+/).filter((part) => part !== '');
+ if (numbers.length === 0) {
+ return undefined;
+ }
+ const lengths = numbers.map((part) => Number(part));
+ if (!lengths.every((length) => Number.isFinite(length) && length >= 0)) {
+ return undefined;
+ }
+ return lengths.filter((_, index) => index % 2 === 0).every((on) => on <= 1) ? 'dotted' : 'dashed';
+}
diff --git a/src/svg/path.test.ts b/src/svg/path.test.ts
new file mode 100644
index 00000000..3d08ac6e
--- /dev/null
+++ b/src/svg/path.test.ts
@@ -0,0 +1,193 @@
+import { describe, expect, it } from 'vitest';
+import { parseSvgPathData } from './path';
+
+describe('parseSvgPathData', () => {
+ it('parses absolute M/L into one open subpath of line segments', () => {
+ const parsed = parseSvgPathData('M 10 20 L 30 40');
+ expect(parsed).toEqual([
+ { start: { x: 10, y: 20 }, closed: false, segments: [{ kind: 'line', to: { x: 30, y: 40 } }] },
+ ]);
+ });
+
+ it('parses the relative lowercase forms against the running current point', () => {
+ const parsed = parseSvgPathData('m 10 20 l 5 5 L 100 100');
+ expect(parsed).toEqual([
+ { start: { x: 10, y: 20 }, closed: false, segments: [{ kind: 'line', to: { x: 15, y: 25 } }, { kind: 'line', to: { x: 100, y: 100 } }] },
+ ]);
+ });
+
+ it('parses H/V (and h/v) as lines that keep the other coordinate fixed', () => {
+ const parsed = parseSvgPathData('M 10 20 H 30 V 5 h -5 v -3');
+ expect(parsed).toEqual([
+ { start: { x: 10, y: 20 }, closed: false, segments: [
+ { kind: 'line', to: { x: 30, y: 20 } },
+ { kind: 'line', to: { x: 30, y: 5 } },
+ { kind: 'line', to: { x: 25, y: 5 } },
+ { kind: 'line', to: { x: 25, y: 2 } },
+ ] },
+ ]);
+ });
+
+ it('parses absolute C with its three points verbatim', () => {
+ const parsed = parseSvgPathData('M 0 0 C 10 0 10 10 20 10');
+ expect(parsed).toEqual([
+ { start: { x: 0, y: 0 }, closed: false, segments: [{ kind: 'cubic', control1: { x: 10, y: 0 }, control2: { x: 10, y: 10 }, to: { x: 20, y: 10 } }] },
+ ]);
+ });
+
+ it('reflects S\'s first control through the current point exactly (the previous cubic\'s second control)', () => {
+ // After C lands at (20,10) with second control (10,10), S\'s own first control must be the mirror image (30,10) -- the author\'s intended smooth join, reproduced with no approximation.
+ const parsed = parseSvgPathData('M 0 0 C 10 0 10 10 20 10 S 30 20 30 30');
+ expect(parsed).toEqual([
+ { start: { x: 0, y: 0 }, closed: false, segments: [
+ { kind: 'cubic', control1: { x: 10, y: 0 }, control2: { x: 10, y: 10 }, to: { x: 20, y: 10 } },
+ { kind: 'cubic', control1: { x: 30, y: 10 }, control2: { x: 30, y: 20 }, to: { x: 30, y: 30 } },
+ ] },
+ ]);
+ });
+
+ it('falls back to the current point as S\'s first control when no cubic precedes it', () => {
+ // The spec\'s own rule: with no previous cubic\'s control to reflect, the reflected control is the current point itself.
+ const parsed = parseSvgPathData('M 0 0 S 10 10 20 20');
+ expect(parsed).toEqual([
+ { start: { x: 0, y: 0 }, closed: false, segments: [{ kind: 'cubic', control1: { x: 0, y: 0 }, control2: { x: 10, y: 10 }, to: { x: 20, y: 20 } }] },
+ ]);
+ });
+
+ it('elevates Q to a cubic exactly, with both controls at the 2/3 marks toward the shared control', () => {
+ // A quadratic is the degree-2 special case of a cubic: the elevated controls at from + 2/3*(control-from) and to + 2/3*(control-to) trace the identical curve at every parameter.
+ const parsed = parseSvgPathData('M 0 0 Q 30 0 30 30');
+ expect(parsed).toEqual([
+ { start: { x: 0, y: 0 }, closed: false, segments: [{ kind: 'cubic', control1: { x: 20, y: 0 }, control2: { x: 30, y: 10 }, to: { x: 30, y: 30 } }] },
+ ]);
+ });
+
+ it('reflects T\'s control through the current point, then elevates the quadratic exactly', () => {
+ // After Q (from (0,0), control (30,0), to (30,30)), T\'s control is the mirror of (30,0) through (30,30): (30,60). The elevated controls are then (30,50) and (40,60).
+ const parsed = parseSvgPathData('M 0 0 Q 30 0 30 30 T 60 60');
+ expect(parsed).toEqual([
+ { start: { x: 0, y: 0 }, closed: false, segments: [
+ { kind: 'cubic', control1: { x: 20, y: 0 }, control2: { x: 30, y: 10 }, to: { x: 30, y: 30 } },
+ { kind: 'cubic', control1: { x: 30, y: 50 }, control2: { x: 40, y: 60 }, to: { x: 60, y: 60 } },
+ ] },
+ ]);
+ });
+
+ it('degenerates T to the current point as control when no quadratic precedes it, per the spec\'s own rule', () => {
+ // With no previous quadratic control to reflect, the control is the current point (0,0) itself, and the same exact elevation applies -- control2 sits 2/3 of the way back from the endpoint, expressed here as the identical arithmetic so the assertion is bit-exact.
+ const parsed = parseSvgPathData('M 0 0 T 10 10');
+ expect(parsed).toEqual([
+ { start: { x: 0, y: 0 }, closed: false, segments: [{ kind: 'cubic', control1: { x: 0, y: 0 }, control2: { x: 10 + (2 / 3) * (0 - 10), y: 10 + (2 / 3) * (0 - 10) }, to: { x: 10, y: 10 } }] },
+ ]);
+ });
+
+ it('closes Z/z subpaths and opens a fresh one at the next moveto', () => {
+ const parsed = parseSvgPathData('M 0 0 L 10 0 M 20 20 L 30 30 Z');
+ expect(parsed).toEqual([
+ { start: { x: 0, y: 0 }, closed: false, segments: [{ kind: 'line', to: { x: 10, y: 0 } }] },
+ { start: { x: 20, y: 20 }, closed: true, segments: [{ kind: 'line', to: { x: 30, y: 30 } }] },
+ ]);
+ });
+
+ it('treats further coordinate groups after one M as lineto (implicit repetition)', () => {
+ const parsed = parseSvgPathData('M 10 10 20 20 30 30');
+ expect(parsed).toEqual([
+ { start: { x: 10, y: 10 }, closed: false, segments: [{ kind: 'line', to: { x: 20, y: 20 } }, { kind: 'line', to: { x: 30, y: 30 } }] },
+ ]);
+ });
+
+ it('reads arc flags as single characters even when fused with the surrounding numbers', () => {
+ // "01100" must split into flag 0, flag 1, and the number 100 -- the classic packed-flag form a number-based parser misreads. A half circle from (0,0) to (100,0), sweep 1.
+ const parsed = parseSvgPathData('M 0 0 A 50 50 0 01100 0');
+ expect(parsed).toBeDefined();
+ const segments = parsed![0]!.segments;
+ expect(segments.length).toBe(2);
+ const last = segments[segments.length - 1]!;
+ if (last.kind !== 'cubic') {
+ throw new Error('expected the arc to emit cubic segments');
+ }
+ expect(last.to.x).toBeCloseTo(100, 9);
+ expect(last.to.y).toBeCloseTo(0, 9);
+ });
+
+ it('renders a zero-radius arc as a straight line to the endpoint, per the spec\'s own rule', () => {
+ const parsed = parseSvgPathData('M 0 0 A 0 0 0 0 1 10 0');
+ expect(parsed).toEqual([
+ { start: { x: 0, y: 0 }, closed: false, segments: [{ kind: 'line', to: { x: 10, y: 0 } }] },
+ ]);
+ });
+
+ it('splits a 180-degree arc into two cubics whose segment boundaries sit on the true circle', () => {
+ // From (0,0) to (100,0) with r=50 the chord is the diameter, so the centre is the midpoint (50,0) by symmetry and every segment boundary must sit exactly 50 from it (only the curve between boundaries is the bounded Bezier approximation).
+ const parsed = parseSvgPathData('M 0 0 A 50 50 0 0 1 100 0');
+ expect(parsed).toBeDefined();
+ const segments = parsed![0]!.segments;
+ expect(segments.length).toBe(2);
+ for (const segment of segments) {
+ if (segment.kind !== 'cubic') {
+ throw new Error('expected the arc to emit cubic segments');
+ }
+ expect(Math.hypot(segment.to.x - 50, segment.to.y)).toBeCloseTo(50, 6);
+ }
+ });
+
+ it('splits a 270-degree sweep into three cubics, every boundary on the true circle', () => {
+ // Clockwise on screen (sweep=1, y-down) from (50,0) through (0,50), (-50,0) to (0,-50) is the large 270-degree arc around the origin: exactly three at-most-90-degree segments, each boundary at distance 50 from the origin.
+ const parsed = parseSvgPathData('M 50 0 A 50 50 0 1 1 0 -50');
+ expect(parsed).toBeDefined();
+ const segments = parsed![0]!.segments;
+ expect(segments.length).toBe(3);
+ for (const segment of segments) {
+ if (segment.kind !== 'cubic') {
+ throw new Error('expected the arc to emit cubic segments');
+ }
+ expect(Math.hypot(segment.to.x, segment.to.y)).toBeCloseTo(50, 6);
+ }
+ const last = segments[segments.length - 1]!;
+ if (last.kind !== 'cubic') {
+ throw new Error('expected the arc to emit cubic segments');
+ }
+ expect(last.to.x).toBeCloseTo(0, 9);
+ expect(last.to.y).toBeCloseTo(-50, 9);
+ });
+
+ it('scales up radii too small to span the endpoints, exactly the factor that makes them span', () => {
+ // rx=1 cannot reach (100,0) from (0,0); F.6.6\'s correction scales it to 50, so the recovered curve still lands on the endpoint.
+ const parsed = parseSvgPathData('M 0 0 A 1 1 0 0 1 100 0');
+ expect(parsed).toBeDefined();
+ const segments = parsed![0]!.segments;
+ const last = segments[segments.length - 1]!;
+ if (last.kind !== 'cubic') {
+ throw new Error('expected the arc to emit cubic segments');
+ }
+ expect(last.to.x).toBeCloseTo(100, 9);
+ expect(last.to.y).toBeCloseTo(0, 9);
+ });
+
+ it('drops subpaths that carry no segments (a bare moveto, or M immediately followed by Z)', () => {
+ expect(parseSvgPathData('M 10 10')).toEqual([]);
+ expect(parseSvgPathData('M 10 10 Z')).toEqual([]);
+ });
+
+ it('returns undefined for malformed data rather than a partial parse', () => {
+ // A drawing command before the first moveto has no subpath to draw into; an unknown command letter and an argument-count shortfall have no meaning at all -- none may half-parse.
+ expect(parseSvgPathData('L 10 10')).toBeUndefined();
+ expect(parseSvgPathData('M 0 0 X 10 10')).toBeUndefined();
+ expect(parseSvgPathData('M 0 0 C 10 10 20')).toBeUndefined();
+ expect(parseSvgPathData('M 10 10 Z 5 5')).toBeUndefined();
+ });
+
+ it('reads a sign as itself a separator, so "10-10" is two numbers', () => {
+ const parsed = parseSvgPathData('M 10-10L20-20');
+ expect(parsed).toEqual([
+ { start: { x: 10, y: -10 }, closed: false, segments: [{ kind: 'line', to: { x: 20, y: -20 } }] },
+ ]);
+ });
+
+ it('reads exponent-notation numbers', () => {
+ const parsed = parseSvgPathData('M 1e1 2e1 L 1.5e1 .5e1');
+ expect(parsed).toEqual([
+ { start: { x: 10, y: 20 }, closed: false, segments: [{ kind: 'line', to: { x: 15, y: 5 } }] },
+ ]);
+ });
+});
diff --git a/src/svg/path.ts b/src/svg/path.ts
new file mode 100644
index 00000000..bbe30715
--- /dev/null
+++ b/src/svg/path.ts
@@ -0,0 +1,377 @@
+// The SVG path-data grammar (SVG 2, "B (Shape) Grammar"), parsed into the subpath vocabulary the ContentVector path variant already carries: subpaths of pure line and cubic Bezier segments in the same user space the d attribute itself lives in (the reader transforms them through the active CTM afterwards -- an affine maps lines to lines and cubics to cubics exactly, so that step loses nothing). This is deliberately a sibling of odf.js's own parseOdfPathData rather than an import of it: ODF's svg:d is the SVG subset LibreOffice emits (M/L/H/V/C/Z only), while a real-world .svg additionally uses S/Q/T/A and the relative lowercase forms, so this parser implements the full SVG command set on top of the identical scanner discipline.
+//
+// The three commands beyond the M/L/H/V/C/Z subset, and their exactness contracts:
+// - S (smooth cubic): the first control point is the reflection of the previous cubic's second control through the current point -- EXACT, it reproduces the author's intended curve with no approximation.
+// - Q (quadratic): elevated to a cubic EXACTLY -- a quadratic Bezier is the degree-2 special case of a cubic, with controls at the 2/3 marks toward the shared control point, so the elevated cubic is the same curve at every parameter.
+// - T (smooth quadratic): reflects the previous quadratic's control the way S does; when the previous command was not Q/T the SVG spec itself defines the reflected control as the current point, degenerating to a straight line -- exact either way.
+// - A (elliptical arc): the one genuinely approximate conversion. The standard endpoint-to-centre parameterisation (SVG 2, F.6.5) recovers the arc's centre and angles exactly; the arc is then split into segments of at most 90 degrees, each emitted as one cubic whose controls sit kappa = 4/3*tan(delta/4) along the segment's own boundary tangents -- the same bounded construction every Bezier-based renderer uses for circular arcs (the 90-degree worst case is the classical kappa approximation, accurate to a fraction of a point at document scale, and the error shrinks as segments shorten).
+
+export interface ParsedPathPoint {
+ readonly x: number;
+ readonly y: number;
+}
+
+// A discriminated union rather than optional control fields, so a consumer narrowing on kind: 'cubic' gets non-optional controls with no assertion -- the same discipline the ContentSubpath vocabulary itself uses.
+export type ParsedPathSegment =
+ | { readonly kind: 'line'; readonly to: ParsedPathPoint }
+ | { readonly kind: 'cubic'; readonly control1: ParsedPathPoint; readonly control2: ParsedPathPoint; readonly to: ParsedPathPoint };
+
+export interface ParsedPathSubpath {
+ readonly start: ParsedPathPoint;
+ readonly closed: boolean;
+ readonly segments: readonly ParsedPathSegment[];
+}
+
+const PATH_NUMBER = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/y;
+
+class PathScanner {
+ pos = 0;
+
+ constructor(readonly source: string) {}
+
+ private skipSeparators(): void {
+ while (this.pos < this.source.length && /[\s,]/.test(this.source[this.pos]!)) {
+ this.pos++;
+ }
+ }
+
+ atEnd(): boolean {
+ this.skipSeparators();
+ return this.pos >= this.source.length;
+ }
+
+ // A leading sign is itself a separator for the next number ("1-2" is two numbers -- the grammar's own rule), which the sticky pattern handles by matching the signed form first wherever the cursor sits.
+ nextNumber(): number | undefined {
+ this.skipSeparators();
+ PATH_NUMBER.lastIndex = this.pos;
+ const match = PATH_NUMBER.exec(this.source);
+ if (match === null) {
+ return undefined;
+ }
+ this.pos = PATH_NUMBER.lastIndex;
+ const value = Number(match[0]);
+ return Number.isFinite(value) ? value : undefined;
+ }
+
+ // Arc flags are the grammar's one single-character token: two [01] chars that may be packed against the surrounding numbers ("a1 1 0..." and "a110..." are both legal and distinct). Reading them as bare chars rather than numbers is the classic path-parser bug this method exists to avoid.
+ nextArcFlag(): 0 | 1 | undefined {
+ this.skipSeparators();
+ const char = this.source[this.pos];
+ if (char === '0' || char === '1') {
+ this.pos++;
+ return char === '0' ? 0 : 1;
+ }
+ return undefined;
+ }
+
+ nextCommandLetter(): string | undefined {
+ this.skipSeparators();
+ const char = this.source[this.pos];
+ if (char !== undefined && /[a-zA-Z]/.test(char)) {
+ this.pos++;
+ return char;
+ }
+ return undefined;
+ }
+}
+
+// The running cursor the commands mutate: the current point, the start of the open subpath (Z returns to it), and the previous cubic's second control / previous quadratic's control for S/T reflection. Both reflection anchors are cleared by every command outside their own family and by Z/M (a broken curve chain has nothing to reflect) -- exactly the spec's "the previous control point" scoping.
+interface PathCursor {
+ x: number;
+ y: number;
+ subpathStartX: number;
+ subpathStartY: number;
+ lastCubicControl?: ParsedPathPoint;
+ lastQuadControl?: ParsedPathPoint;
+}
+
+interface PathAccumulator {
+ subpaths: ParsedPathSubpath[];
+ current?: { start: ParsedPathPoint; segments: ParsedPathSegment[] };
+}
+
+function addLine(cursor: PathCursor, acc: PathAccumulator, to: ParsedPathPoint): void {
+ acc.current!.segments.push({ kind: 'line', to });
+ cursor.x = to.x;
+ cursor.y = to.y;
+ cursor.lastCubicControl = undefined;
+ cursor.lastQuadControl = undefined;
+}
+
+function addCubic(cursor: PathCursor, acc: PathAccumulator, control1: ParsedPathPoint, control2: ParsedPathPoint, to: ParsedPathPoint): void {
+ acc.current!.segments.push({ kind: 'cubic', control1, control2, to });
+ cursor.x = to.x;
+ cursor.y = to.y;
+ cursor.lastCubicControl = control2;
+ cursor.lastQuadControl = undefined;
+}
+
+// The exact quadratic -> cubic elevation described in the module note: the degree-3 curve with both controls at the 2/3 marks toward the quadratic's own control IS the quadratic, so nothing is approximated.
+function addQuad(cursor: PathCursor, acc: PathAccumulator, control: ParsedPathPoint, to: ParsedPathPoint): void {
+ const from = { x: cursor.x, y: cursor.y };
+ addCubic(
+ cursor,
+ acc,
+ { x: from.x + (2 / 3) * (control.x - from.x), y: from.y + (2 / 3) * (control.y - from.y) },
+ { x: to.x + (2 / 3) * (control.x - to.x), y: to.y + (2 / 3) * (control.y - to.y) },
+ to,
+ );
+ // T reflects the previous quadratic's OWN control (the point named in the Q command, not the elevated cubic's control), and the addCubic delegation above just cleared the quad family's state -- restore it so a following T reflects the right point.
+ cursor.lastQuadControl = control;
+}
+
+// One elliptical-arc command -> cubic segments appended to the open subpath. Implements SVG 2 F.6.5's endpoint-to-centre conversion (including the out-of-range radii correction, which scales the radii up by exactly the factor that makes the endpoints reachable), then walks the arc in segments of at most 90 degrees, each emitted as a cubic whose controls sit kappa along the segment's own boundary tangents.
+function addArc(cursor: PathCursor, acc: PathAccumulator, rx: number, ry: number, xRotDeg: number, largeArc: 0 | 1, sweep: 0 | 1, to: ParsedPathPoint): void {
+ const from = { x: cursor.x, y: cursor.y };
+ if ((rx === 0 || ry === 0) || (from.x === to.x && from.y === to.y)) {
+ // A zero radius renders as a straight line to the endpoint (the spec's own rule); a coincident endpoint has no arc to draw at all.
+ if (from.x !== to.x || from.y !== to.y) {
+ addLine(cursor, acc, to);
+ }
+ return;
+ }
+ const xRot = (xRotDeg * Math.PI) / 180;
+ const cosRot = Math.cos(xRot);
+ const sinRot = Math.sin(xRot);
+ const dx = (from.x - to.x) / 2;
+ const dy = (from.y - to.y) / 2;
+ // The endpoint midpoint translated into the arc's own rotated frame -- F.6.5's (x1', y1').
+ const x1p = cosRot * dx + sinRot * dy;
+ const y1p = -sinRot * dx + cosRot * dy;
+
+ // F.6.6: if the stated radii cannot span the endpoints at all, scale both up by the one factor that makes the centre equation solvable.
+ const radiiSpan = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
+ if (radiiSpan > 1) {
+ const factor = Math.sqrt(radiiSpan);
+ rx *= factor;
+ ry *= factor;
+ }
+
+ // The centre, with the sign chosen so the large-arc/sweep flag pair picks one of the two candidates.
+ const sign = largeArc !== sweep ? 1 : -1;
+ const numerator = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
+ const denominator = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
+ const coefficient = denominator === 0 ? 0 : sign * Math.sqrt(Math.max(numerator, 0) / denominator);
+ const cxp = (coefficient * rx * y1p) / ry;
+ const cyp = -(coefficient * ry * x1p) / rx;
+ const cx = cosRot * cxp - sinRot * cyp + (from.x + to.x) / 2;
+ const cy = sinRot * cxp + cosRot * cyp + (from.y + to.y) / 2;
+
+ // The signed angle between two unit vectors, F.6.5's own helper.
+ const angleBetween = (ux: number, uy: number, vx: number, vy: number): number => {
+ const dot = ux * vx + uy * vy;
+ const len = Math.hypot(ux, uy) * Math.hypot(vx, vy);
+ if (len === 0) {
+ return 0;
+ }
+ const inner = Math.min(1, Math.max(-1, dot / len));
+ const result = Math.acos(inner);
+ return ux * vy - uy * vx < 0 ? -result : result;
+ };
+
+ const theta1 = angleBetween(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
+ let deltaTheta = angleBetween((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
+ if (sweep === 0 && deltaTheta > 0) {
+ deltaTheta -= 2 * Math.PI;
+ } else if (sweep === 1 && deltaTheta < 0) {
+ deltaTheta += 2 * Math.PI;
+ }
+
+ // At most 90 degrees per cubic segment -- ceil(|delta| / (pi/2)), never fewer than one segment even for a tiny arc.
+ const segmentCount = Math.max(1, Math.ceil(Math.abs(deltaTheta) / (Math.PI / 2)));
+ const deltaPerSegment = deltaTheta / segmentCount;
+ const kappa = (4 / 3) * Math.tan(deltaPerSegment / 4);
+
+ // The arc's own parametrisation and tangent in user space: point(t) = centre + R(rot) * (rx cos t, ry sin t), tangent(t) = R(rot) * (-rx sin t, ry cos t). A cubic segment from t0 to t1 takes its controls kappa times the tangent length away from each endpoint (P1 = P0 + kappa*T(t0), P2 = P3 - kappa*T(t1)) -- the standard bounded arc-to-cubic construction.
+ const pointAt = (t: number): ParsedPathPoint => ({
+ x: cx + rx * cosRot * Math.cos(t) - ry * sinRot * Math.sin(t),
+ y: cy + rx * sinRot * Math.cos(t) + ry * cosRot * Math.sin(t),
+ });
+ const tangentAt = (t: number): ParsedPathPoint => ({
+ x: -rx * cosRot * Math.sin(t) - ry * sinRot * Math.cos(t),
+ y: -rx * sinRot * Math.sin(t) + ry * cosRot * Math.cos(t),
+ });
+
+ for (let i = 0; i < segmentCount; i++) {
+ const start = pointAt(theta1 + deltaPerSegment * i);
+ const end = pointAt(theta1 + deltaPerSegment * (i + 1));
+ const t0 = tangentAt(theta1 + deltaPerSegment * i);
+ const t1 = tangentAt(theta1 + deltaPerSegment * (i + 1));
+ addCubic(
+ cursor,
+ acc,
+ { x: start.x + kappa * t0.x, y: start.y + kappa * t0.y },
+ { x: end.x - kappa * t1.x, y: end.y - kappa * t1.y },
+ end,
+ );
+ }
+}
+
+// The number of coordinate values each command consumes per repetition; Z consumes none.
+const COMMAND_ARITY: Record = {
+ M: 2, m: 2,
+ L: 2, l: 2,
+ H: 1, h: 1,
+ V: 1, v: 1,
+ C: 6, c: 6,
+ S: 4, s: 4,
+ Q: 4, q: 4,
+ T: 2, t: 2,
+ A: 7, a: 7,
+};
+
+export function parseSvgPathData(d: string): readonly ParsedPathSubpath[] | undefined {
+ const scanner = new PathScanner(d.trim());
+ const cursor: PathCursor = { x: 0, y: 0, subpathStartX: 0, subpathStartY: 0 };
+ const acc: PathAccumulator = { subpaths: [] };
+ let command: string | undefined;
+
+ const closeSubpath = (): void => {
+ if (acc.current !== undefined) {
+ acc.subpaths.push({ start: acc.current.start, closed: true, segments: acc.current.segments });
+ acc.current = undefined;
+ }
+ cursor.x = cursor.subpathStartX;
+ cursor.y = cursor.subpathStartY;
+ cursor.lastCubicControl = undefined;
+ cursor.lastQuadControl = undefined;
+ };
+
+ const startSubpath = (at: ParsedPathPoint): void => {
+ // A moveto flushes any open subpath as unclosed before opening the next.
+ if (acc.current !== undefined) {
+ acc.subpaths.push({ start: acc.current.start, closed: false, segments: acc.current.segments });
+ acc.current = undefined;
+ }
+ acc.current = { start: at, segments: [] };
+ cursor.x = at.x;
+ cursor.y = at.y;
+ cursor.subpathStartX = at.x;
+ cursor.subpathStartY = at.y;
+ cursor.lastCubicControl = undefined;
+ cursor.lastQuadControl = undefined;
+ };
+
+ while (!scanner.atEnd()) {
+ const letter = scanner.nextCommandLetter();
+ if (letter !== undefined) {
+ command = letter;
+ } else if (command === undefined) {
+ return undefined;
+ }
+ const active = command;
+
+ if (active === 'Z' || active === 'z') {
+ closeSubpath();
+ // Z takes no arguments, so an implicit repetition right after it is malformed rather than another close.
+ command = undefined;
+ continue;
+ }
+ const arity = COMMAND_ARITY[active];
+ if (arity === undefined) {
+ return undefined;
+ }
+
+ // One command letter's argument stream is a series of full coordinate groups (implicit repetition); the first group of M/m is the moveto itself and every later group degenerates to a lineto per the grammar.
+ let groupIndex = 0;
+ for (;;) {
+ if (active === 'A' || active === 'a') {
+ // Arc arguments are not plain numbers: the two flags are single chars that may legally fuse with adjacent numbers, so they are read positionally rather than through nextNumber.
+ const rx = scanner.nextNumber();
+ const ry = scanner.nextNumber();
+ const rotation = scanner.nextNumber();
+ const largeArc = scanner.nextArcFlag();
+ const sweep = scanner.nextArcFlag();
+ const x = scanner.nextNumber();
+ const y = scanner.nextNumber();
+ if (rx === undefined || ry === undefined || rotation === undefined || largeArc === undefined || sweep === undefined || x === undefined || y === undefined) {
+ return undefined;
+ }
+ if (acc.current === undefined) {
+ return undefined;
+ }
+ const to = active === 'A' ? { x, y } : { x: cursor.x + x, y: cursor.y + y };
+ addArc(cursor, acc, Math.abs(rx), Math.abs(ry), rotation, largeArc, sweep, to);
+ } else {
+ const args: number[] = [];
+ for (let i = 0; i < arity; i++) {
+ const value = scanner.nextNumber();
+ if (value === undefined) {
+ return undefined;
+ }
+ args.push(value);
+ }
+ const relative = active === active.toLowerCase() && active !== active.toUpperCase();
+ const point = (x: number, y: number): ParsedPathPoint => (relative ? { x: cursor.x + x, y: cursor.y + y } : { x, y });
+ const upper = active.toUpperCase();
+
+ if (upper === 'M') {
+ if (groupIndex === 0) {
+ startSubpath(point(args[0]!, args[1]!));
+ } else {
+ if (acc.current === undefined) {
+ return undefined;
+ }
+ addLine(cursor, acc, point(args[0]!, args[1]!));
+ }
+ } else {
+ if (acc.current === undefined) {
+ // Any drawing command before the first moveto is malformed -- there is no open subpath to draw into.
+ return undefined;
+ }
+ switch (upper) {
+ case 'L':
+ addLine(cursor, acc, point(args[0]!, args[1]!));
+ break;
+ case 'H':
+ addLine(cursor, acc, relative ? { x: cursor.x + args[0]!, y: cursor.y } : { x: args[0]!, y: cursor.y });
+ break;
+ case 'V':
+ addLine(cursor, acc, relative ? { x: cursor.x, y: cursor.y + args[0]! } : { x: cursor.x, y: args[0]! });
+ break;
+ case 'C':
+ addCubic(cursor, acc, point(args[0]!, args[1]!), point(args[2]!, args[3]!), point(args[4]!, args[5]!));
+ break;
+ case 'S': {
+ const from = { x: cursor.x, y: cursor.y };
+ const reflected = cursor.lastCubicControl === undefined ? from : { x: 2 * from.x - cursor.lastCubicControl.x, y: 2 * from.y - cursor.lastCubicControl.y };
+ addCubic(cursor, acc, reflected, point(args[0]!, args[1]!), point(args[2]!, args[3]!));
+ break;
+ }
+ case 'Q':
+ addQuad(cursor, acc, point(args[0]!, args[1]!), point(args[2]!, args[3]!));
+ break;
+ case 'T': {
+ const from = { x: cursor.x, y: cursor.y };
+ const to = point(args[0]!, args[1]!);
+ const control = cursor.lastQuadControl === undefined ? from : { x: 2 * from.x - cursor.lastQuadControl.x, y: 2 * from.y - cursor.lastQuadControl.y };
+ addQuad(cursor, acc, control, to);
+ break;
+ }
+ }
+ }
+ }
+ groupIndex++;
+ // A further group follows only if the scanner sits on a number or sign (a command letter ends the stream); atEnd() consumes trailing separators, and nextNumber() would over-read a following letter's arguments as this command's, so the boundary is probed exactly here.
+ if (!hasNextNumberToken(scanner)) {
+ break;
+ }
+ }
+ }
+
+ if (acc.current !== undefined) {
+ acc.subpaths.push({ start: acc.current.start, closed: false, segments: acc.current.segments });
+ }
+ // A subpath with no segments (a bare moveto, or an M immediately followed by Z) paints nothing; dropping it here keeps every emitted subpath drawable.
+ return acc.subpaths.filter((subpath) => subpath.segments.length > 0);
+}
+
+// Probes whether the next non-separator character continues a number (digit, dot, or sign) without consuming anything -- the implicit-repetition boundary test. A sticky zero-width lookahead on the shared pattern would advance lastIndex, so this peeks the raw character class instead.
+function hasNextNumberToken(scanner: PathScanner): boolean {
+ let probe = scanner.pos;
+ while (probe < scanner.source.length && /[\s,]/.test(scanner.source[probe]!)) {
+ probe++;
+ }
+ const char = scanner.source[probe];
+ return char !== undefined && /[0-9.\-+]/.test(char);
+}
diff --git a/src/svg/read-write.test.ts b/src/svg/read-write.test.ts
new file mode 100644
index 00000000..6c3df0f8
--- /dev/null
+++ b/src/svg/read-write.test.ts
@@ -0,0 +1,345 @@
+import { describe, expect, it } from 'vitest';
+import type { ContentDocument, ContentVector } from 'document-schema.js';
+import { CONTENT_FORMAT_VERSION } from 'document-schema.js';
+import type { SvgDiagnostic } from './diagnostics';
+import { readSvgContent } from './read';
+import { SvgMissingRootElementError } from './read';
+import { buildSvgText } from './write';
+import { SvgMultiPageNotSpecifiedError, SvgPageNotFoundError, SvgUnsupportedDocumentKindError } from './write';
+import { decodeSvgText, encodeSvgText, SvgInvalidUtf8Error } from './text';
+
+// The read tests below want an identity root map -- width/height in pt equal to the viewBox extents -- so every user-unit coordinate lands in the page-point space unchanged and assertions read the SVG's own numbers back.
+const IDENTITY_ROOT = '';
+const svg = (inner: string, root = IDENTITY_ROOT): string => `${root}${inner} `;
+
+function readVectors(text: string, diagnostics?: SvgDiagnostic[]): ContentVector[] {
+ const document = readSvgContent(text, diagnostics === undefined ? undefined : { onSvgDiagnostic: (diagnostic) => diagnostics.push(diagnostic) });
+ if (document.kind !== 'drawing') {
+ throw new Error('expected a drawing ContentDocument');
+ }
+ return document.pages[0]!.vectors;
+}
+
+function drawingDocument(pages: readonly { readonly vectors: readonly ContentVector[] }[], title?: string): ContentDocument {
+ return {
+ kind: 'drawing',
+ formatVersion: CONTENT_FORMAT_VERSION,
+ metadata: title === undefined ? {} : { title },
+ pages: pages.map((page) => ({ size: { widthPt: 100, heightPt: 60 }, shapes: [], vectors: [...page.vectors] })),
+ };
+}
+
+describe('readSvgContent', () => {
+ it('maps the six shape primitives onto ContentVector kinds', () => {
+ const vectors = readVectors(svg(`
+
+
+
+
+
+
+ `));
+ expect(vectors.map((vector) => vector.kind)).toEqual(['rect', 'ellipse', 'ellipse', 'line', 'path', 'path']);
+ expect(vectors[0]).toMatchObject({ kind: 'rect', frame: { xPt: 10, yPt: 10, widthPt: 40, heightPt: 20 }, fill: { r: 1, g: 0, b: 0 } });
+ expect(vectors[1]).toMatchObject({ kind: 'ellipse', frame: { xPt: 20, yPt: 30, widthPt: 20, heightPt: 20 } });
+ expect(vectors[2]).toMatchObject({ kind: 'ellipse', frame: { xPt: 45, yPt: 30, widthPt: 30, heightPt: 20 } });
+ expect(vectors[3]).toMatchObject({ kind: 'line', from: { xPt: 0, yPt: 0 }, to: { xPt: 100, yPt: 60 }, stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 } });
+ // A polyline is an open path: the frame is the tight box of its points and the subpath carries them rebased into that frame's local space.
+ expect(vectors[4]).toMatchObject({ kind: 'path', frame: { xPt: 0, yPt: 0, widthPt: 20, heightPt: 20 }, subpaths: [{ start: { xPt: 0, yPt: 0 }, closed: false, segments: [{ kind: 'line', to: { xPt: 10, yPt: 20 } }, { kind: 'line', to: { xPt: 20, yPt: 0 } }] }] });
+ expect(vectors[5]).toMatchObject({ kind: 'path', frame: { xPt: 20, yPt: 0, widthPt: 20, heightPt: 20 }, subpaths: [{ start: { xPt: 10, yPt: 0 }, closed: true, segments: [{ kind: 'line', to: { xPt: 20, yPt: 20 } }, { kind: 'line', to: { xPt: 0, yPt: 20 } }] }] });
+ });
+
+ it('reads a bare d attribute as a path whose frame is the tight hull of all points including cubic controls', () => {
+ const vectors = readVectors(svg(' '));
+ expect(vectors[0]).toMatchObject({ kind: 'path', frame: { xPt: 10, yPt: 10, widthPt: 80, heightPt: 40 }, subpaths: [{ start: { xPt: 0, yPt: 0 }, closed: false, segments: [{ kind: 'line', to: { xPt: 80, yPt: 40 } }] }], stroke: { color: { r: 0, g: 0, b: 1 }, widthPt: 1 } });
+ });
+
+ it('builds a rounded rect as a path of four edges and four kappa corners', () => {
+ const vectors = readVectors(svg(' '));
+ const vector = vectors[0];
+ if (vector?.kind !== 'path') {
+ throw new Error('expected a path vector');
+ }
+ const subpath = vector.subpaths[0];
+ expect(subpath?.closed).toBe(true);
+ expect(subpath?.segments.filter((segment) => segment.kind === 'line')).toHaveLength(4);
+ expect(subpath?.segments.filter((segment) => segment.kind === 'cubic')).toHaveLength(4);
+ });
+
+ it('reads the root title into metadata.title, entity-decoded', () => {
+ const document = readSvgContent(svg('My & drawing '));
+ if (document.kind !== 'drawing') {
+ throw new Error('expected a drawing ContentDocument');
+ }
+ expect(document.metadata.title).toBe('My & drawing');
+ });
+
+ it('throws SvgMissingRootElementError when no svg root element is present', () => {
+ expect(() => readSvgContent(' ')).toThrow(SvgMissingRootElementError);
+ });
+
+ it('matches element and attribute names namespace-agnostically', () => {
+ const document = readSvgContent(' ');
+ if (document.kind !== 'drawing') {
+ throw new Error('expected a drawing ContentDocument');
+ }
+ expect(document.pages[0]?.vectors[0]).toMatchObject({ kind: 'rect', frame: { xPt: 10, yPt: 10, widthPt: 5, heightPt: 5 } });
+ });
+});
+
+describe('readSvgContent root geometry', () => {
+ it('falls back to the viewBox extents as the page size when width/height are absent, at a 1:1 map', () => {
+ const document = readSvgContent(' ');
+ if (document.kind !== 'drawing') {
+ throw new Error('expected a drawing ContentDocument');
+ }
+ expect(document.pages[0]?.size).toEqual({ widthPt: 100, heightPt: 60 });
+ expect(document.pages[0]?.vectors[0]).toMatchObject({ frame: { xPt: 10, yPt: 10, widthPt: 5, heightPt: 5 } });
+ });
+
+ it('scales user units at the exact 0.75pt/px ratio when only width/height size the page', () => {
+ // No viewBox: one user unit is one CSS px = 0.75pt, so 40 user units of width become 30pt.
+ const vectors = readVectors(' ');
+ expect(vectors[0]).toMatchObject({ frame: { xPt: 7.5, yPt: 7.5, widthPt: 30, heightPt: 15 } });
+ });
+
+ it('assumes the CSS default replaced-element size (300x150 px) when nothing sizes the root, and names it', () => {
+ const diagnostics: SvgDiagnostic[] = [];
+ const document = readSvgContent(' ', { onSvgDiagnostic: (diagnostic) => diagnostics.push(diagnostic) });
+ if (document.kind !== 'drawing') {
+ throw new Error('expected a drawing ContentDocument');
+ }
+ expect(document.pages[0]?.size).toEqual({ widthPt: 225, heightPt: 112.5 });
+ expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain('svg/default-size-assumed');
+ });
+
+ it('discards a lone width or height (the CSS intrinsic-sizing rule) and falls to the viewBox', () => {
+ const document = readSvgContent(' ');
+ if (document.kind !== 'drawing') {
+ throw new Error('expected a drawing ContentDocument');
+ }
+ expect(document.pages[0]?.size).toEqual({ widthPt: 50, heightPt: 25 });
+ expect(document.pages[0]?.vectors[0]).toMatchObject({ frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 5 } });
+ });
+
+ it('translates a viewBox with a non-zero origin so the viewBox minimum lands at the page origin', () => {
+ const vectors = readVectors(svg(' ', ''));
+ expect(vectors[0]).toMatchObject({ frame: { xPt: 0, yPt: 0, widthPt: 40, heightPt: 20 } });
+ });
+
+ it('stretches a viewBox whose aspect differs from the page, under a diagnostic, and honours preserveAspectRatio="none" silently', () => {
+ const diagnostics: SvgDiagnostic[] = [];
+ const stretched = '';
+ const vectors = readVectors(`${stretched} `, diagnostics);
+ expect(vectors[0]).toMatchObject({ frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 50 } });
+ expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain('svg/preserve-aspect-ratio-stretched');
+ const silent: SvgDiagnostic[] = [];
+ readVectors(`${stretched.replace('viewBox="0 0 100 100"', 'viewBox="0 0 100 100" preserveAspectRatio="none"')} `, silent);
+ expect(silent.map((diagnostic) => diagnostic.code)).not.toContain('svg/preserve-aspect-ratio-stretched');
+ });
+});
+
+describe('readSvgContent paint', () => {
+ it('paints an absent fill black and an absent stroke not at all -- SVG\'s own defaults', () => {
+ const vectors = readVectors(svg(' '));
+ expect(vectors[0]).toMatchObject({ fill: { r: 0, g: 0, b: 0 } });
+ expect(vectors[0]?.stroke).toBeUndefined();
+ });
+
+ it('unpaints fill="none" and keeps the element only when a stroke paints it', () => {
+ const diagnostics: SvgDiagnostic[] = [];
+ const vectors = readVectors(svg(' '), diagnostics);
+ expect(vectors).toHaveLength(1);
+ const kept = vectors[0];
+ if (kept?.kind !== 'rect') {
+ throw new Error('expected a rect vector');
+ }
+ expect(kept.fill).toBeUndefined();
+ expect(kept).toMatchObject({ stroke: { color: { r: 0, g: 0, b: 1 }, widthPt: 1 } });
+ expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain('svg/element-skipped');
+ });
+
+ it('inherits presentation attributes from groups, with the child\'s own value winning', () => {
+ const vectors = readVectors(svg(' '));
+ expect(vectors[0]).toMatchObject({ fill: { r: 1, g: 0, b: 0 } });
+ expect(vectors[1]).toMatchObject({ fill: { r: 0, g: 0, b: 1 } });
+ });
+
+ it('scales stroke width by the CTM\'s mean scale and carries dash styles onto the stroke enum', () => {
+ const vectors = readVectors(svg(' '));
+ expect(vectors[0]?.stroke).toMatchObject({ widthPt: 4, style: 'dashed' });
+ const dotted = readVectors(svg(' '));
+ expect(dotted[0]?.stroke).toMatchObject({ style: 'dotted' });
+ });
+});
+
+describe('readSvgContent transforms', () => {
+ it('composes group transforms with the viewBox map into every coordinate', () => {
+ const vectors = readVectors(svg(' '));
+ expect(vectors[0]).toMatchObject({ frame: { xPt: 10, yPt: 5, widthPt: 10, heightPt: 10 } });
+ });
+
+ it('emits a rotated rect as the scaled pre-rotation box centred on the transformed centre, plus rotationDeg', () => {
+ // rotate(90) moves the box centre (20,5) to (-5,20); the frame is the 20x10 pre-rotation box centred there, and the renderer\'s own rotation about that centre lands on the true corners.
+ const vectors = readVectors(svg(' '));
+ expect(vectors[0]).toMatchObject({ kind: 'rect', frame: { xPt: -15, yPt: 15, widthPt: 20, heightPt: 10 }, rotationDeg: 90 });
+ });
+
+ it('narrows a sheared circle to the path variant, since only paths express a skewed conic', () => {
+ const vectors = readVectors(svg(' '));
+ expect(vectors[0]?.kind).toBe('path');
+ });
+
+ it('honours a transform attribute on the shape element itself, not only on groups', () => {
+ // The write side emits rotation exactly this way -- a transform directly on the rect -- so the reader must apply an element's own transform for its own output to round trip. The rotation comes back through atan2, so 30 degrees carries double-precision dust, not the literal 30.
+ const vectors = readVectors(svg(' '));
+ const rotated = vectors[0];
+ if (rotated?.kind !== 'rect') {
+ throw new Error('expected a rect vector');
+ }
+ expect(rotated).toMatchObject({ frame: { xPt: 10, yPt: 20, widthPt: 30, heightPt: 10 } });
+ expect(rotated.rotationDeg).toBeCloseTo(30, 9);
+ });
+});
+
+describe('readSvgContent diagnostics', () => {
+ it('names every out-of-scope construct through the diagnostic channel, never a silent drop', () => {
+ const diagnostics: SvgDiagnostic[] = [];
+ readVectors(svg(`
+
+ Hello
+
+
+
+
+
+
+
+
+
+ `), diagnostics);
+ const codes = diagnostics.map((diagnostic) => diagnostic.code);
+ // A defs block and its gradient definition paint nothing by design -- only the element that references the gradient fires gradient-unsupported, exactly once.
+ expect(codes.filter((code) => code === 'svg/gradient-unsupported')).toHaveLength(1);
+ for (const code of ['svg/text-unsupported', 'svg/image-unsupported', 'svg/use-unsupported', 'svg/gradient-unsupported', 'svg/element-unsupported', 'svg/element-skipped', 'svg/paint-unsupported', 'svg/css-style-ignored', 'svg/opacity-ignored']) {
+ expect(codes).toContain(code);
+ }
+ expect(codes.filter((code) => code === 'svg/css-style-ignored')).toHaveLength(2);
+ });
+});
+
+describe('buildSvgText', () => {
+ it('writes each vector kind as its own shape element at 1:1 page points', () => {
+ const text = buildSvgText(drawingDocument([{ vectors: [
+ { kind: 'rect', frame: { xPt: 10, yPt: 20, widthPt: 30, heightPt: 40 }, fill: { r: 1, g: 0, b: 0 }, paintOrder: 0 },
+ { kind: 'ellipse', frame: { xPt: 10, yPt: 20, widthPt: 30, heightPt: 40 }, paintOrder: 1 },
+ { kind: 'line', from: { xPt: 0, yPt: 0 }, to: { xPt: 10, yPt: 10 }, stroke: { color: { r: 0, g: 0, b: 1 }, widthPt: 1 }, paintOrder: 2 },
+ { kind: 'path', frame: { xPt: 10, yPt: 20, widthPt: 20, heightPt: 20 }, subpaths: [{ start: { xPt: 0, yPt: 0 }, closed: true, segments: [{ kind: 'line', to: { xPt: 20, yPt: 20 } }] }], fill: { r: 1, g: 1, b: 0 }, paintOrder: 3 },
+ ] }]));
+ expect(text).toContain('');
+ expect(text).toContain(' ');
+ // An ellipse without a fill writes fill="none", since an absent fill paints nothing rather than SVG's black default -- which would change the drawing's appearance.
+ expect(text).toContain(' ');
+ expect(text).toContain(' ');
+ expect(text).toContain(' ');
+ });
+
+ it('writes the stroke styles, and reports double as solid under a diagnostic', () => {
+ const diagnostics: SvgDiagnostic[] = [];
+ const text = buildSvgText(drawingDocument([{ vectors: [
+ { kind: 'line', from: { xPt: 0, yPt: 0 }, to: { xPt: 10, yPt: 0 }, stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: 'dashed' }, paintOrder: 0 },
+ { kind: 'line', from: { xPt: 0, yPt: 5 }, to: { xPt: 10, yPt: 5 }, stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: 'dotted' }, paintOrder: 1 },
+ { kind: 'line', from: { xPt: 0, yPt: 10 }, to: { xPt: 10, yPt: 10 }, stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1, style: 'double' }, paintOrder: 2 },
+ ] }]), { onSvgDiagnostic: (diagnostic) => diagnostics.push(diagnostic) });
+ expect(text).toContain('stroke-dasharray="6 4"');
+ expect(text).toContain('stroke-dasharray="1 3" stroke-linecap="round"');
+ expect(text).not.toContain('stroke-dasharray="double"');
+ expect(diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['svg/stroke-style-unsupported']);
+ });
+
+ it('writes rotationDeg as a rotate() transform about the frame\'s own centre', () => {
+ const text = buildSvgText(drawingDocument([{ vectors: [{ kind: 'rect', frame: { xPt: 10, yPt: 20, widthPt: 30, heightPt: 20 }, rotationDeg: 30, paintOrder: 0 }] }]));
+ expect(text).toContain('transform="rotate(30 25 30)"');
+ });
+
+ it('writes metadata.title as an escaped title element and omits it when absent', () => {
+ expect(buildSvgText(drawingDocument([{ vectors: [] }]), undefined)).not.toContain('');
+ const titled = buildSvgText(drawingDocument([{ vectors: [] }], 'A & B '));
+ expect(titled).toContain('A & B <drawing> ');
+ });
+
+ it('throws SvgUnsupportedDocumentKindError for a non-drawing ContentDocument', () => {
+ const wordprocessing: ContentDocument = { kind: 'wordprocessing', formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, sections: [] };
+ expect(() => buildSvgText(wordprocessing)).toThrow(SvgUnsupportedDocumentKindError);
+ });
+
+ it('requires a page index for a multi-page document, naming the count, and writes the selected page', () => {
+ const document = drawingDocument([
+ { vectors: [{ kind: 'rect', frame: { xPt: 0, yPt: 0, widthPt: 5, heightPt: 5 }, paintOrder: 0 }] },
+ { vectors: [{ kind: 'rect', frame: { xPt: 50, yPt: 30, widthPt: 5, heightPt: 5 }, paintOrder: 0 }] },
+ ]);
+ expect(() => buildSvgText(document)).toThrow(SvgMultiPageNotSpecifiedError);
+ try {
+ buildSvgText(document);
+ } catch (error) {
+ if (error instanceof SvgMultiPageNotSpecifiedError) {
+ expect(error.pageCount).toBe(2);
+ }
+ }
+ expect(buildSvgText(document, { page: 1 })).toContain(' {
+ const document = drawingDocument([{ vectors: [] }]);
+ expect(() => buildSvgText(document, { page: 5 })).toThrow(SvgPageNotFoundError);
+ const empty: ContentDocument = { kind: 'drawing', formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, pages: [] };
+ expect(() => buildSvgText(empty)).toThrow(SvgPageNotFoundError);
+ });
+
+ it('reports draw:frame content through svg/shape-unsupported rather than silently dropping it', () => {
+ const diagnostics: SvgDiagnostic[] = [];
+ const document: ContentDocument = {
+ kind: 'drawing',
+ formatVersion: CONTENT_FORMAT_VERSION,
+ metadata: {},
+ pages: [{
+ size: { widthPt: 100, heightPt: 60 },
+ shapes: [{ name: 'TextBox 1', frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, insetLeftPt: 0, insetTopPt: 0, insetRightPt: 0, insetBottomPt: 0, blocks: [] }],
+ vectors: [],
+ }],
+ };
+ buildSvgText(document, { onSvgDiagnostic: (diagnostic) => diagnostics.push(diagnostic) });
+ expect(diagnostics).toEqual([{ code: 'svg/shape-unsupported', detail: 'TextBox 1: draw:frame text/image/table content has no SVG vector representation' }]);
+ });
+});
+
+describe('readSvgContent -> buildSvgText round trip', () => {
+ it('round-trips the vector set exactly, rotation included, since write emits a 1:1 viewBox the reader maps through the identity', () => {
+ const source = svg(`
+
+
+
+
+
+ `);
+ const first = readSvgContent(source);
+ const written = buildSvgText(first);
+ const second = readSvgContent(written);
+ if (first.kind !== 'drawing' || second.kind !== 'drawing') {
+ throw new Error('expected drawing ContentDocuments');
+ }
+ expect(second.pages[0]?.vectors).toEqual(first.pages[0]?.vectors);
+ expect(second.pages[0]?.size).toEqual(first.pages[0]?.size);
+ expect(second.metadata).toEqual(first.metadata);
+ });
+});
+
+describe('decodeSvgText / encodeSvgText', () => {
+ it('round-trips text through the byte boundary', () => {
+ expect(decodeSvgText(encodeSvgText('café — ☃ '))).toBe('café — ☃ ');
+ });
+
+ it('throws SvgInvalidUtf8Error on malformed UTF-8 rather than producing U+FFFD replacement characters', () => {
+ expect(() => decodeSvgText(new Uint8Array([0xff, 0xfe, 0x00]))).toThrow(SvgInvalidUtf8Error);
+ });
+});
diff --git a/src/svg/read.ts b/src/svg/read.ts
new file mode 100644
index 00000000..e1b6bffc
--- /dev/null
+++ b/src/svg/read.ts
@@ -0,0 +1,548 @@
+import type { Box, Color, ContentDocument, ContentDrawPage, ContentStroke, ContentSubpath, ContentVector } from 'document-schema.js';
+import { CONTENT_FORMAT_VERSION } from 'document-schema.js';
+import type { SvgDiagnosticCode, SvgDiagnosticSink } from './diagnostics';
+import { parseSvgDashStyle, parseSvgPaint } from './paint';
+import { parseSvgPathData } from './path';
+import type { ParsedPathPoint, ParsedPathSegment, ParsedPathSubpath } from './path';
+import { parseSvgLengthPt, parseSvgUserUnits, parseSvgViewBox } from './units';
+import type { SvgViewBox } from './units';
+import { applyMatrix, composeMatrices, isAxisAligned, isNonReflectingSimilarity, meanScaleFactor, parseSvgTransform, similarityRotationDeg } from './transform';
+import type { AffineMatrix } from './transform';
+import { parseXml } from 'odf.js';
+import type { XmlElement, XmlNode } from 'odf.js';
+import { decodeEntities } from 'ooxml.js';
+
+// SVG text -> ContentDocument (the drawing variant): the fifth adapter family, sharing the drawing variant with odg. The walk maps the six vector shape primitives (rect/circle/ellipse/line/polyline/polygon/path) onto ContentVector, carrying group transforms and the root viewBox -> viewport map as one affine matrix every coordinate passes through, so SVG rides the existing drawing layout engine with zero new layout code. Scope is vector graphics only, and every limit is named through onSvgDiagnostic rather than silently dropped: text, images, use references, gradients, filters, CSS styling, and opacity are all out of scope (see src/svg/diagnostics.ts for the full vocabulary).
+//
+// COORDINATE CONVENTIONS: SVG user space is y-down/top-left-origin, which is exactly the convention ContentVector's own frames carry in the drawing variant's page model (src/layout/drawing.ts flips into PDF's bottom-left origin at the layout boundary, not here). Every parsed coordinate therefore stays in y-down page-point space end to end: user units flow through rootMap (the viewBox -> viewport scale) composed with each ancestor group's transform, and the resulting page-point values become the vector's frame / from / to directly. A length carrying an absolute unit (mm, pt, ...) on a geometry attribute resolves to user units against CSS px (1 user unit = 1px = 0.75pt exactly), matching SVG's own rule that absolute lengths convert into the initial user coordinate system before any viewBox scale applies.
+//
+// ROTATED RECT/ELLIPSE FRAMES: for a similarity CTM (uniform scale + rotation) the emitted frame is the SCALED PRE-ROTATION box centred on the transformed centre, with rotationDeg alongside -- the exact contract src/layout/drawing.ts implements, where a rotated vector renders by rotating the frame's own corners/curve points about the frame's own centre. A tight bbox of the rotated corners would instead be the wrong frame: the renderer would inscribe a shape in it and rotate that again, growing the shape.
+
+// Named ReadSvgContentOptions rather than SvgReadOptions because convert.ts declares its own SvgReadOptions as the ergonomic intersection type the named svg-sourced conversions expose -- the identical split csv holds between ReadCsvContentOptions and CsvReadOptions, so the two layers never collide on this package's export surface.
+export interface ReadSvgContentOptions {
+ readonly onSvgDiagnostic?: SvgDiagnosticSink;
+}
+
+// A recognised svg byte stream whose root element is not -- a named class matching this package's convention for every other "recognised but unsupported" input, so a caller can branch on it with instanceof rather than string-matching a thrown Error's own message.
+export class SvgMissingRootElementError extends Error {
+ constructor() {
+ super('svg text must contain an root element');
+ this.name = 'SvgMissingRootElementError';
+ }
+}
+
+// The CSS default replaced-element size every browser assumes for an with no intrinsic size (CSS sizing level 3's default object size): 300x150 px, i.e. 225x112.5 pt at the exact 0.75 pt/px ratio. Assumed only when the root carries neither usable width/height nor a usable viewBox, and named through the svg/default-size-assumed diagnostic when it is.
+const DEFAULT_WIDTH_PT = 300 * 0.75;
+const DEFAULT_HEIGHT_PT = 150 * 0.75;
+
+// The circle-to-cubic control-point ratio shared with src/layout/drawing.ts's own CIRCLE_CUBIC_RATIO: 4/3 * (sqrt(2) - 1), derived by forcing a cubic through a quarter arc's own 45-degree midpoint.
+const KAPPA = (4 / 3) * (Math.SQRT2 - 1);
+
+// Namespace-agnostic by design: real-world SVG files mix prefixed and unprefixed names (svg:rect and rect), and the namespaces that matter here (SVG, xlink, dc metadata) carry no same-local-name collisions a walk keyed on local names could confuse.
+function localName(tag: string): string {
+ const colon = tag.indexOf(':');
+ return colon === -1 ? tag : tag.slice(colon + 1);
+}
+
+function findAttribute(element: XmlElement, name: string): string | undefined {
+ for (const attribute of element.attributes) {
+ if (localName(attribute.name) === name) {
+ return decodeEntities(attribute.value);
+ }
+ }
+ return undefined;
+}
+
+function elementChildren(node: XmlNode): XmlElement[] {
+ return node.type === 'element' ? node.children.filter((child): child is XmlElement => child.type === 'element') : [];
+}
+
+function textOf(element: XmlElement): string {
+ let text = '';
+ for (const child of element.children) {
+ if (child.type === 'text' || child.type === 'cdata') {
+ text += decodeEntities(child.value);
+ }
+ }
+ return text;
+}
+
+// The inherited presentation-attribute state, walked as raw strings and resolved only at the shape that uses them -- so a diagnostic about an unresolvable value fires per painted element, not per declaration, and a value on a group that paints nothing directly diagnoses nothing.
+interface PaintState {
+ readonly fillSpec?: string;
+ readonly strokeSpec?: string;
+ readonly strokeWidthSpec?: string;
+ readonly fillRuleSpec?: string;
+ readonly dashSpec?: string;
+}
+
+function childPaintState(element: XmlElement, parent: PaintState): PaintState {
+ return {
+ fillSpec: findAttribute(element, 'fill') ?? parent.fillSpec,
+ strokeSpec: findAttribute(element, 'stroke') ?? parent.strokeSpec,
+ strokeWidthSpec: findAttribute(element, 'stroke-width') ?? parent.strokeWidthSpec,
+ fillRuleSpec: findAttribute(element, 'fill-rule') ?? parent.fillRuleSpec,
+ dashSpec: findAttribute(element, 'stroke-dasharray') ?? parent.dashSpec,
+ };
+}
+
+interface ReaderState {
+ readonly sink?: SvgDiagnosticSink;
+ vectors: ContentVector[];
+ paintOrder: number;
+}
+
+function report(state: ReaderState, code: SvgDiagnosticCode, detail?: string): void {
+ state.sink?.(detail === undefined ? { code } : { code, detail });
+}
+
+// One paint property resolved to the schema's vocabulary, with every degradation named. Defaults follow SVG's own: an absent fill paints black, an absent stroke paints nothing. 'none' unpaints; url(#...) is reported as the gradient limit and unpaints (rendering a guessed solid colour would misrepresent the document worse than leaving it unpainted); currentColor renders black -- the CSS 'color' property's own initial value -- under a paint-unsupported diagnostic; an unparseable value falls back to the property's own default under the same diagnostic rather than poisoning geometry with a half-parse.
+function resolveFillPaint(state: ReaderState, spec: string | undefined, isFill: boolean): Color | undefined {
+ if (spec === undefined) {
+ return isFill ? { r: 0, g: 0, b: 0 } : undefined;
+ }
+ const paint = parseSvgPaint(spec);
+ if (paint === undefined) {
+ report(state, 'svg/paint-unsupported', spec);
+ return isFill ? { r: 0, g: 0, b: 0 } : undefined;
+ }
+ if (paint.kind === 'none') {
+ return undefined;
+ }
+ if (paint.kind === 'url') {
+ report(state, 'svg/gradient-unsupported', `#${paint.fragment}`);
+ return undefined;
+ }
+ if (paint.kind === 'currentColor') {
+ report(state, 'svg/paint-unsupported', 'currentColor renders as black: the CSS color property is out of scope');
+ return { r: 0, g: 0, b: 0 };
+ }
+ return paint.color;
+}
+
+// Stroke width resolves through the shared user-unit length parser (default 1, the attribute's own default), then scales by the CTM's mean column scale. A stroke whose scaled width is not positive is dropped rather than clamped -- ContentStrokeSchema demands widthPt > 0, and a zero-width stroke paints nothing in a conforming renderer either.
+function resolveStroke(state: ReaderState, paint: PaintState, ctm: AffineMatrix): ContentStroke | undefined {
+ const color = resolveFillPaint(state, paint.strokeSpec, false);
+ if (color === undefined) {
+ return undefined;
+ }
+ const userUnits = parseSvgUserUnits(paint.strokeWidthSpec) ?? 1;
+ const widthPt = userUnits * meanScaleFactor(ctm);
+ if (!(widthPt > 0)) {
+ return undefined;
+ }
+ const style = parseSvgDashStyle(paint.dashSpec);
+ return style === undefined ? { color, widthPt } : { color, widthPt, style };
+}
+
+function resolvePaint(state: ReaderState, paint: PaintState, ctm: AffineMatrix): { readonly fill?: Color; readonly stroke?: ContentStroke } {
+ return { fill: resolveFillPaint(state, paint.fillSpec, true), stroke: resolveStroke(state, paint, ctm) };
+}
+
+function boxOfPoints(points: readonly { readonly x: number; readonly y: number }[]): Box {
+ let minX = Number.POSITIVE_INFINITY;
+ let minY = Number.POSITIVE_INFINITY;
+ let maxX = Number.NEGATIVE_INFINITY;
+ let maxY = Number.NEGATIVE_INFINITY;
+ for (const point of points) {
+ minX = Math.min(minX, point.x);
+ minY = Math.min(minY, point.y);
+ maxX = Math.max(maxX, point.x);
+ maxY = Math.max(maxY, point.y);
+ }
+ return { xPt: minX, yPt: minY, widthPt: maxX - minX, heightPt: maxY - minY };
+}
+
+// The frame an axis-aligned CTM gives a box: the bounding box of the four transformed corners, which for a matrix with no rotation or shear terms (mirroring included) is exactly the transformed box.
+function axisAlignedFrame(ctm: AffineMatrix, x: number, y: number, width: number, height: number): Box {
+ return boxOfPoints([applyMatrix(ctm, x, y), applyMatrix(ctm, x + width, y), applyMatrix(ctm, x + width, y + height), applyMatrix(ctm, x, y + height)]);
+}
+
+// The frame a non-reflecting similarity CTM gives a box, per the module note's pre-rotation contract: the uniformly scaled box, positioned so its centre sits on the transformed centre -- the renderer then rotates the frame's own points about that centre by rotationDeg and lands exactly on the transformed corners.
+function similarityFrame(ctm: AffineMatrix, x: number, y: number, width: number, height: number): Box {
+ const centre = applyMatrix(ctm, x + width / 2, y + height / 2);
+ const scale = Math.hypot(ctm.a, ctm.b);
+ return { xPt: centre.x - (scale * width) / 2, yPt: centre.y - (scale * height) / 2, widthPt: scale * width, heightPt: scale * height };
+}
+
+// The path pipeline every curve-carrying construction funnels into: transform each point of the already-parsed local-space subpaths through the CTM (an affine maps a cubic's controls exactly, so nothing is approximated here), take the tight bounding box of ALL points including cubic controls (the identical hull convention src/layout/drawing.ts's own vectorItemBounds documents -- a cubic lies within the convex hull of its controls, so the frame contains the rendered curve), and rebase the points into the frame's own local space, which is the ContentVector path variant's own subpaths contract.
+function buildPathVector(state: ReaderState, subpaths: readonly ParsedPathSubpath[], ctm: AffineMatrix, paint: { readonly fill?: Color; readonly stroke?: ContentStroke }, fillRule: 'evenodd' | undefined): ContentVector | undefined {
+ const placed: ParsedPathSubpath[] = subpaths.map((subpath) => ({
+ start: applyMatrix(ctm, subpath.start.x, subpath.start.y),
+ closed: subpath.closed,
+ segments: subpath.segments.map((segment) => (segment.kind === 'line'
+ ? { kind: 'line' as const, to: applyMatrix(ctm, segment.to.x, segment.to.y) }
+ : {
+ kind: 'cubic' as const,
+ control1: applyMatrix(ctm, segment.control1.x, segment.control1.y),
+ control2: applyMatrix(ctm, segment.control2.x, segment.control2.y),
+ to: applyMatrix(ctm, segment.to.x, segment.to.y),
+ })),
+ }));
+ const allPoints = placed.flatMap((subpath) => [
+ subpath.start,
+ ...subpath.segments.flatMap((segment): ParsedPathPoint[] => (segment.kind === 'line' ? [segment.to] : [segment.control1, segment.control2, segment.to])),
+ ]);
+ const frame = boxOfPoints(allPoints);
+ if (frame.widthPt === 0 && frame.heightPt === 0) {
+ return undefined;
+ }
+ const localSubpaths: ContentSubpath[] = placed.map((subpath) => ({
+ start: { xPt: subpath.start.x - frame.xPt, yPt: subpath.start.y - frame.yPt },
+ closed: subpath.closed,
+ segments: subpath.segments.map((segment) => (segment.kind === 'line'
+ ? { kind: 'line' as const, to: { xPt: segment.to.x - frame.xPt, yPt: segment.to.y - frame.yPt } }
+ : {
+ kind: 'cubic' as const,
+ control1: { xPt: segment.control1.x - frame.xPt, yPt: segment.control1.y - frame.yPt },
+ control2: { xPt: segment.control2.x - frame.xPt, yPt: segment.control2.y - frame.yPt },
+ to: { xPt: segment.to.x - frame.xPt, yPt: segment.to.y - frame.yPt },
+ })),
+ }));
+ const sourceIndex = state.vectors.length;
+ return {
+ kind: 'path',
+ frame,
+ subpaths: localSubpaths,
+ ...(paint.fill !== undefined ? { fill: paint.fill } : {}),
+ ...(fillRule !== undefined ? { fillRule } : {}),
+ ...(paint.stroke !== undefined ? { stroke: paint.stroke } : {}),
+ paintOrder: state.paintOrder++,
+ sourcePath: `svg/vector[${sourceIndex}]`,
+ };
+}
+
+// A rounded rect becomes a path the same way it renders: four straight edges and four kappa quarter-ellipse corners, walked clockwise in y-down space. rx/ry arrive already resolved (each defaulting to the other when one is absent) and are clamped against half the rect's own width/height per the attribute's own rule.
+function roundedRectSubpaths(x: number, y: number, width: number, height: number, rx: number, ry: number): ParsedPathSubpath[] {
+ const radiusX = Math.min(rx, width / 2);
+ const radiusY = Math.min(ry, height / 2);
+ const kx = radiusX * KAPPA;
+ const ky = radiusY * KAPPA;
+ return [
+ {
+ start: { x: x + radiusX, y },
+ closed: true,
+ segments: [
+ { kind: 'line', to: { x: x + width - radiusX, y } },
+ { kind: 'cubic', control1: { x: x + width - radiusX + kx, y }, control2: { x: x + width, y: y + radiusY - ky }, to: { x: x + width, y: y + radiusY } },
+ { kind: 'line', to: { x: x + width, y: y + height - radiusY } },
+ { kind: 'cubic', control1: { x: x + width, y: y + height - radiusY + ky }, control2: { x: x + width - radiusX + kx, y: y + height }, to: { x: x + width - radiusX, y: y + height } },
+ { kind: 'line', to: { x: x + radiusX, y: y + height } },
+ { kind: 'cubic', control1: { x: x + radiusX - kx, y: y + height }, control2: { x, y: y + height - radiusY + ky }, to: { x, y: y + height - radiusY } },
+ { kind: 'line', to: { x, y: y + radiusY } },
+ { kind: 'cubic', control1: { x, y: y + radiusY - ky }, control2: { x: x + radiusX - kx, y }, to: { x: x + radiusX, y } },
+ ],
+ },
+ ];
+}
+
+// An ellipse as its four kappa quarter-arc cubics, walked clockwise from the rightmost axis point in y-down space -- the mirror image of src/layout/drawing.ts's own ellipseCubicPoints walk (counter-clockwise in PDF's y-up space; both trace the same curve).
+function ellipseSubpaths(cx: number, cy: number, rx: number, ry: number): ParsedPathSubpath[] {
+ const kx = rx * KAPPA;
+ const ky = ry * KAPPA;
+ return [
+ {
+ start: { x: cx + rx, y: cy },
+ closed: true,
+ segments: [
+ { kind: 'cubic', control1: { x: cx + rx, y: cy + ky }, control2: { x: cx + kx, y: cy + ry }, to: { x: cx, y: cy + ry } },
+ { kind: 'cubic', control1: { x: cx - kx, y: cy + ry }, control2: { x: cx - rx, y: cy + ky }, to: { x: cx - rx, y: cy } },
+ { kind: 'cubic', control1: { x: cx - rx, y: cy - ky }, control2: { x: cx - kx, y: cy - ry }, to: { x: cx, y: cy - ry } },
+ { kind: 'cubic', control1: { x: cx + kx, y: cy - ry }, control2: { x: cx + rx, y: cy - ky }, to: { x: cx + rx, y: cy } },
+ ],
+ },
+ ];
+}
+
+// The element's geometry attributes in user units; SVG's own default for every one of them is 0 (x/y/cx/cy/x1..y2/rx/ry), so a missing attribute reads as the origin/default rather than a diagnostic.
+function userUnits(element: XmlElement, attr: string): number {
+ return parseSvgUserUnits(findAttribute(element, attr)) ?? 0;
+}
+
+function readShape(state: ReaderState, element: XmlElement, ctm: AffineMatrix, paint: PaintState): void {
+ const name = localName(element.tag);
+ const id = findAttribute(element, 'id');
+ const detail = id === undefined ? name : `${name}#${id}`;
+
+ let fillRule: 'evenodd' | undefined;
+ const fillRuleSpec = paint.fillRuleSpec;
+ if (fillRuleSpec !== undefined && fillRuleSpec !== 'nonzero') {
+ if (fillRuleSpec === 'evenodd') {
+ fillRule = 'evenodd';
+ } else {
+ report(state, 'svg/paint-unsupported', fillRuleSpec);
+ }
+ }
+
+ const resolved = resolvePaint(state, paint, ctm);
+ if (resolved.fill === undefined && resolved.stroke === undefined) {
+ report(state, 'svg/element-skipped', `${detail}: nothing painted (fill and stroke both absent or none)`);
+ return;
+ }
+
+ if (name === 'rect') {
+ const x = userUnits(element, 'x');
+ const y = userUnits(element, 'y');
+ const width = userUnits(element, 'width');
+ const height = userUnits(element, 'height');
+ if (width <= 0 || height <= 0) {
+ report(state, 'svg/element-skipped', `${detail}: zero or negative size`);
+ return;
+ }
+ // Each corner radius defaults to the other when only one is present -- the attribute's own rule.
+ const rxAttr = parseSvgUserUnits(findAttribute(element, 'rx'));
+ const ryAttr = parseSvgUserUnits(findAttribute(element, 'ry'));
+ const rx = rxAttr ?? ryAttr ?? 0;
+ const ry = ryAttr ?? rxAttr ?? 0;
+ if (rx > 0 || ry > 0) {
+ // The schema's rect variant has no corner-radius field, so rounded corners are exactly representable only as a path -- constructed the same way the renderer itself draws them.
+ const vector = buildPathVector(state, roundedRectSubpaths(x, y, width, height, rx, ry), ctm, resolved, fillRule);
+ if (vector !== undefined) {
+ state.vectors.push(vector);
+ }
+ return;
+ }
+ const rotated = !isAxisAligned(ctm) && isNonReflectingSimilarity(ctm);
+ const frame = rotated ? similarityFrame(ctm, x, y, width, height) : axisAlignedFrame(ctm, x, y, width, height);
+ if (frame.widthPt <= 0 || frame.heightPt <= 0) {
+ report(state, 'svg/element-skipped', `${detail}: collapses to zero size under transform`);
+ return;
+ }
+ state.vectors.push({
+ kind: 'rect',
+ frame,
+ ...(rotated ? { rotationDeg: similarityRotationDeg(ctm) } : {}),
+ ...(resolved.fill !== undefined ? { fill: resolved.fill } : {}),
+ ...(resolved.stroke !== undefined ? { stroke: resolved.stroke } : {}),
+ paintOrder: state.paintOrder++,
+ sourcePath: `svg/vector[${state.vectors.length}]`,
+ });
+ return;
+ }
+
+ if (name === 'circle' || name === 'ellipse') {
+ const cx = userUnits(element, 'cx');
+ const cy = userUnits(element, 'cy');
+ const rx = name === 'circle' ? userUnits(element, 'r') : userUnits(element, 'rx');
+ const ry = name === 'circle' ? userUnits(element, 'r') : userUnits(element, 'ry');
+ if (rx <= 0 || ry <= 0) {
+ report(state, 'svg/element-skipped', `${detail}: zero or negative radius`);
+ return;
+ }
+ if (!isAxisAligned(ctm) && !isNonReflectingSimilarity(ctm)) {
+ // A shear or non-uniform-scale-plus-rotation matrix maps a circle to a genuinely skewed conic; only the path variant can express it, and the kappa cubics deform exactly the way the true ellipse does under the same affine.
+ const vector = buildPathVector(state, ellipseSubpaths(cx, cy, rx, ry), ctm, resolved, fillRule);
+ if (vector !== undefined) {
+ state.vectors.push(vector);
+ }
+ return;
+ }
+ const rotated = !isAxisAligned(ctm);
+ const frame = rotated ? similarityFrame(ctm, cx - rx, cy - ry, rx * 2, ry * 2) : axisAlignedFrame(ctm, cx - rx, cy - ry, rx * 2, ry * 2);
+ if (frame.widthPt <= 0 || frame.heightPt <= 0) {
+ report(state, 'svg/element-skipped', `${detail}: collapses to zero size under transform`);
+ return;
+ }
+ state.vectors.push({
+ kind: 'ellipse',
+ frame,
+ ...(rotated ? { rotationDeg: similarityRotationDeg(ctm) } : {}),
+ ...(resolved.fill !== undefined ? { fill: resolved.fill } : {}),
+ ...(resolved.stroke !== undefined ? { stroke: resolved.stroke } : {}),
+ paintOrder: state.paintOrder++,
+ sourcePath: `svg/vector[${state.vectors.length}]`,
+ });
+ return;
+ }
+
+ if (name === 'line') {
+ if (resolved.stroke === undefined) {
+ report(state, 'svg/element-skipped', `${detail}: a line paints only through its stroke, which is absent or none`);
+ return;
+ }
+ const from = applyMatrix(ctm, userUnits(element, 'x1'), userUnits(element, 'y1'));
+ const to = applyMatrix(ctm, userUnits(element, 'x2'), userUnits(element, 'y2'));
+ if (from.x === to.x && from.y === to.y) {
+ report(state, 'svg/element-skipped', `${detail}: zero-length line`);
+ return;
+ }
+ state.vectors.push({
+ kind: 'line',
+ from: { xPt: from.x, yPt: from.y },
+ to: { xPt: to.x, yPt: to.y },
+ stroke: resolved.stroke,
+ paintOrder: state.paintOrder++,
+ sourcePath: `svg/vector[${state.vectors.length}]`,
+ });
+ return;
+ }
+
+ if (name === 'polyline' || name === 'polygon') {
+ const pointsRaw = findAttribute(element, 'points');
+ if (pointsRaw === undefined) {
+ report(state, 'svg/element-skipped', `${detail}: no points attribute`);
+ return;
+ }
+ const numbers = pointsRaw.trim().split(/[\s,]+/).filter((part) => part !== '').map(Number);
+ if (numbers.length === 0 || numbers.length % 2 !== 0 || !numbers.every((value) => Number.isFinite(value))) {
+ report(state, 'svg/element-unsupported', `${detail}: malformed points attribute`);
+ return;
+ }
+ if (numbers.length < 4) {
+ report(state, 'svg/element-skipped', `${detail}: fewer than two points`);
+ return;
+ }
+ const segments: ParsedPathSegment[] = [];
+ for (let i = 2; i < numbers.length; i += 2) {
+ segments.push({ kind: 'line', to: { x: numbers[i]!, y: numbers[i + 1]! } });
+ }
+ const vector = buildPathVector(state, [{ start: { x: numbers[0]!, y: numbers[1]! }, closed: name === 'polygon', segments }], ctm, resolved, fillRule);
+ if (vector === undefined) {
+ report(state, 'svg/element-skipped', `${detail}: all points coincide`);
+ } else {
+ state.vectors.push(vector);
+ }
+ return;
+ }
+
+ if (name === 'path') {
+ const d = findAttribute(element, 'd');
+ if (d === undefined || d.trim() === '') {
+ report(state, 'svg/element-skipped', `${detail}: no d attribute`);
+ return;
+ }
+ const parsed = parseSvgPathData(d);
+ if (parsed === undefined || parsed.length === 0) {
+ report(state, 'svg/element-unsupported', `${detail}: malformed or empty path data`);
+ return;
+ }
+ const vector = buildPathVector(state, parsed, ctm, resolved, fillRule);
+ if (vector === undefined) {
+ report(state, 'svg/element-skipped', `${detail}: path collapses to a single point`);
+ } else {
+ state.vectors.push(vector);
+ }
+ return;
+ }
+}
+
+// Elements that never render directly -- definitions, references, and non-visual metadata -- walked past silently rather than diagnosed: they are supposed to produce nothing on the canvas, and a full of gradients is not a fidelity loss until something actually references one (which the url(#...) resolution then reports).
+const NON_RENDERING_ELEMENTS = new Set(['defs', 'title', 'desc', 'metadata', 'linearGradient', 'radialGradient', 'pattern', 'clipPath', 'mask', 'marker', 'symbol', 'script']);
+
+function walkElement(state: ReaderState, element: XmlElement, ctm: AffineMatrix, paint: PaintState): void {
+ const name = localName(element.tag);
+ const id = findAttribute(element, 'id');
+ const detail = id === undefined ? name : `${name}#${id}`;
+
+ if (findAttribute(element, 'style') !== undefined) {
+ report(state, 'svg/css-style-ignored', detail);
+ }
+ for (const opacityAttr of ['opacity', 'fill-opacity', 'stroke-opacity']) {
+ const raw = findAttribute(element, opacityAttr);
+ if (raw !== undefined) {
+ const value = Number(raw);
+ if (Number.isFinite(value) && value < 1) {
+ report(state, 'svg/opacity-ignored', `${detail}: ${opacityAttr}=${raw}`);
+ }
+ }
+ }
+
+ if (name === 'g' || name === 'a') {
+ const own = parseSvgTransform(findAttribute(element, 'transform'));
+ walkChildren(state, element, own === undefined ? ctm : composeMatrices(ctm, own), childPaintState(element, paint));
+ return;
+ }
+ if (NON_RENDERING_ELEMENTS.has(name)) {
+ return;
+ }
+ if (name === 'style') {
+ report(state, 'svg/css-style-ignored', detail);
+ return;
+ }
+ if (name === 'text' || name === 'tspan' || name === 'textPath' || name === 'tref') {
+ report(state, 'svg/text-unsupported', detail);
+ return;
+ }
+ if (name === 'image') {
+ report(state, 'svg/image-unsupported', detail);
+ return;
+ }
+ if (name === 'use') {
+ report(state, 'svg/use-unsupported', detail);
+ return;
+ }
+ if (name === 'rect' || name === 'circle' || name === 'ellipse' || name === 'line' || name === 'polyline' || name === 'polygon' || name === 'path') {
+ // A shape element's own transform attribute composes after the ancestors' -- SVG applies transform to every element, not just groups, and the write side relies on this directly (rotationDeg is emitted as a transform on the shape element itself, so honouring it here is what makes a rotated rect survive its own round trip). The element's own presentation attributes merge over the inherited paint state the same way a group's do -- fill=/stroke= directly on a shape is the most common authoring pattern there is.
+ const own = parseSvgTransform(findAttribute(element, 'transform'));
+ readShape(state, element, own === undefined ? ctm : composeMatrices(ctm, own), childPaintState(element, paint));
+ return;
+ }
+ report(state, 'svg/element-unsupported', detail);
+}
+
+function walkChildren(state: ReaderState, element: XmlElement, ctm: AffineMatrix, paint: PaintState): void {
+ for (const child of elementChildren(element)) {
+ walkElement(state, child, ctm, paint);
+ }
+}
+
+// The root viewport's geometry: page size (pt), the root affine every user-space coordinate flows through, and the root-level diagnostics the fallbacks owe. Width/height resolve as CSS lengths directly into pt; a viewBox present alongside scales user units onto that page, absent the initial 1 user unit = 1px = 0.75pt mapping holds. Only one dimension present discards both (CSS 2.1's intrinsic-sizing rule for the missing dimension has no page-model equivalent); a degenerate viewBox (zero width or height) is ignored entirely, as no scale it could define is meaningful.
+function resolveRootGeometry(root: XmlElement, state: ReaderState): { readonly widthPt: number; readonly heightPt: number; readonly map: AffineMatrix } {
+ const attrWidth = parseSvgLengthPt(findAttribute(root, 'width'));
+ const attrHeight = parseSvgLengthPt(findAttribute(root, 'height'));
+ const parsedViewBox = parseSvgViewBox(findAttribute(root, 'viewBox'));
+ const viewBox: SvgViewBox | undefined = parsedViewBox !== undefined && parsedViewBox.width > 0 && parsedViewBox.height > 0 ? parsedViewBox : undefined;
+
+ let widthPt = attrWidth !== undefined && attrWidth > 0 ? attrWidth : undefined;
+ let heightPt = attrHeight !== undefined && attrHeight > 0 ? attrHeight : undefined;
+ if ((widthPt === undefined) !== (heightPt === undefined)) {
+ widthPt = undefined;
+ heightPt = undefined;
+ }
+ if (widthPt === undefined || heightPt === undefined) {
+ if (viewBox !== undefined) {
+ widthPt = viewBox.width;
+ heightPt = viewBox.height;
+ } else {
+ widthPt = DEFAULT_WIDTH_PT;
+ heightPt = DEFAULT_HEIGHT_PT;
+ report(state, 'svg/default-size-assumed', 'neither width/height nor a usable viewBox was present; assuming the CSS default replaced-element size of 300x150 px');
+ }
+ }
+
+ if (viewBox === undefined) {
+ return { widthPt, heightPt, map: { a: 0.75, b: 0, c: 0, d: 0.75, e: 0, f: 0 } };
+ }
+ const preserveAspectRatio = findAttribute(root, 'preserveAspectRatio') ?? 'xMidYMid meet';
+ if (preserveAspectRatio.trim() !== 'none') {
+ const viewBoxAspect = viewBox.width / viewBox.height;
+ const pageAspect = widthPt / heightPt;
+ if (Math.abs(viewBoxAspect - pageAspect) > 1e-6 * Math.max(viewBoxAspect, pageAspect)) {
+ report(state, 'svg/preserve-aspect-ratio-stretched', `viewBox aspect ${viewBoxAspect.toFixed(4)} stretched onto page aspect ${pageAspect.toFixed(4)} under preserveAspectRatio="${preserveAspectRatio.trim()}" (letterboxing is out of scope)`);
+ }
+ }
+ const sx = widthPt / viewBox.width;
+ const sy = heightPt / viewBox.height;
+ return { widthPt, heightPt, map: { a: sx, b: 0, c: 0, d: sy, e: -viewBox.minX * sx, f: -viewBox.minY * sy } };
+}
+
+export function readSvgContent(text: string, options?: ReadSvgContentOptions): ContentDocument {
+ const nodes = parseXml(text);
+ const root = nodes.find((node): node is XmlElement => node.type === 'element' && localName(node.tag) === 'svg');
+ if (root === undefined) {
+ throw new SvgMissingRootElementError();
+ }
+
+ const state: ReaderState = { sink: options?.onSvgDiagnostic, vectors: [], paintOrder: 0 };
+ const rootGeometry = resolveRootGeometry(root, state);
+
+ // The root element's own child is SVG's one genuinely representable metadata field -- it becomes the document's metadata.title, entity-decoded because odf.js's parseXml deliberately keeps the original encoding.
+ const titleElement = elementChildren(root).find((child) => localName(child.tag) === 'title');
+ const title = titleElement === undefined ? undefined : textOf(titleElement).trim();
+ const metadata = title === undefined || title === '' ? {} : { title };
+
+ walkChildren(state, root, rootGeometry.map, childPaintState(root, {}));
+
+ const page: ContentDrawPage = { size: { widthPt: rootGeometry.widthPt, heightPt: rootGeometry.heightPt }, shapes: [], vectors: state.vectors };
+ return { kind: 'drawing', formatVersion: CONTENT_FORMAT_VERSION, metadata, pages: [page] };
+}
diff --git a/src/svg/text.ts b/src/svg/text.ts
new file mode 100644
index 00000000..5fbbcefd
--- /dev/null
+++ b/src/svg/text.ts
@@ -0,0 +1,21 @@
+// decodeSvgText/encodeSvgText: the byte <-> text boundary for svg, exactly mirroring src/csv/text.ts's own pair (csv being the other plain-text format with no upstream codec package). A fresh TextDecoder/TextEncoder is constructed per call, never module-level cached -- this package's own sideEffects:false convention (package.json) means nothing here creates shared mutable state at import time.
+//
+// decodeSvgText uses a fatal-mode TextDecoder specifically so a non-UTF-8 input throws here, at the boundary, rather than silently producing U+FFFD replacement characters that would then corrupt every downstream title string and path the read path produces -- the identical reasoning src/model/bytes.ts's own SvgBytesSchema documents for the schema-validation path. This function is the enforcement point for the ergonomic conversions (svgToPdf/svgToOdg and every composed route sourcing svg) that bypass the schema and call readSvgContent directly on already-checked bytes.
+export class SvgInvalidUtf8Error extends Error {
+ constructor() {
+ super('svg text must be well-formed UTF-8');
+ this.name = 'SvgInvalidUtf8Error';
+ }
+}
+
+export function decodeSvgText(bytes: Uint8Array): string {
+ try {
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
+ } catch {
+ throw new SvgInvalidUtf8Error();
+ }
+}
+
+export function encodeSvgText(text: string): Uint8Array {
+ return new TextEncoder().encode(text);
+}
diff --git a/src/svg/transform.test.ts b/src/svg/transform.test.ts
new file mode 100644
index 00000000..2e220390
--- /dev/null
+++ b/src/svg/transform.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest';
+import { applyMatrix, composeMatrices, IDENTITY_MATRIX, isAxisAligned, isNonReflectingSimilarity, meanScaleFactor, parseSvgTransform, similarityRotationDeg } from './transform';
+
+describe('parseSvgTransform', () => {
+ it('applies a transform list left to right, innermost function first', () => {
+ // translate(10,20) scale(2): scale applies to the point first, then the translate -- the SVG list order as function composition.
+ const total = parseSvgTransform('translate(10,20) scale(2)');
+ expect(total).toBeDefined();
+ expect(applyMatrix(total!, 1, 1)).toEqual({ x: 12, y: 22 });
+ });
+
+ it('parses every function form, including rotate\'s optional centre', () => {
+ expect(parseSvgTransform('translate(5)')).toBeDefined();
+ expect(parseSvgTransform('scale(2,3)')).toBeDefined();
+ expect(parseSvgTransform('skewX(45)')).toBeDefined();
+ expect(parseSvgTransform('skewY(30)')).toBeDefined();
+ expect(parseSvgTransform('matrix(1 2 3 4 5 6)')).toBeDefined();
+ // rotate(90, 5, 5) is exactly translate(5,5) rotate(90) translate(-5,-5): the point (5,6) orbits the centre to (4,5).
+ const rotated = parseSvgTransform('rotate(90, 5, 5)');
+ expect(applyMatrix(rotated!, 5, 6)).toEqual({ x: 4, y: 5 });
+ });
+
+ it('returns undefined for any malformed list rather than a partial parse', () => {
+ expect(parseSvgTransform('translate(10')).toBeUndefined();
+ expect(parseSvgTransform('foo(1)')).toBeUndefined();
+ expect(parseSvgTransform('scale(1,2,3)')).toBeUndefined();
+ expect(parseSvgTransform('matrix(1 2 3)')).toBeUndefined();
+ expect(parseSvgTransform('rotate(90,)')).toBeUndefined();
+ expect(parseSvgTransform('TRANSLATE(10)')).toBeUndefined();
+ expect(parseSvgTransform('')).toBeUndefined();
+ expect(parseSvgTransform(' ')).toBeUndefined();
+ expect(parseSvgTransform(undefined)).toBeUndefined();
+ });
+});
+
+describe('matrix classification', () => {
+ it('marks a matrix axis-aligned exactly when no rotation or shear terms exist', () => {
+ expect(isAxisAligned(IDENTITY_MATRIX)).toBe(true);
+ expect(isAxisAligned(parseSvgTransform('scale(2,3)')!)).toBe(true);
+ expect(isAxisAligned(parseSvgTransform('translate(10,20)')!)).toBe(true);
+ // Mirroring is still axis-aligned: a bounding box absorbs it exactly.
+ expect(isAxisAligned(parseSvgTransform('scale(-1,1)')!)).toBe(true);
+ expect(isAxisAligned(parseSvgTransform('rotate(90)')!)).toBe(false);
+ expect(isAxisAligned(parseSvgTransform('skewX(45)')!)).toBe(false);
+ });
+
+ it('classifies by frame representability: axis-aligned maps pass however they scale or mirror, rotation-composed non-uniform maps and shears fail', () => {
+ expect(isNonReflectingSimilarity(parseSvgTransform('rotate(30)')!)).toBe(true);
+ expect(isNonReflectingSimilarity(parseSvgTransform('rotate(30) scale(2) scale(0.5)')!)).toBe(true);
+ // An axis-aligned non-uniform scale or mirror still maps a frame onto a frame (an ellipse stays an axis-aligned ellipse, with new radii), so both pass the classification; only composed with rotation or shear does the map become a shape no frame carries.
+ expect(isNonReflectingSimilarity(parseSvgTransform('scale(2,3)')!)).toBe(true);
+ expect(isNonReflectingSimilarity(parseSvgTransform('scale(-1,1)')!)).toBe(true);
+ expect(isNonReflectingSimilarity(parseSvgTransform('matrix(-1 1 0 1 0 0)')!)).toBe(false);
+ expect(isNonReflectingSimilarity(parseSvgTransform('rotate(30) scale(2,3)')!)).toBe(false);
+ expect(isNonReflectingSimilarity(parseSvgTransform('skewX(45)')!)).toBe(false);
+ });
+
+ it('reads a similarity\'s rotation straight off the first column, in screen-clockwise degrees', () => {
+ expect(similarityRotationDeg(parseSvgTransform('rotate(90)')!)).toBeCloseTo(90, 9);
+ expect(similarityRotationDeg(parseSvgTransform('rotate(-30)')!)).toBeCloseTo(-30, 9);
+ expect(similarityRotationDeg(composeMatrices(parseSvgTransform('scale(2)')!, parseSvgTransform('rotate(45)')!))).toBeCloseTo(45, 9);
+ });
+});
+
+describe('meanScaleFactor', () => {
+ it('is 1 for the identity and any rotation, the mean of the two column scales otherwise', () => {
+ expect(meanScaleFactor(IDENTITY_MATRIX)).toBe(1);
+ expect(meanScaleFactor(parseSvgTransform('rotate(90)')!)).toBeCloseTo(1, 12);
+ expect(meanScaleFactor(parseSvgTransform('scale(2,3)')!)).toBe(2.5);
+ // The mean, not the determinant\'s square root: under this shear the columns disagree (1 and sqrt(2)), and the stroke width tracks their average.
+ expect(meanScaleFactor(parseSvgTransform('matrix(1 0 1 1 0 0)')!)).toBeCloseTo((1 + Math.SQRT2) / 2, 12);
+ });
+});
diff --git a/src/svg/transform.ts b/src/svg/transform.ts
new file mode 100644
index 00000000..8e630861
--- /dev/null
+++ b/src/svg/transform.ts
@@ -0,0 +1,187 @@
+// SVG transform parsing and affine composition. SVG models every transform attribute (and the viewBox -> viewport map, and the group nesting rule) as one 2x3 affine matrix applied to user-space column vectors: x' = a*x + c*y + e, y' = b*x + d*y + f -- the identical parameterisation CSS transforms and PDF's cm operator use, and the reason a composition of any number of SVG transforms stays exactly one matrix rather than a tree of closures.
+export interface AffineMatrix {
+ readonly a: number;
+ readonly b: number;
+ readonly c: number;
+ readonly d: number;
+ readonly e: number;
+ readonly f: number;
+}
+
+export const IDENTITY_MATRIX: AffineMatrix = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
+
+export function applyMatrix(m: AffineMatrix, x: number, y: number): { readonly x: number; readonly y: number } {
+ return { x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f };
+}
+
+// Applies `inner` first and `outer` second -- the SVG nesting semantics: a group's transform maps its children's coordinates, so the total matrix walking into a child is outerCTM * childOwnTransform, matrix-multiplied in that left-to-right order (outer ∘ inner as function composition).
+export function composeMatrices(outer: AffineMatrix, inner: AffineMatrix): AffineMatrix {
+ return {
+ a: outer.a * inner.a + outer.c * inner.b,
+ b: outer.b * inner.a + outer.d * inner.b,
+ c: outer.a * inner.c + outer.c * inner.d,
+ d: outer.b * inner.c + outer.d * inner.d,
+ e: outer.a * inner.e + outer.c * inner.f + outer.e,
+ f: outer.b * inner.e + outer.d * inner.f + outer.f,
+ };
+}
+
+export function applyScale(m: AffineMatrix, factor: number): AffineMatrix {
+ return { a: m.a * factor, b: m.b * factor, c: m.c * factor, d: m.d * factor, e: m.e * factor, f: m.f * factor };
+}
+
+// The mean of the two column scales -- the factor a stroke width grows by under m. Not the determinant's square root: for a shear-heavy matrix the columns disagree, and the mean keeps a stroked line's weight tracking the average of how the matrix stretches each basis direction, which is the best one-number answer a schema carrying a scalar stroke width has.
+export function meanScaleFactor(m: AffineMatrix): number {
+ return (Math.hypot(m.a, m.b) + Math.hypot(m.c, m.d)) / 2;
+}
+
+// axis-aligned: no rotation or shear terms at all, so a rect stays an axis-aligned rect and an ellipse stays an axis-aligned ellipse (possibly mirrored, which a bounding box absorbs exactly). A similarity additionally allows a uniform rotation/reflection but still maps squares to squares and circles to circles, so rect/ellipse again keep their kind -- via a bounding frame plus a rotationDeg the schema's rect/ellipse variants do carry -- provided the matrix does not reflect (a mirrored ellipse is not a rotated one; det < 0 is excluded). Anything more (non-uniform scale composed with rotation, shear) maps a circle to a genuinely skewed conic, which only the path variant can express.
+export function isAxisAligned(m: AffineMatrix): boolean {
+ return m.b === 0 && m.c === 0;
+}
+
+export function isNonReflectingSimilarity(m: AffineMatrix): boolean {
+ if (isAxisAligned(m)) {
+ return true;
+ }
+ if (m.a * m.d - m.b * m.c < 0) {
+ return false;
+ }
+ return Math.abs(m.a * m.a + m.b * m.b - (m.c * m.c + m.d * m.d)) < 1e-9;
+}
+
+// The rotation angle of a non-reflecting similarity, in degrees clockwise on screen (SVG's own convention, y-down), which is exactly the sign convention ContentVector.rotationDeg already carries for the drawing variant. atan2(b, a) reads the angle straight off the matrix's first column; the caller is responsible for having classified m as a non-reflecting similarity first, since for a general matrix this quantity is not the rotation of anything.
+export function similarityRotationDeg(m: AffineMatrix): number {
+ return (Math.atan2(m.b, m.a) * 180) / Math.PI;
+}
+
+// The transform attribute grammar: a whitespace/comma-separated list of function calls translate(tx [ty]), scale(sx [sy]), rotate(angle [cx cy]), skewX(a), skewY(a), matrix(a b c d e f), applied LEFT TO RIGHT in list order -- which is the composition order composeMatrices(outer, inner) with each list entry as the new outer. Numbers reuse the shared SVG number grammar; function names are case-sensitive per the spec. Returns undefined for any malformed list (an unknown function, a bad argument count, a non-finite number) rather than a partial parse -- a half-applied transform would silently misplace every descendant.
+const TRANSFORM_NUMBER = /[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/y;
+const TRANSFORM_FUNCTIONS = ['translate', 'scale', 'rotate', 'skewX', 'skewY', 'matrix'] as const;
+type TransformFunctionName = (typeof TRANSFORM_FUNCTIONS)[number];
+
+interface TransformScanner {
+ pos: number;
+}
+
+// Returns whether a comma was consumed, so the argument loop can reject a comma left dangling before the closing paren -- "rotate(90,)" is malformed, not an omitted argument silently accepted as rotate(90).
+function skipTransformSeparators(source: string, scanner: TransformScanner): boolean {
+ let comma = false;
+ while (scanner.pos < source.length && /[\s,]/.test(source[scanner.pos]!)) {
+ if (source[scanner.pos] === ',') {
+ comma = true;
+ }
+ scanner.pos++;
+ }
+ return comma;
+}
+
+function scanTransformNumber(source: string, scanner: TransformScanner): number | undefined {
+ TRANSFORM_NUMBER.lastIndex = scanner.pos;
+ const match = TRANSFORM_NUMBER.exec(source);
+ if (match === null) {
+ return undefined;
+ }
+ scanner.pos = TRANSFORM_NUMBER.lastIndex;
+ const value = Number(match[0]);
+ return Number.isFinite(value) ? value : undefined;
+}
+
+function matrixFromFunction(name: TransformFunctionName, args: readonly number[]): AffineMatrix | undefined {
+ switch (name) {
+ case 'translate':
+ if (args.length !== 1 && args.length !== 2) {
+ return undefined;
+ }
+ return { a: 1, b: 0, c: 0, d: 1, e: args[0]!, f: args[1] ?? 0 };
+ case 'scale': {
+ if (args.length !== 1 && args.length !== 2) {
+ return undefined;
+ }
+ const sx = args[0]!;
+ const sy = args[1] ?? sx;
+ return { a: sx, b: 0, c: 0, d: sy, e: 0, f: 0 };
+ }
+ case 'rotate': {
+ if (args.length !== 1 && args.length !== 3) {
+ return undefined;
+ }
+ const radians = (args[0]! * Math.PI) / 180;
+ const cos = Math.cos(radians);
+ const sin = Math.sin(radians);
+ const rotation: AffineMatrix = { a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 };
+ if (args.length === 1) {
+ return rotation;
+ }
+ // rotate(a, cx, cy) is exactly translate(cx, cy) rotate(a) translate(-cx, -cy), spelled out here rather than delegated so the centre arithmetic stays in one place.
+ const cx = args[1]!;
+ const cy = args[2]!;
+ return composeMatrices(composeMatrices({ a: 1, b: 0, c: 0, d: 1, e: cx, f: cy }, rotation), { a: 1, b: 0, c: 0, d: 1, e: -cx, f: -cy });
+ }
+ case 'skewX':
+ if (args.length !== 1) {
+ return undefined;
+ }
+ return { a: 1, b: 0, c: Math.tan((args[0]! * Math.PI) / 180), d: 1, e: 0, f: 0 };
+ case 'skewY':
+ if (args.length !== 1) {
+ return undefined;
+ }
+ return { a: 1, b: Math.tan((args[0]! * Math.PI) / 180), c: 0, d: 1, e: 0, f: 0 };
+ case 'matrix':
+ if (args.length !== 6) {
+ return undefined;
+ }
+ return { a: args[0]!, b: args[1]!, c: args[2]!, d: args[3]!, e: args[4]!, f: args[5]! };
+ }
+}
+
+export function parseSvgTransform(raw: string | undefined): AffineMatrix | undefined {
+ if (raw === undefined) {
+ return undefined;
+ }
+ const source = raw.trim();
+ if (source === '') {
+ return undefined;
+ }
+ const scanner: TransformScanner = { pos: 0 };
+ let total: AffineMatrix | undefined;
+ for (;;) {
+ skipTransformSeparators(source, scanner);
+ if (scanner.pos >= source.length) {
+ break;
+ }
+ const name = TRANSFORM_FUNCTIONS.find((candidate) => source.startsWith(candidate, scanner.pos));
+ if (name === undefined) {
+ return undefined;
+ }
+ scanner.pos += name.length;
+ skipTransformSeparators(source, scanner);
+ if (source[scanner.pos] !== '(') {
+ return undefined;
+ }
+ scanner.pos++;
+ const args: number[] = [];
+ for (;;) {
+ const hadComma = skipTransformSeparators(source, scanner);
+ if (source[scanner.pos] === ')') {
+ if (hadComma) {
+ return undefined;
+ }
+ scanner.pos++;
+ break;
+ }
+ const value = scanTransformNumber(source, scanner);
+ if (value === undefined) {
+ return undefined;
+ }
+ args.push(value);
+ }
+ const step = matrixFromFunction(name, args);
+ if (step === undefined) {
+ return undefined;
+ }
+ total = total === undefined ? step : composeMatrices(total, step);
+ }
+ return total;
+}
diff --git a/src/svg/units.test.ts b/src/svg/units.test.ts
new file mode 100644
index 00000000..d9ac4fdb
--- /dev/null
+++ b/src/svg/units.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest';
+import { parseSvgLengthPt, parseSvgUserUnits, parseSvgViewBox } from './units';
+
+describe('parseSvgLengthPt', () => {
+ it('resolves a bare number as one CSS px, at the exact 0.75pt ratio', () => {
+ expect(parseSvgLengthPt('100')).toBe(75);
+ expect(parseSvgLengthPt('100px')).toBe(75);
+ expect(parseSvgLengthPt('.5')).toBe(0.375);
+ expect(parseSvgLengthPt('1e2')).toBe(75);
+ });
+
+ it('converts each absolute unit by its exact factor', () => {
+ expect(parseSvgLengthPt('72pt')).toBe(72);
+ expect(parseSvgLengthPt('1in')).toBe(72);
+ expect(parseSvgLengthPt('25.4mm')).toBeCloseTo(72, 9);
+ expect(parseSvgLengthPt('2.54cm')).toBeCloseTo(72, 9);
+ expect(parseSvgLengthPt('1pc')).toBe(12);
+ // One q is a quarter millimetre, so 40q is 10mm -- not the 72pt of an inch.
+ expect(parseSvgLengthPt('40q')).toBeCloseTo((10 / 25.4) * 72, 9);
+ });
+
+ it('returns undefined for em/ex/percent and malformed values, never a silent zero', () => {
+ expect(parseSvgLengthPt('12em')).toBeUndefined();
+ expect(parseSvgLengthPt('3ex')).toBeUndefined();
+ expect(parseSvgLengthPt('50%')).toBeUndefined();
+ expect(parseSvgLengthPt('wide')).toBeUndefined();
+ expect(parseSvgLengthPt('10px5')).toBeUndefined();
+ expect(parseSvgLengthPt('')).toBeUndefined();
+ expect(parseSvgLengthPt(undefined)).toBeUndefined();
+ });
+});
+
+describe('parseSvgUserUnits', () => {
+ it('keeps a bare number the identity, since geometry attributes live in the user coordinate system the root map scales afterwards', () => {
+ expect(parseSvgUserUnits('10')).toBe(10);
+ });
+
+ it('converts absolute units into user units through the exact px ratio', () => {
+ // 100pt is 100/0.75 px; anything else would double-scale once the root viewBox map applies.
+ expect(parseSvgUserUnits('100pt')).toBeCloseTo(100 / 0.75, 9);
+ expect(parseSvgUserUnits('1in')).toBeCloseTo(96, 9);
+ });
+
+ it('returns undefined for the font-relative and percentage forms', () => {
+ expect(parseSvgUserUnits('2em')).toBeUndefined();
+ expect(parseSvgUserUnits('50%')).toBeUndefined();
+ });
+});
+
+describe('parseSvgViewBox', () => {
+ it('parses four whitespace or comma separated numbers', () => {
+ expect(parseSvgViewBox('0 0 100 60')).toEqual({ minX: 0, minY: 0, width: 100, height: 60 });
+ expect(parseSvgViewBox('-10 -5,100,60')).toEqual({ minX: -10, minY: -5, width: 100, height: 60 });
+ });
+
+ it('returns undefined for the wrong count and for negative dimensions', () => {
+ expect(parseSvgViewBox('0 0 100')).toBeUndefined();
+ expect(parseSvgViewBox('0 0 100 60 5')).toBeUndefined();
+ expect(parseSvgViewBox('0 0 -100 60')).toBeUndefined();
+ expect(parseSvgViewBox('0 0 100 abc')).toBeUndefined();
+ expect(parseSvgViewBox(undefined)).toBeUndefined();
+ });
+
+ it('accepts a zero dimension as legal-but-degenerate, leaving the classification to the caller', () => {
+ expect(parseSvgViewBox('0 0 0 60')).toEqual({ minX: 0, minY: 0, width: 0, height: 60 });
+ });
+});
diff --git a/src/svg/units.ts b/src/svg/units.ts
new file mode 100644
index 00000000..eeec1aae
--- /dev/null
+++ b/src/svg/units.ts
@@ -0,0 +1,74 @@
+// SVG length parsing: the one unit surface the read side needs. SVG lengths (width/height/viewBox companions and per-shape geometry) are expressed in CSS user units against the user coordinate system in force, so every absolute unit has an exact conversion factor into CSS px and then into the points this package's whole geometry pipeline runs on (CSS defines 1in = 96px and 1in = 72pt, so 1px = 72/96 = 0.75pt exactly -- a ratio, not a measured value).
+const PT_PER_PX = 0.75;
+const PT_PER_MM = 72 / 25.4;
+const PT_PER_CM = 720 / 25.4;
+const PT_PER_IN = 72;
+
+// A single SVG number, the shared grammar of every length and coordinate this file touches: optional sign, digits with optional fraction in either "1.5" or ".5" form, optional exponent. Kept as one pattern (rather than Number() alone) so a trailing unit is split off cleanly and a malformed value yields undefined instead of NaN poisoning downstream geometry.
+const SVG_NUMBER_PATTERN = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/;
+
+// Resolves one SVG length against the user-unit convention in force and returns it in points. A bare number is a user unit; under the root coordinate systems this reader builds (viewBox mapped 1:1 onto the viewport, or the 0.75pt fallback when the svg declares neither -- see readSvgContent's own root notes), one user unit is one CSS px, hence the PT_PER_PX factor. The absolute units convert by their exact factors, pc and q included (1pc = 12pt, 1q = 1/40cm exactly). Returns undefined for em/ex/% -- each needs a font context or a referent this reader keeps no model of -- and for malformed values; an unresolvable length is the caller's diagnostic to report, never a silent zero.
+const SVG_LENGTH_PATTERN = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)(px|pt|mm|cm|in|pc|q|em|ex|%)?$/;
+
+export function parseSvgLengthPt(raw: string | undefined): number | undefined {
+ if (raw === undefined) {
+ return undefined;
+ }
+ const match = SVG_LENGTH_PATTERN.exec(raw.trim());
+ if (match === null) {
+ return undefined;
+ }
+ const value = Number(match[1]);
+ if (!Number.isFinite(value)) {
+ return undefined;
+ }
+ switch (match[2]) {
+ case undefined:
+ case 'px':
+ return value * PT_PER_PX;
+ case 'pt':
+ return value;
+ case 'mm':
+ return value * PT_PER_MM;
+ case 'cm':
+ return value * PT_PER_CM;
+ case 'in':
+ return value * PT_PER_IN;
+ case 'pc':
+ return value * 12;
+ case 'q':
+ return (value * PT_PER_CM) / 40;
+ default:
+ return undefined;
+ }
+}
+
+// Resolves one SVG length into USER UNITS (CSS px) rather than points -- the form geometry attributes (x/y/width/rx/cx and stroke-width) need, because those coordinates live in the user coordinate system the root viewBox map afterwards scales, whereas a points value here would have pre-scaled them once and let the viewBox scale them again. Only absolute units are accepted: each converts through its exact pt factor and back through PT_PER_PX, so a bare number is itself (the identity), and em/ex/% return undefined for the same reason parseSvgLengthPt does.
+export function parseSvgUserUnits(raw: string | undefined): number | undefined {
+ const pt = parseSvgLengthPt(raw);
+ return pt === undefined ? undefined : pt / PT_PER_PX;
+}
+
+// A viewBox's four numbers (minX minY width height), whitespace/comma-separated. Width/height must be non-negative per the attribute's own grammar (SVG 2, "The 'svg' element"); a zero dimension is legal and degenerate, which the caller classifies, but a negative one is malformed and returns undefined rather than being silently negated.
+export interface SvgViewBox {
+ readonly minX: number;
+ readonly minY: number;
+ readonly width: number;
+ readonly height: number;
+}
+
+export function parseSvgViewBox(raw: string | undefined): SvgViewBox | undefined {
+ if (raw === undefined) {
+ return undefined;
+ }
+ const parts = raw.trim().replace(/,/g, ' ').split(/\s+/);
+ if (parts.length !== 4) {
+ return undefined;
+ }
+ const numbers = parts.map((part) => (SVG_NUMBER_PATTERN.test(part) ? Number(part) : Number.NaN));
+ // The parts.length === 4 check above guarantees every index exists, so the non-null assertions restate that check rather than assume past it -- the identical indexed access pattern read.ts's own polyline points use.
+ if (!numbers.every((value) => Number.isFinite(value)) || numbers[2]! < 0 || numbers[3]! < 0) {
+ return undefined;
+ }
+ return { minX: numbers[0]!, minY: numbers[1]!, width: numbers[2]!, height: numbers[3]! };
+}
diff --git a/src/svg/write.ts b/src/svg/write.ts
new file mode 100644
index 00000000..d913f715
--- /dev/null
+++ b/src/svg/write.ts
@@ -0,0 +1,162 @@
+import type { Color, ContentDocument, ContentDrawPage, ContentShape, ContentSubpath, ContentVector } from 'document-schema.js';
+import { colorToRgbHex } from 'document-schema.js';
+import { buildSvgPathData, buildSvgViewBox, formatPathNumber } from '../edit/odg/svg-path';
+import { mergeByPaintOrder } from '../model/paint-order';
+import type { SvgDiagnosticSink } from './diagnostics';
+import { encodeXmlText } from 'odf.js';
+
+// The svg write half: a drawing ContentDocument -> SVG text, the inverse of src/svg/read.ts. One user unit is written as one point (root width/height carry explicit pt units and viewBox is the identical 1:1 "0 0 W H" via buildSvgViewBox), so every ContentVector coordinate -- itself page-point space in the drawing variant's y-down convention -- lands in the output unchanged: no rescaling arithmetic on write, and readSvgContent parses the same numbers back. Vectors render as the six shape primitives; ContentShapes (draw:frame text/image/table content) have no SVG vector representation in this scope and are skipped under svg/shape-unsupported, never silently dropped.
+
+export class SvgUnsupportedDocumentKindError extends Error {
+ readonly kind: ContentDocument['kind'];
+
+ constructor(kind: ContentDocument['kind']) {
+ super(`buildSvgText: expected a drawing ContentDocument, got kind '${kind}'`);
+ this.name = 'SvgUnsupportedDocumentKindError';
+ this.kind = kind;
+ }
+}
+
+// svg has no second page, so writing a multi-page source is a caller decision, never a silent truncation -- the identical contract buildCsvText holds for sheets, carried by page INDEX here because drawing pages are anonymous (a sheet has a name; a page does not).
+export class SvgMultiPageNotSpecifiedError extends Error {
+ readonly pageCount: number;
+
+ constructor(pageCount: number) {
+ super(`buildSvgText: this document has more than one page (${pageCount}) -- pass { page: } to select one`);
+ this.name = 'SvgMultiPageNotSpecifiedError';
+ this.pageCount = pageCount;
+ }
+}
+
+export class SvgPageNotFoundError extends Error {
+ readonly page: number;
+ readonly pageCount: number;
+
+ constructor(page: number, pageCount: number) {
+ super(`buildSvgText: page index ${page} not found -- the document has ${pageCount} page(s)`);
+ this.name = 'SvgPageNotFoundError';
+ this.page = page;
+ this.pageCount = pageCount;
+ }
+}
+
+// Named BuildSvgTextOptions rather than SvgWriteOptions because convert.ts declares its own SvgWriteOptions as the ergonomic intersection type the named svg-targeted conversions expose -- the identical split csv holds between BuildCsvTextOptions and CsvWriteOptions, so the two layers never collide on this package's export surface.
+export interface BuildSvgTextOptions {
+ // Selects which page of a multi-page document is written. Optional only when the document has exactly one page.
+ readonly page?: number;
+ readonly onSvgDiagnostic?: SvgDiagnosticSink;
+}
+
+function selectPage(pages: readonly ContentDrawPage[], page: number | undefined): ContentDrawPage {
+ if (page !== undefined) {
+ const found = pages[page];
+ if (found === undefined) {
+ throw new SvgPageNotFoundError(page, pages.length);
+ }
+ return found;
+ }
+ if (pages.length === 0) {
+ throw new SvgPageNotFoundError(0, 0);
+ }
+ if (pages.length > 1) {
+ throw new SvgMultiPageNotSpecifiedError(pages.length);
+ }
+ return pages[0]!;
+}
+
+// The two dash patterns map onto the two stroke styles this ecosystem's writers share: "6 4" and "1 3" are the same constants src/edit/odg's own graphic writer uses, in user units -- here 1pt each, so a written dashed/dotted stroke round-trips at the same visual weight the ODF writers produce. A dotted pattern additionally needs round linecaps, or the dashes render as hairline rectangles rather than dots.
+const DASHED_PATTERN = '6 4';
+const DOTTED_PATTERN = '1 3';
+
+// colorToRgbHex returns the bare six-digit hex (no '#'), which is not a colour any CSS/SVG parser accepts -- the '#' is this format's own spelling of the value.
+function svgColor(fill: Color): string {
+ return `#${colorToRgbHex(fill)}`;
+}
+
+function fillAttr(fill: Color | undefined): string {
+ return fill === undefined ? ' fill="none"' : ` fill="${svgColor(fill)}"`;
+}
+
+function strokeAttr(vector: ContentVector, stroke: { readonly color: Color; readonly widthPt: number; readonly style?: 'solid' | 'dashed' | 'dotted' | 'double' }, sink: SvgDiagnosticSink | undefined): string {
+ let attrs = ` stroke="${svgColor(stroke.color)}" stroke-width="${formatPathNumber(stroke.widthPt)}"`;
+ if (stroke.style === 'dashed') {
+ attrs += ` stroke-dasharray="${DASHED_PATTERN}"`;
+ } else if (stroke.style === 'dotted') {
+ attrs += ` stroke-dasharray="${DOTTED_PATTERN}" stroke-linecap="round"`;
+ } else if (stroke.style === 'double') {
+ // SVG strokes are single -- the schema's 'double' style has no construct to map onto, so it writes solid under a diagnostic rather than being silently flattened.
+ sink?.({ code: 'svg/stroke-style-unsupported', detail: `${vector.sourcePath ?? vector.kind}: stroke style 'double' written as solid` });
+ }
+ return attrs;
+}
+
+// ContentVector.rotationDeg is clockwise-on-screen in the drawing variant's y-down space, which is exactly SVG's own rotate() convention -- so the transform is a direct transcription about the frame's own centre, the same centre src/layout/drawing.ts rotates about on the render side.
+function rotationAttr(rotationDeg: number | undefined, frame: { readonly xPt: number; readonly yPt: number; readonly widthPt: number; readonly heightPt: number }): string {
+ if (rotationDeg === undefined || rotationDeg === 0) {
+ return '';
+ }
+ const cx = frame.xPt + frame.widthPt / 2;
+ const cy = frame.yPt + frame.heightPt / 2;
+ return ` transform="rotate(${formatPathNumber(rotationDeg)} ${formatPathNumber(cx)} ${formatPathNumber(cy)})"`;
+}
+
+// A path's own subpaths are local to its frame (the ContentVector path variant contract), so writing absolute d coordinates is one offset: frame origin added to every point, reusing buildSvgPathData unchanged for the grammar itself.
+function offsetSubpaths(subpaths: readonly ContentSubpath[], offsetX: number, offsetY: number): ContentSubpath[] {
+ const offsetPoint = (point: { readonly xPt: number; readonly yPt: number }) => ({ xPt: point.xPt + offsetX, yPt: point.yPt + offsetY });
+ return subpaths.map((subpath) => ({
+ start: offsetPoint(subpath.start),
+ closed: subpath.closed,
+ segments: subpath.segments.map((segment) => (segment.kind === 'line'
+ ? { kind: 'line' as const, to: offsetPoint(segment.to) }
+ : { kind: 'cubic' as const, control1: offsetPoint(segment.control1), control2: offsetPoint(segment.control2), to: offsetPoint(segment.to) })),
+ }));
+}
+
+function vectorElement(vector: ContentVector, sink: SvgDiagnosticSink | undefined): string {
+ switch (vector.kind) {
+ case 'rect':
+ return ` `;
+ case 'ellipse': {
+ const cx = vector.frame.xPt + vector.frame.widthPt / 2;
+ const cy = vector.frame.yPt + vector.frame.heightPt / 2;
+ const rx = vector.frame.widthPt / 2;
+ const ry = vector.frame.heightPt / 2;
+ return ` `;
+ }
+ case 'line':
+ return ` `;
+ case 'path': {
+ const d = buildSvgPathData(offsetSubpaths(vector.subpaths, vector.frame.xPt, vector.frame.yPt));
+ const fillRule = vector.fillRule === 'evenodd' ? ' fill-rule="evenodd"' : '';
+ return ` `;
+ }
+ }
+}
+
+function shapeDetail(shape: ContentShape): string {
+ return shape.name ?? shape.sourcePath ?? 'shape';
+}
+
+export function buildSvgText(content: ContentDocument, options?: BuildSvgTextOptions): string {
+ if (content.kind !== 'drawing') {
+ throw new SvgUnsupportedDocumentKindError(content.kind);
+ }
+ const sink = options?.onSvgDiagnostic;
+ const page = selectPage(content.pages, options?.page);
+
+ const lines: string[] = [];
+ lines.push('');
+ lines.push(``);
+ if (content.metadata.title !== undefined && content.metadata.title !== '') {
+ lines.push(` ${encodeXmlText(content.metadata.title)} `);
+ }
+ for (const entry of mergeByPaintOrder(page.vectors, page.shapes)) {
+ if (entry.kind === 'vector') {
+ lines.push(` ${vectorElement(entry.value, sink)}`);
+ } else {
+ sink?.({ code: 'svg/shape-unsupported', detail: `${shapeDetail(entry.value)}: draw:frame text/image/table content has no SVG vector representation` });
+ }
+ }
+ lines.push(' ');
+ return `${lines.join('\n')}\n`;
+}