Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,15 +162,17 @@ DocumentFormatSchema.parse(userSuppliedFormat); // throws a ZodError for anythin

### Intermediate `DocumentPackage`, JSON, and bytes

Every conversion function accepts an `onDocument` callback receiving the intermediate `DocumentPackage` (content + layout). The port surfaces the same value as `package` on `ConversionResult`. For PDF-bypassing bridges, `pkg.layout` is always `undefined`.
Every conversion function accepts an `onDocument` callback receiving the intermediate `DocumentPackage` — the fused unified tree of document-schema.js 3: `content` (whose own nodes carry `frames`, the rendered page positions the layout pass stamped onto them, in PDF user-space) plus `pages` (each rendered page's size, indexed to match every `frames[].pageIndex`). The port surfaces the same value as `package` on `ConversionResult`. For PDF-bypassing bridges, `pkg.pages` is always `undefined` and no node carries frames — no layout pass ran.

```ts
import { docxToPdf } from 'documents.js';

const pdfBytes = docxToPdf(docxBytes, {
onDocument: (pkg) => {
console.log(pkg.content.kind); // 'wordprocessing'
console.log(pkg.layout?.pages.length); // populated for every X-to-PDF/PDF-to-X conversion
console.log(pkg.pages?.length); // populated for every X-to-PDF/PDF-to-X conversion
const block = pkg.content.kind === 'wordprocessing' ? pkg.content.sections[0]?.blocks[0] : undefined;
console.log(block?.kind === 'paragraph' ? block.runs[0]?.frames : 'no paragraph'); // that run's rendered placements
},
});
```
Expand All @@ -187,7 +189,7 @@ const { kind, value } = documentFromJson(JSON.parse(readFileSync('converted.doc.
// kind: 'DocumentPackage' (here) | 'ContentDocument' | 'LayoutDocument'
```

`buildDocumentBytes` rebuilds any `DocumentFormat`'s bytes from a `DocumentPackage` — `'pdf'` writes the `LayoutDocument` half directly (throwing if the package carries none), `'odf'` has no builder and throws, everything else rebuilds from the `ContentDocument` half:
`buildDocumentBytes` rebuilds any `DocumentFormat`'s bytes from a `DocumentPackage` — `'pdf'` rebuilds the pdf-codec view from the package's own frames+pages (`layoutDocumentFromPackage`, a mechanical inverse walking the content tree and emitting `LayoutItem`s from each node's recorded placements; throwing if the package carries no `pages`), `'odf'` has no builder and throws, everything else rebuilds from the `ContentDocument` half. `layoutDocumentFromPackage` is exported too, for a caller wanting the rebuilt `LayoutDocument` without writing bytes. Two honest limits on the pdf rebuild, both structural properties of what a package records: a run's frames carry positions, not the wrap decisions that distributed its text across them, so a wrapped run re-renders once, whole, at its first recorded placement; and no font registry or positioned formula survives a bare package (a formula block's frame records where it sat while its glyphs render as nothing):

```ts
import { buildDocumentBytes, docxToPdf } from 'documents.js';
Expand Down Expand Up @@ -543,7 +545,8 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
- **`ooxml.js`'s typed readers are the basis for conversion** — `readDocxContent`/`readPptxContent` are thin wrappers, not independent walks. They are deliberately not re-exported (exposing both would invite using the wrong one). `readDocx`'s `comments`/`footnotes`/`headers`/`footers`/`numbering` are exposed via `readDocxExtras`. `readPptx` has no extras reader yet. xlsx is the one exception: `ooxml.js`'s `readXlsxContent`/`buildXlsxPackage` already read/write a spreadsheet `ContentDocument` directly (unlike `readDocx`/`readPptx`, which `readDocxContent`/`readPptxContent` wrap), so they're re-exported as-is rather than given a documents.js-local wrapper of their own — `readXlsx`, the separate lossy cell-values-only view, stays unexported for the same reason `readDocx`/`readPptx` do.
- **ODF text content is not a plain string.** ODF represents runs of spaces as `<text:s>`, tabs as `<text:tab/>`, line breaks as `<text:line-break/>` — all elements, not text nodes. Every ODF text getter MUST call `decodeOdfText`, never `textContent()` — which silently drops them (no error, just shorter text).
- **docx⇄PDF and pptx⇄PDF are explicitly not round-trip-lossless** — see [Fidelity](#fidelity). The cross-format bridge pairs are a genuinely different case.
- **A `DocumentPackage` from `onDocument`/`ConversionResult.package` is a snapshot, not a live view** — mutating `content` afterwards leaves `layout` stale; nothing detects or rejects that.
- **A `DocumentPackage` from `onDocument`/`ConversionResult.package` is a snapshot, not a live view** — mutating `content` after the layout pass leaves its nodes' `frames` stale; nothing detects or rejects that, and the schema keeps `content`'s populated `frames` and `pages` in sync with nothing.
- **`frames` are stamped in place onto the caller's own content tree** — `convertXToLayout` mutates its `ContentDocument` argument (each node's placements are appended to its own `frames` array, one frame per rendered placement: per wrapped fragment on a run, the cell box on a cell, the emitted item's box on an image/vector/shape) and returns `pages` alongside the internal `LayoutDocument`. A run wrapped across three lines carries three frames; a repeat-row spreadsheet cell carries one per page it re-renders on. Reconstructors attach frames from the exact items each reconstructed node was clustered from, so every PDF-to-X conversion's content carries genuine positions too.
- **ODF text getters must call `decodeOdfText`.** See the dedicated gotcha above.
- **`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.
Expand Down Expand Up @@ -604,7 +607,7 @@ To run a single test file: `pnpm vitest run src/path/to/file.test.ts`.
- **A formula that cannot typeset degrades to its plain-text stand-in, never to nothing.** `buildDocxPackage` writes real OMML; `buildOdtPackage` writes real embedded formula sub-documents. The markdown writer is the only stand-in-only path. `odmToPdf` carries formulas through as ordinary blocks.
- **OMML read/write are deliberately asymmetric** — the reader covers more (`m:d`, `m:nary`, `m:acc`, `m:bar`, `m:func`, `m:sPre`) because it must read what Word wrote. `docx → odt → docx` round trips keep the mathematics but may change the OMML construct.
- **The OMML translator covers exactly what `src/mathml/layout.ts` typesets.** A stretchy fence diverges: PDF stretches it, docx writes it at base size. `munderover` becomes nested `m:limUpp`/`m:limLow` (no operand scope in MathML).
- **`sourcePath` traces a `LayoutItem` to its `ContentDocument` origin, but only within one read+layout pass** — not an edit-tracking mechanism.
- **`sourcePath` traces a `LayoutItem` to its `ContentDocument` origin, but only within one read+layout pass** — not an edit-tracking mechanism. Since the frames fusion it survives as traceability only: the authoritative node↔position association is each content node's own `frames`, stamped at the moment of layout (or of reconstruction) rather than re-matched by string afterwards.
- **`readMarkdownContent` passes `readMarkdown`'s result straight through** — `markdown-codec` already produces a full `ContentDocument`.
- **Every markdown construct-mapping gap is a documented `MarkdownDiagnosticCodes` entry** (`md/invented-page-geometry`, `md/nested-emphasis-flattened`, `md/link-title-dropped`, `md/code-block-info-string-dropped`, `md/blockquote-nested-depth`, `md/list-item-block-unlisted`, `md/list-item-multi-block-flattened`, `md/image-unresolved`, `md/raw-html-preserved-as-text`/`md/raw-html-dropped`, `md/front-matter-key-unmapped`, `md/heading-level-clamped`, `md/adjacent-links-merged`, `md/code-span-as-monospace-run`, `md/paragraph-indent-dropped`, `md/list-numid-fallback`, `md/table-cell-formatting-dropped`, `md/table-cell-multi-paragraph-joined`) — never a silent approximation.
- **`buildMarkdownText` throws for non-`'wordprocessing'` `ContentDocument`.**
Expand Down
155 changes: 64 additions & 91 deletions examples/document-package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@2.7.4/schemas/document-package.schema.json",
"formatVersion": 1,
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@3.2.0/schemas/document-package.schema.json",
"formatVersion": 2,
"content": {
"kind": "wordprocessing",
"formatVersion": 2,
"formatVersion": 3,
"metadata": {},
"sections": [
{
Expand All @@ -25,7 +25,23 @@
"text": "Hello, world!",
"fontFamily": "Calibri",
"sizePt": 11,
"sourcePath": "sections[0].blocks[0].runs[0]"
"sourcePath": "sections[0].blocks[0].runs[0]",
"frames": [
{
"pageIndex": 0,
"xPt": 72,
"yPt": 706.572265625,
"widthPt": 25.72216796875,
"heightPt": 13.427734375
},
{
"pageIndex": 0,
"xPt": 100.208984375,
"yPt": 706.572265625,
"widthPt": 29.283203125,
"heightPt": 13.427734375
}
]
}
],
"sourcePath": "sections[0].blocks[0]"
Expand All @@ -48,11 +64,29 @@
"text": "A1",
"fontFamily": "Calibri",
"sizePt": 11,
"sourcePath": "sections[0].blocks[1].rows[0].cells[0].blocks[0].runs[0]"
"sourcePath": "sections[0].blocks[1].rows[0].cells[0].blocks[0].runs[0]",
"frames": [
{
"pageIndex": 0,
"xPt": 72,
"yPt": 693.14453125,
"widthPt": 11.93994140625,
"heightPt": 13.427734375
}
]
}
],
"sourcePath": "sections[0].blocks[1].rows[0].cells[0].blocks[0]"
}
],
"frames": [
{
"pageIndex": 0,
"xPt": 72,
"yPt": 686.572265625,
"widthPt": 234,
"heightPt": 20
}
]
},
{
Expand All @@ -64,11 +98,29 @@
"text": "B1",
"fontFamily": "Calibri",
"sizePt": 11,
"sourcePath": "sections[0].blocks[1].rows[0].cells[1].blocks[0].runs[0]"
"sourcePath": "sections[0].blocks[1].rows[0].cells[1].blocks[0].runs[0]",
"frames": [
{
"pageIndex": 0,
"xPt": 306,
"yPt": 693.14453125,
"widthPt": 11.55859375,
"heightPt": 13.427734375
}
]
}
],
"sourcePath": "sections[0].blocks[1].rows[0].cells[1].blocks[0]"
}
],
"frames": [
{
"pageIndex": 0,
"xPt": 306,
"yPt": 686.572265625,
"widthPt": 234,
"heightPt": 20
}
]
}
]
Expand All @@ -80,89 +132,10 @@
}
]
},
"layout": {
"formatVersion": 1,
"metadata": {},
"pages": [
{
"widthPt": 612,
"heightPt": 792,
"items": [
{
"kind": "text",
"text": "Hello,",
"xPt": 72,
"yPt": 709.5263671875,
"font": {
"family": "Calibri",
"weight": "normal",
"style": "normal"
},
"sizePt": 11,
"color": {
"r": 0,
"g": 0,
"b": 0
},
"sourcePath": "sections[0].blocks[0].runs[0]"
},
{
"kind": "text",
"text": "world!",
"xPt": 100.208984375,
"yPt": 709.5263671875,
"font": {
"family": "Calibri",
"weight": "normal",
"style": "normal"
},
"sizePt": 11,
"color": {
"r": 0,
"g": 0,
"b": 0
},
"sourcePath": "sections[0].blocks[0].runs[0]"
},
{
"kind": "text",
"text": "A1",
"xPt": 72,
"yPt": 696.0986328125,
"font": {
"family": "Calibri",
"weight": "normal",
"style": "normal"
},
"sizePt": 11,
"color": {
"r": 0,
"g": 0,
"b": 0
},
"sourcePath": "sections[0].blocks[1].rows[0].cells[0].blocks[0].runs[0]"
},
{
"kind": "text",
"text": "B1",
"xPt": 306,
"yPt": 696.0986328125,
"font": {
"family": "Calibri",
"weight": "normal",
"style": "normal"
},
"sizePt": 11,
"color": {
"r": 0,
"g": 0,
"b": 0
},
"sourcePath": "sections[0].blocks[1].rows[0].cells[1].blocks[0].runs[0]"
}
]
}
],
"images": {}
}
"pages": [
{
"widthPt": 612,
"heightPt": 792
}
]
}
4 changes: 2 additions & 2 deletions examples/drawing.content.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@2.7.4/schemas/content-document.schema.json",
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@3.2.0/schemas/content-document.schema.json",
"kind": "drawing",
"formatVersion": 2,
"formatVersion": 3,
"metadata": {
"title": "My Drawing"
},
Expand Down
4 changes: 2 additions & 2 deletions examples/formula.content.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@2.7.4/schemas/content-document.schema.json",
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@3.2.0/schemas/content-document.schema.json",
"kind": "formula",
"formatVersion": 2,
"formatVersion": 3,
"metadata": {},
"formula": {
"mathml": [
Expand Down
12 changes: 6 additions & 6 deletions examples/layout-document.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@2.7.4/schemas/layout-document.schema.json",
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@3.2.0/schemas/layout-document.schema.json",
"formatVersion": 1,
"metadata": {},
"pages": [
Expand All @@ -11,7 +11,7 @@
"kind": "text",
"text": "Hello,",
"xPt": 72,
"yPt": 709.5263671875,
"yPt": 712.102,
"font": {
"family": "Calibri",
"weight": "normal",
Expand All @@ -28,8 +28,8 @@
{
"kind": "text",
"text": "world!",
"xPt": 100.208984375,
"yPt": 709.5263671875,
"xPt": 100.68008,
"yPt": 712.102,
"font": {
"family": "Calibri",
"weight": "normal",
Expand All @@ -47,7 +47,7 @@
"kind": "text",
"text": "A1",
"xPt": 72,
"yPt": 696.0986328125,
"yPt": 699.452,
"font": {
"family": "Calibri",
"weight": "normal",
Expand All @@ -65,7 +65,7 @@
"kind": "text",
"text": "B1",
"xPt": 306,
"yPt": 696.0986328125,
"yPt": 699.452,
"font": {
"family": "Calibri",
"weight": "normal",
Expand Down
4 changes: 2 additions & 2 deletions examples/presentation.content.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@2.7.4/schemas/content-document.schema.json",
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@3.2.0/schemas/content-document.schema.json",
"kind": "presentation",
"formatVersion": 2,
"formatVersion": 3,
"metadata": {},
"slides": [
{
Expand Down
4 changes: 2 additions & 2 deletions examples/spreadsheet.content.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@2.7.4/schemas/content-document.schema.json",
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@3.2.0/schemas/content-document.schema.json",
"kind": "spreadsheet",
"formatVersion": 2,
"formatVersion": 3,
"metadata": {
"title": "Grid Spreadsheet"
},
Expand Down
4 changes: 2 additions & 2 deletions examples/wordprocessing.content.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@2.7.4/schemas/content-document.schema.json",
"$schema": "https://cdn.jsdelivr.net/npm/document-schema.js@3.2.0/schemas/content-document.schema.json",
"kind": "wordprocessing",
"formatVersion": 2,
"formatVersion": 3,
"metadata": {},
"sections": [
{
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,11 @@
"packageManager": "pnpm@11.6.0",
"dependencies": {
"byte-codec": "^1.1.9",
"document-schema.js": "^2.7.17",
"document-schema.js": "^3.2.0",
"fflate": "^0.8.3",
"markdown-codec": "^1.4.2",
"odf.js": "^2.7.23",
"ooxml.js": "^2.11.31",
"markdown-codec": "^2.0.0",
"odf.js": "^3.0.1",
"ooxml.js": "^2.16.0",
"pdf-codec": "^2.2.35",
"zod": "^4.4.3"
},
Expand Down
Loading
Loading