From b43fb0e649f4d252da969869f2b30757388d0cfd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 17 Aug 2026 17:01:42 +0100 Subject: [PATCH 1/2] feat!: report pages and frame-stamped content instead of a layout half in DocumentPackage Bump document-schema.js to ^3.2.0, ooxml.js to ^2.16.0, odf.js to ^3.0.1 and markdown-codec to ^2.0.0 in the same change: holding markdown-codec 1.4.2 (schema ^2) alongside schema ^3 installs two schema copies and fails typecheck, so the bumps and the code migration cannot land apart. The four layout engines stamp every placement they compute onto the corresponding content node's own frames array -- one frame per rendered fragment on a run, the cell box on a cell, the emitted item's box on an image, vector or shape -- and return the package's pages array alongside the internal LayoutDocument, which remains pdf-codec's writePdf contract. buildDocumentBytes('pdf') rebuilds that LayoutDocument from the package's own frames and pages (layoutDocumentFromPackage) instead of reading a layout half the schema no longer carries; a run's frames carry positions, not wrap decisions, so a wrapped run re-renders once at its first recorded placement. BREAKING CHANGE: DocumentPackage is document-schema.js 3's fused shape ({ formatVersion: 2, content, pages }); onDocument and ConversionResult.package no longer report a layout, buildDocumentBytes(pkg, 'pdf') throws for a package with no pages and rebuilds from frames otherwise, convertDrawingToLayout returns { document, pages } rather than a bare LayoutDocument, and content read by markdown-codec 2.0.0 carries headingLevel on heading paragraphs. --- README.md | 13 +- examples/document-package.json | 155 ++++++------- examples/drawing.content.json | 4 +- examples/formula.content.json | 4 +- examples/layout-document.json | 12 +- examples/presentation.content.json | 4 +- examples/spreadsheet.content.json | 4 +- examples/wordprocessing.content.json | 4 +- package.json | 8 +- pnpm-lock.yaml | 48 ++-- src/convert/bridges.test.ts | 10 +- src/convert/composition.ts | 20 +- src/convert/convert-fonts.test.ts | 2 +- src/convert/convert.test.ts | 42 ++-- src/convert/convert.ts | 7 +- .../docx-odt-decoration-bridge.test.ts | 3 +- src/convert/formula.test.ts | 7 +- src/convert/from-package.test.ts | 22 +- src/convert/from-package.ts | 216 +++++++++++++++++- src/convert/local.test.ts | 13 +- src/convert/ondocument-timing.test.ts | 18 +- src/examples.test.ts | 26 ++- src/index.ts | 6 +- src/layout/drawing.test.ts | 4 +- src/layout/drawing.ts | 73 +++++- src/layout/engine.ts | 57 +++-- src/layout/shared.ts | 53 ++++- src/layout/sheets.ts | 24 +- src/layout/slides.ts | 37 ++- src/layout/text-layout.ts | 37 ++- 30 files changed, 651 insertions(+), 282 deletions(-) diff --git a/README.md b/README.md index 4e4b4c99..16977659 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ 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'; @@ -170,7 +170,9 @@ 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 }, }); ``` @@ -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'; @@ -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 ``, tabs as ``, line breaks as `` — 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. @@ -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`.** diff --git a/examples/document-package.json b/examples/document-package.json index 3cc731b6..406ad9ae 100644 --- a/examples/document-package.json +++ b/examples/document-package.json @@ -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": [ { @@ -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]" @@ -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 + } ] }, { @@ -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 + } ] } ] @@ -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 + } + ] } diff --git a/examples/drawing.content.json b/examples/drawing.content.json index 1e91143a..5883a6ac 100644 --- a/examples/drawing.content.json +++ b/examples/drawing.content.json @@ -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" }, diff --git a/examples/formula.content.json b/examples/formula.content.json index 95b9bf2b..058779ce 100644 --- a/examples/formula.content.json +++ b/examples/formula.content.json @@ -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": [ diff --git a/examples/layout-document.json b/examples/layout-document.json index 1c9bcc78..28ae344a 100644 --- a/examples/layout-document.json +++ b/examples/layout-document.json @@ -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": [ @@ -11,7 +11,7 @@ "kind": "text", "text": "Hello,", "xPt": 72, - "yPt": 709.5263671875, + "yPt": 712.102, "font": { "family": "Calibri", "weight": "normal", @@ -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", @@ -47,7 +47,7 @@ "kind": "text", "text": "A1", "xPt": 72, - "yPt": 696.0986328125, + "yPt": 699.452, "font": { "family": "Calibri", "weight": "normal", @@ -65,7 +65,7 @@ "kind": "text", "text": "B1", "xPt": 306, - "yPt": 696.0986328125, + "yPt": 699.452, "font": { "family": "Calibri", "weight": "normal", diff --git a/examples/presentation.content.json b/examples/presentation.content.json index a044ddf2..c544495e 100644 --- a/examples/presentation.content.json +++ b/examples/presentation.content.json @@ -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": [ { diff --git a/examples/spreadsheet.content.json b/examples/spreadsheet.content.json index 68977854..43ddd280 100644 --- a/examples/spreadsheet.content.json +++ b/examples/spreadsheet.content.json @@ -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" }, diff --git a/examples/wordprocessing.content.json b/examples/wordprocessing.content.json index b200ed35..6e456371 100644 --- a/examples/wordprocessing.content.json +++ b/examples/wordprocessing.content.json @@ -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": [ { diff --git a/package.json b/package.json index 5a5d24de..6e365831 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e97ea11..3636bc9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,20 +16,20 @@ importers: specifier: ^1.1.9 version: 1.1.9 document-schema.js: - specifier: ^2.7.17 - version: 2.7.17 + specifier: ^3.2.0 + version: 3.2.0 fflate: specifier: ^0.8.3 version: 0.8.3 markdown-codec: - specifier: ^1.4.2 - version: 1.4.2 + specifier: ^2.0.0 + version: 2.0.0 odf.js: - specifier: ^2.7.23 - version: 2.7.23 + specifier: ^3.0.1 + version: 3.0.1 ooxml.js: - specifier: ^2.11.31 - version: 2.11.31 + specifier: ^2.16.0 + version: 2.16.0 pdf-codec: specifier: ^2.2.35 version: 2.2.35 @@ -1625,10 +1625,6 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} - document-schema.js@2.7.17: - resolution: {integrity: sha512-z+1XNFToTrERlxIgoeyFb4Rw5b2nP+EgFOMH/CQfIjo0V9+To0LG6jJe2JUbTUfaggvbBRAEfDAHDMPVcBPROQ==} - engines: {node: '>=20'} - document-schema.js@3.2.0: resolution: {integrity: sha512-XDu/+fo56WrXrcR3f16xH5lYEQX+3b4W6kJELRNFwrrWxOqhQBQepXMkCi+niSrgCEIcfaC1IeaGPlZ8oj5gSw==} engines: {node: '>=20'} @@ -2246,8 +2242,8 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - markdown-codec@1.4.2: - resolution: {integrity: sha512-SfY0hXE5GdgS+GuiUVOf3v4SeD9XxIfT6ZBcJfLVY3Qa9Q3TfBzvNAVstE30g9CrW8vykvcz1xEACEYOos11Pw==} + markdown-codec@2.0.0: + resolution: {integrity: sha512-vbNj6KPo4hJxA7EAzqIiTAx9x3hF1xmFHFEJm/VVptlzuy8KfYYp3wmq7ZkWPbQ47/pIvANXfiA3Zc5NUvVitQ==} engines: {node: '>=20'} marked-terminal@7.3.0: @@ -2424,16 +2420,16 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} - odf.js@2.7.23: - resolution: {integrity: sha512-qmee21eZYO0WRBjmwUL72ZaggZ/NvkQ1LNkgK+DAn/9sV4PKtjOAuIb4uScL2jSJJ1a8S3afMP32fV+1eGDngw==} + odf.js@3.0.1: + resolution: {integrity: sha512-gVTXbQc6oHy1y1m7VxXXCQFU0HkTYoTSg7j/CH2Q+hSPulzOV4SCyPucS4CilSpmruckRONILpIMCi8DX4bFrA==} engines: {node: '>=20'} onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} - ooxml.js@2.11.31: - resolution: {integrity: sha512-VvUv39woYX4/IqFsCnxUKSHkM5jgaeK49cLRVzndYzZ1PT6anqRVyxOMlK0qjOjxLvnxWMJfeXBmm+NlMzHQOQ==} + ooxml.js@2.16.0: + resolution: {integrity: sha512-NLoTLONWlZ09ZdUURCfF+oC+yZ2+A7VO+qDYy6vGbhcOVCRka8W95TuESNKEO+737JRDRSI527IBv82YS1EHTg==} engines: {node: '>=20'} optionator@0.9.4: @@ -4581,10 +4577,6 @@ snapshots: dependencies: path-type: 4.0.0 - document-schema.js@2.7.17: - dependencies: - zod: 4.4.3 - document-schema.js@3.2.0: dependencies: zod: 4.4.3 @@ -5183,9 +5175,9 @@ snapshots: dependencies: semver: 7.8.5 - markdown-codec@1.4.2: + markdown-codec@2.0.0: dependencies: - document-schema.js: 2.7.17 + document-schema.js: 3.2.0 zod: 4.4.3 marked-terminal@7.3.0(marked@15.0.12): @@ -5299,9 +5291,9 @@ snapshots: obug@2.1.4: {} - odf.js@2.7.23: + odf.js@3.0.1: dependencies: - document-schema.js: 2.7.17 + document-schema.js: 3.2.0 fast-xml-parser: 5.10.1 fflate: 0.8.3 zod: 4.4.3 @@ -5310,9 +5302,9 @@ snapshots: dependencies: mimic-fn: 4.0.0 - ooxml.js@2.11.31: + ooxml.js@2.16.0: dependencies: - document-schema.js: 2.7.17 + document-schema.js: 3.2.0 fast-xml-parser: 5.10.1 fflate: 0.8.3 zod: 4.4.3 diff --git a/src/convert/bridges.test.ts b/src/convert/bridges.test.ts index f2b17ba5..3082ba8f 100644 --- a/src/convert/bridges.test.ts +++ b/src/convert/bridges.test.ts @@ -149,8 +149,8 @@ function paragraphTexts(content: ReturnType): string[] { } describe('onDocument (DocumentPackage side channel)', () => { - // A bridge never runs a layout engine (see convert.ts's own DocumentBridgeOptions comment), so its DocumentPackage always carries content with layout left undefined -- unlike the PDF-pivot conversions, which populate both (see convert.test.ts's own docxToPdf onDocument test). - it('calls onDocument with content populated and layout left undefined', () => { + // A bridge never runs a layout engine (see convert.ts's own DocumentBridgeOptions comment), so its DocumentPackage always carries content only, with no pages array and no node frames -- unlike the PDF-pivot conversions, which populate both (see convert.test.ts's own docxToPdf onDocument test). + it('calls onDocument with content populated and pages left undefined', () => { let captured: DocumentPackage | undefined; const docxBytes = odtToDocx(minimalOdtBytes(), { onDocument: (pkg) => { captured = pkg; } }); expect(docxBytes.length).toBeGreaterThan(0); @@ -159,7 +159,7 @@ describe('onDocument (DocumentPackage side channel)', () => { const pkg = captured!; expect(pkg.formatVersion).toBe(DOCUMENT_PACKAGE_FORMAT_VERSION); expect(pkg.content.kind).toBe('wordprocessing'); - expect(pkg.layout).toBeUndefined(); + expect(pkg.pages).toBeUndefined(); }); }); @@ -674,13 +674,13 @@ describe('markdownToDocx/docxToMarkdown and markdownToOdt/odtToMarkdown never in }); describe('onDocument (DocumentPackage side channel): markdown bridges', () => { - it('markdownToDocx calls onDocument with content populated and layout left undefined', () => { + it('markdownToDocx calls onDocument with content populated and pages left undefined', () => { let captured: DocumentPackage | undefined; const docxBytes = markdownToDocx(encodeMarkdownText(richMarkdownText()), { onDocument: (pkg) => { captured = pkg; } }); expect(docxBytes.length).toBeGreaterThan(0); expect(captured).toBeDefined(); expect(captured!.content.kind).toBe('wordprocessing'); - expect(captured!.layout).toBeUndefined(); + expect(captured!.pages).toBeUndefined(); }); }); diff --git a/src/convert/composition.ts b/src/convert/composition.ts index 51453d35..572c62b3 100644 --- a/src/convert/composition.ts +++ b/src/convert/composition.ts @@ -2,7 +2,7 @@ // // odf (a standalone formula document) and odm (an ODF master document) are deliberately NOT part of this engine: odfToPdf renders through src/mathml's own formula-positioning path rather than a ContentDocument -> LayoutDocument layout engine, and odmToPdf needs a caller-supplied resolveSubDocument callback that a fixed bytes-in/bytes-out contract cannot express. Both stay as the dedicated functions in convert.ts. -import { DOCUMENT_PACKAGE_FORMAT_VERSION, type ContentDocument, type DocumentPackage, type FontSubstitution, type LayoutDocument, type MathFontMetrics, type PositionedFormula, type ProvidedFont } from 'document-schema.js'; +import { DOCUMENT_PACKAGE_FORMAT_VERSION, type ContentDocument, type DocumentPackage, type FontSubstitution, type LayoutDocument, type MathFontMetrics, type PageSize, type PositionedFormula, type ProvidedFont } from 'document-schema.js'; import { buildXlsxPackage, decodePackage as decodeOoxmlPackage, encodePackage as encodeOoxmlPackage, readXlsxContent, type Package as OoxmlPackage } from 'ooxml.js'; import { decodePackage as decodeOdfPackage, encodePackage as encodeOdfPackage } from 'odf.js'; import { createFontMeasurer, createFontRegistry, loadMathFont, readPdf, writePdf, type FontRegistry, type PdfDiagnosticSink, type WinAnsiSubstitution } from 'pdf-codec'; @@ -247,7 +247,7 @@ function executeBridge(source: ContentFormat, target: ContentFormat, bytes: Uint throwIfAborted(options?.signal); options?.onDocument?.({ formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content: buildContent }); - // Build + encode the target. A bridge never runs a layout engine, so the reported DocumentPackage carries content only -- the identical `layout`-less shape convert.ts's own bridges report. + // Build + encode the target. A bridge never runs a layout engine, so the reported DocumentPackage carries content only, with no pages array and no node frames -- the identical layoutless shape convert.ts's own bridges report. if (targetNode.family === 'markdown') { const text = targetNode.build(buildContent); return targetNode.encode(text); @@ -280,30 +280,36 @@ function executeToPdf(format: ContentFormat, bytes: Uint8Array, opt } const measurer = createFontMeasurer(fonts); - // Layout by variant. The first three engines return { document, formulas } (positioned MathML the wordprocessing/presentation/spreadsheet engines lay out via src/mathml); drawing returns a bare LayoutDocument and takes no mathMetricsAt, so writePdf omits `formulas` for it -- the exact odgToPdf divergence. + // Layout by variant. Every engine returns { document, pages } plus (for the three that render embedded formulas) positioned MathML; the drawing engine takes no mathMetricsAt and produces no positioned formulas, so writePdf omits `formulas` for it -- the exact odgToPdf divergence. Each engine also stamps the placements it computed onto `content`'s own nodes in place (frames), so the content reported below is the fused unified package half, not the bare read output. let layout: LayoutDocument; + let pages: readonly PageSize[]; let formulas: readonly PositionedFormula[] | undefined; switch (content.kind) { case 'wordprocessing': { const result = LAYOUT_ENGINES.wordprocessing(content, { measurer, mathMetricsAt }); layout = result.document; + pages = result.pages; formulas = result.formulas; break; } case 'presentation': { const result = LAYOUT_ENGINES.presentation(content, { measurer, mathMetricsAt }); layout = result.document; + pages = result.pages; formulas = result.formulas; break; } case 'spreadsheet': { const result = LAYOUT_ENGINES.spreadsheet(content, { measurer, mathMetricsAt, signal: options?.signal }); layout = result.document; + pages = result.pages; formulas = result.formulas; break; } case 'drawing': { - layout = LAYOUT_ENGINES.drawing(content, { measurer }); + const result = LAYOUT_ENGINES.drawing(content, { measurer }); + layout = result.document; + pages = result.pages; break; } default: { @@ -311,7 +317,7 @@ function executeToPdf(format: ContentFormat, bytes: Uint8Array, opt } } - options?.onDocument?.({ formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content, layout }); + options?.onDocument?.({ formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content, pages: [...pages] }); if (formulas === undefined) { return writePdf(layout, { signal: options?.signal, onSubstitution: options?.onSubstitution, fonts }); @@ -324,7 +330,9 @@ function executeFromPdf(target: ContentFormat, bytes: Uint8Array, o const node = FORMAT_NODES[target]; const layout = readPdf(bytes, { signal: options?.signal, sink: options?.sink }); const content = RECONSTRUCTORS[node.variant](layout, { signal: options?.signal }); - options?.onDocument?.({ formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content, layout }); + // The pages half derives from the read LayoutDocument's own pages -- every rendered page's size, indexed to match the frames the reconstructor attached to the content it built. + const pages = layout.pages.map((page) => ({ widthPt: page.widthPt, heightPt: page.heightPt })); + options?.onDocument?.({ formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content, pages }); if (node.family === 'markdown') { const text = node.build(content); diff --git a/src/convert/convert-fonts.test.ts b/src/convert/convert-fonts.test.ts index 99884dfc..3601bf6b 100644 --- a/src/convert/convert-fonts.test.ts +++ b/src/convert/convert-fonts.test.ts @@ -159,7 +159,7 @@ function referenceOdgPdf(bytes: Uint8Array): Uint8Array Uint8Array; readonly reference: () => Uint8Array }[] = [ diff --git a/src/convert/convert.test.ts b/src/convert/convert.test.ts index fb3fbe36..cb657acc 100644 --- a/src/convert/convert.test.ts +++ b/src/convert/convert.test.ts @@ -1,6 +1,7 @@ import type { ContentVector, DocumentPackage, LayoutItem, LayoutLine, LayoutPath, LayoutRect, LayoutText } from 'document-schema.js'; import { DOCUMENT_PACKAGE_FORMAT_VERSION } from 'document-schema.js'; import { decodePackage, el, txt } from 'odf.js'; +import { decodePackage as decodeOdfPackage } from 'odf.js'; import { decodePackage as decodeOoxmlPackage, readXlsxContent } from 'ooxml.js'; import { describe, expect, it } from 'vitest'; import { createDocx, openDocx } from '../edit/docx/editor'; @@ -10,6 +11,9 @@ import { openOdp } from '../edit/odp/editor'; import { openOdt } from '../edit/odt/editor'; import { createPptx, openPptx } from '../edit/pptx/editor'; import { convertDrawingToLayout } from '../layout/drawing'; +import { convertSpreadsheetToLayout } from '../layout/sheets'; +import { loadMathFont } from 'pdf-codec'; +const mathMetricsAt = (sizePt: number) => loadMathFont().metricsAt(sizePt); import { readOdgContent } from '../odf/odg/read'; import { readDocxContent } from '../ooxml/docx/read'; import { readOdsContent } from '../odf/ods/read'; @@ -30,7 +34,7 @@ function layoutFromMinimalOdg() { if (content.kind !== 'drawing') { throw new Error('expected a drawing ContentDocument'); } - return convertDrawingToLayout(content, { measurer: createStandardFontMeasurer() }); + return convertDrawingToLayout(content, { measurer: createStandardFontMeasurer() }).document; } function pdfHeader(bytes: Uint8Array): string { @@ -83,12 +87,9 @@ describe('docxToPdf', () => { const contentImage = captured.content.sections.flatMap((s) => s.blocks).find((b) => b.kind === 'image'); expect(contentImage).toMatchObject({ kind: 'image', format: 'png', widthPt: 96, heightPt: 48 }); - // The LayoutDocument this conversion built internally placed a real, positioned LayoutImage on the page, sized in points, distinct from the two text paragraphs either side of it. - const layoutImage = captured.layout?.pages[0]?.items.find((item): item is Extract => item.kind === 'image'); - expect(layoutImage).toBeDefined(); - expect(layoutImage?.widthPt).toBe(96); - expect(layoutImage?.heightPt).toBe(48); - expect(captured.layout?.images[layoutImage!.imageId]).toMatchObject({ format: 'png' }); + // The layout pass fused a real, positioned placement onto the image block's own nodes: one frame on page 0, sized in points, distinct from the text runs either side of it. The rendered bytes themselves are proven by the readPdf round trip below. + expect(contentImage).toMatchObject({ frames: [{ pageIndex: 0, widthPt: 96, heightPt: 48 }] }); + expect(captured.pages?.[0]).toMatchObject({ widthPt: 612, heightPt: 792 }); // The PRODUCED PDF BYTES THEMSELVES actually embed the image as a real XObject, not just the intermediate LayoutDocument -- readPdf (this repo's own PDF reader) parses the PDF back and recovers the identical positioned image, proving the picture survived the full write path into genuine PDF content, not merely the layout stage. const reparsed = readPdf(pdfBytes); @@ -105,7 +106,7 @@ describe('docxToPdf', () => { expect(() => docxToPdf(buildSampleDocx('X'), { signal: controller.signal })).toThrow(); }); - it('calls onDocument exactly once with a DocumentPackage whose content and layout correlate via sourcePath', () => { + it('calls onDocument exactly once with a DocumentPackage whose content carries its own rendered positions as frames', () => { let captured: DocumentPackage | undefined; const pdfBytes = docxToPdf(buildSampleDocx('Hello from docx'), { onDocument: (pkg) => { captured = pkg; } }); expect(pdfHeader(pdfBytes)).toBe('%PDF-'); @@ -124,11 +125,15 @@ describe('docxToPdf', () => { const run = paragraph.runs[0]; expect(run?.sourcePath).toBeDefined(); - expect(pkg.layout).toBeDefined(); - // The layout engine's own word-wrapping splits one run's text into several LayoutText items (one per word) that all share that run's sourcePath -- see src/layout/sourcepath.test.ts's own documented behaviour for this exact split. Every matching item joins back up to the original run text, proving real correlation rather than merely "both fields are present". - const layoutTexts = (pkg.layout?.pages[0]?.items ?? []).filter((item): item is LayoutText => item.kind === 'text' && item.sourcePath === run?.sourcePath); - expect(layoutTexts.length).toBeGreaterThan(0); - expect(layoutTexts.map((item) => item.text).join(' ')).toBe('Hello from docx'); + // The fused unified package: pages is populated, and the layout pass stamped the run's own rendered placements directly onto the run node -- one frame per wrapped fragment, every one on a real page the pages array describes, in reading order. This is the correlation the old sourcePath-matching against a separate LayoutDocument proved, now proven on the content tree itself rather than across two halves. + expect(pkg.pages?.length).toBeGreaterThan(0); + expect(run?.frames?.length).toBeGreaterThan(0); + for (const frame of run?.frames ?? []) { + expect(frame.pageIndex).toBeGreaterThanOrEqual(0); + expect(frame.pageIndex).toBeLessThan(pkg.pages?.length ?? 0); + expect(frame.widthPt).toBeGreaterThan(0); + expect(frame.heightPt).toBeGreaterThan(0); + } }); }); @@ -231,11 +236,14 @@ describe('odsToPdf', () => { expect(text).toContain('1'); // row-number header label }); - // End-to-end proof for the per-cell decoration wiring, all the way from real ODF style XML: decoratedOdsBytes declares fo:background-color / fo:border / fo:text-align / style:vertical-align on real table-cell styles, odf.js's readOds resolves all four onto ContentSheetCell, and src/layout/sheets.ts turns them into genuine LayoutRect/LayoutLine items and a genuinely different text position. Asserted against onDocument's own LayoutDocument rather than a readPdf round trip, so the assertions pin what src/layout/sheets.ts itself emitted rather than what survived a second, independently-tested encode/decode hop. + // End-to-end proof for the per-cell decoration wiring, all the way from real ODF style XML: decoratedOdsBytes declares fo:background-color / fo:border / fo:text-align / style:vertical-align on real table-cell styles, odf.js's readOds resolves all four onto ContentSheetCell, and src/layout/sheets.ts turns them into genuine LayoutRect/LayoutLine items and a genuinely different text position. Asserted against convertSpreadsheetToLayout's own output (the exact LayoutDocument odsToPdf builds internally) rather than a readPdf round trip, so the assertions pin what src/layout/sheets.ts itself emitted rather than what survived a second, independently-tested encode/decode hop -- a DocumentPackage no longer carries the items themselves, only each node's fused frames. it('renders a decorated cell\'s own background, borders, alignment, and vertical alignment into the resulting layout', () => { - let pkg: DocumentPackage | undefined; - odsToPdf(decoratedOdsBytes(), { onDocument: (p) => { pkg = p; } }); - const items = pkg?.layout?.pages[0]?.items ?? []; + const content = readOdsContent(decodeOdfPackage(decoratedOdsBytes())); + if (content.kind !== 'spreadsheet') { + throw new Error('expected a spreadsheet ContentDocument'); + } + const { document: layout } = convertSpreadsheetToLayout(content, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + const items = layout.pages[0]?.items ?? []; const rects = items.filter((item): item is LayoutRect => item.kind === 'rect'); expect(rects).toHaveLength(1); // exactly the one cell that declared a background diff --git a/src/convert/convert.ts b/src/convert/convert.ts index a90e8579..4b02cafb 100644 --- a/src/convert/convert.ts +++ b/src/convert/convert.ts @@ -111,7 +111,8 @@ export function odfToPdf(bytes: Uint8Array, options?: DocumentToPdf pages: [{ widthPt: PAGE_SIZE_A4.widthPt, heightPt: PAGE_SIZE_A4.heightPt, items: [] }], images: {}, }; - options?.onDocument?.({ formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content, layout }); + // The reported package carries the one real A4 page it renders (pages) and no node frames -- a formula document's content has no renderable-item placements at all, since the formula's glyphs travel through writePdf's own formulas side channel rather than as page content. + options?.onDocument?.({ formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content, pages: [{ widthPt: PAGE_SIZE_A4.widthPt, heightPt: PAGE_SIZE_A4.heightPt }] }); throwIfAborted(options?.signal); return writePdf(layout, { signal: options?.signal, formulas: [{ pageIndex: 0, xPt: flipped.xPt, yPt: flipped.yPt, box }] }); } @@ -434,7 +435,7 @@ export function odbReportToPdf(content: ContentDocument, options?: DocumentToPdf throw new Error('odbReportToPdf requires a wordprocessing ContentDocument'); } const fonts = createFontRegistry({ fonts: options?.fonts, onSubstitution: options?.onFontSubstitution }); - const { document: layout, formulas } = convertWordprocessingToLayout(content, { measurer: createFontMeasurer(fonts), mathMetricsAt }); - options?.onDocument?.({ formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content, layout }); + const { document: layout, formulas, pages } = convertWordprocessingToLayout(content, { measurer: createFontMeasurer(fonts), mathMetricsAt }); + options?.onDocument?.({ formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content, pages: [...pages] }); return writePdf(layout, { signal: options?.signal, onSubstitution: options?.onSubstitution, formulas, fonts }); } diff --git a/src/convert/docx-odt-decoration-bridge.test.ts b/src/convert/docx-odt-decoration-bridge.test.ts index e1d6dad0..25bc2b20 100644 --- a/src/convert/docx-odt-decoration-bridge.test.ts +++ b/src/convert/docx-odt-decoration-bridge.test.ts @@ -1,4 +1,5 @@ import type { ContentDocument, ContentStrokeStyle } from 'document-schema.js'; +import { CONTENT_FORMAT_VERSION } from 'document-schema.js'; import { describe, expect, it } from 'vitest'; import { createDocx } from '../edit/docx/editor'; import { createOdt } from '../edit/odt/editor'; @@ -158,7 +159,7 @@ describe('docx/odt decoration bridge', () => { it('writes row.heightPt as w:trHeight and reads it back via readDocxContent', () => { const document: ContentDocument = { kind: 'wordprocessing', - formatVersion: 2, + formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, sections: [{ pageSize: { widthPt: 612, heightPt: 792 }, margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, blocks: [{ kind: 'table', rows: [{ cells: [{ blocks: [{ kind: 'paragraph', runs: [{ text: 'tall' }] }] }], heightPt: 28 }], columnWidthsPt: [468] }] }], }; diff --git a/src/convert/formula.test.ts b/src/convert/formula.test.ts index 23ee5f85..8944286f 100644 --- a/src/convert/formula.test.ts +++ b/src/convert/formula.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; const mathMetricsAt = (sizePt: number) => loadMathFont().metricsAt(sizePt); import { unzlibSync } from 'fflate'; +import { PAGE_SIZE_A4 } from 'document-schema.js'; import { buildXml as buildOdfXml, zipPackage } from 'odf.js'; import { describe, expect, it } from 'vitest'; import { FRACTION_FORMULA, MATRIX_FORMULA, odfFormulaBytes, SQRT_FORMULA, STRETCHY_FENCE_FORMULA, SUBSUP_FORMULA } from '../test-support/odf'; @@ -326,9 +327,9 @@ describe('a formula as a real ContentDocument, not a side-channel map', () => { } expect(captured.content.formula.starMath).toBe('{a} over {b}'); expect(captured.content.formula.mathml.length).toBeGreaterThan(0); - // The layout half is a genuine single A4 page carrying no items, by construction: the formula renders through writePdf's own separate formula positioning, never as page content. - expect(captured.layout?.pages).toHaveLength(1); - expect(captured.layout?.pages[0]?.items).toHaveLength(0); + // The pages half is a genuine single A4 page and no node carries any frame, by construction: the formula renders through writePdf's own separate formula positioning, never as page content, so there are no item placements to fuse onto content. + expect(captured.pages).toHaveLength(1); + expect(captured.pages?.[0]).toEqual(PAGE_SIZE_A4); }); it('carries an odt formula through onDocument as part of the ContentDocument the conversion built', () => { diff --git a/src/convert/from-package.test.ts b/src/convert/from-package.test.ts index ce556983..b55ec3e0 100644 --- a/src/convert/from-package.test.ts +++ b/src/convert/from-package.test.ts @@ -1,4 +1,4 @@ -import type { DocumentPackage } from 'document-schema.js'; +import type { DocumentPackage, LayoutItem } from 'document-schema.js'; import { DOCUMENT_PACKAGE_FORMAT_VERSION } from 'document-schema.js'; import { decodePackage as decodeOdfPackage } from 'odf.js'; import { decodePackage as decodeOoxmlPackage, readXlsxContent } from 'ooxml.js'; @@ -81,7 +81,8 @@ describe('buildDocumentBytes', () => { expect(page).toBeDefined(); }); - it('writes PDF bytes directly from a package that carries a real layout', () => { + // The pdf target rebuilds the pdf-codec view from the package's own fused positions (layoutDocumentFromPackage, the frames-to-layout inverse) and writes it -- the package carries no LayoutDocument any more, only each node's own frames plus the pages array. + it('writes PDF bytes rebuilt from a frame-stamped package', () => { let captured: DocumentPackage | undefined; docxToPdf(minimalDocxBytes(), { onDocument: (pkg) => { captured = pkg; } }); if (captured === undefined) { @@ -89,17 +90,26 @@ describe('buildDocumentBytes', () => { } const bytes = buildDocumentBytes(captured, 'pdf'); const layout = readPdf(bytes); - expect(layout.pages.length).toBeGreaterThan(0); + expect(layout.pages.length).toBe(captured.pages?.length); + // The rebuilt page carries the stamped text back as real positioned text: each run renders once, whole, at its first recorded frame, so every run's own text survives the package -> pdf round trip verbatim. + if (captured.content.kind !== 'wordprocessing') { + throw new Error('expected a wordprocessing ContentDocument'); + } + const texts = layout.pages.flatMap((page) => page.items.filter((item): item is Extract => item.kind === 'text').map((item) => item.text)); + const runTexts = captured.content.sections.flatMap((section) => section.blocks).flatMap((block) => (block.kind === 'paragraph' ? block.runs.map((run) => run.text) : [])).filter((text) => text.length > 0); + for (const runText of runTexts) { + expect(texts).toContain(runText); + } }); - it('throws when asked for pdf from a package with no layout (a bridge conversion dump)', () => { + it('throws when asked for pdf from a package with no pages (a bridge conversion dump)', () => { let captured: DocumentPackage | undefined; odtToDocx(minimalOdtBytes(), { onDocument: (pkg) => { captured = pkg; } }); if (captured === undefined) { throw new Error('expected odtToDocx to report a package via onDocument'); } - expect(captured.layout).toBeUndefined(); - expect(() => buildDocumentBytes(captured!, 'pdf')).toThrow(/has no layout/); + expect(captured.pages).toBeUndefined(); + expect(() => buildDocumentBytes(captured!, 'pdf')).toThrow(/has no pages/); }); it('builds real xlsx bytes from a spreadsheet package', () => { diff --git a/src/convert/from-package.ts b/src/convert/from-package.ts index 57f952a2..fe8dcdf9 100644 --- a/src/convert/from-package.ts +++ b/src/convert/from-package.ts @@ -1,15 +1,20 @@ -import type { DocumentPackage } from 'document-schema.js'; +import type { ContentBlock, ContentImageBlock, ContentParagraph, ContentRun, ContentSheet, ContentSheetCell, ContentTableCell, ContentTable, ContentVector, DocumentPackage, LayoutDocument, LayoutFrame, LayoutImage, LayoutImageAsset, LayoutItem, LayoutLink, LayoutPage, LayoutText } from 'document-schema.js'; +import { COLOR_BLACK, DEFAULT_LAYOUT_FONT, LAYOUT_FORMAT_VERSION } from 'document-schema.js'; import { writePdf } from 'pdf-codec'; +import { flipY } from '../model/geometry'; +import { convertVector } from '../layout/drawing'; +import { NOMINAL_CELL_TEXT_SIZE_PT } from '../layout/sheets'; +import { NOMINAL_TEXT_SIZE_PT, pushCellBorderLines, registerImage, runFont } from '../layout/shared'; import { DOCUMENT_FORMAT_CODECS, requireArrayBufferBytes } from '../codecs/registry'; import type { DocumentFormat } from './port'; -// Builds any DocumentFormat's own bytes from an already-assembled DocumentPackage (content + optional layout, document-schema.js) -- the reverse of what every ergonomic X-to-PDF/PDF-to-X conversion's own onDocument callback hands back. 'pdf' writes the package's own LayoutDocument half directly (no font registry, no positioned formulas -- neither survives a bare DocumentPackage, since both are side channels a DocumentPackage never carries: a formula renders as nothing and an embedded font falls back to the standard 14 or a vendored substitute; see this package's own README for the DocumentPackage-is-a-snapshot gotcha). Every other target dispatches through DOCUMENT_FORMAT_CODECS (src/codecs/registry.ts), building a fresh package from the ContentDocument half through the identical buildXPackage function the matching pdf-to-X/bridge conversion already uses, then encoding it with that format's own codec -- xlsx now goes through this exact same dispatch (DOCUMENT_FORMAT_CODECS.xlsx.content.write wraps ooxml.js's buildXlsxPackage), no longer a named exception. 'odf' still has no builder at all -- a standalone formula document has no write path from ContentDocument to begin with -- so it alone is rejected outright ahead of the registry lookup. +// Builds any DocumentFormat's own bytes from an already-assembled DocumentPackage (content + its fused positions, document-schema.js) -- the reverse of what every ergonomic X-to-PDF/PDF-to-X conversion's own onDocument callback hands back. Every target except 'pdf' dispatches through DOCUMENT_FORMAT_CODECS (src/codecs/registry.ts), building a fresh package from the ContentDocument half through the identical buildXPackage function the matching pdf-to-X/bridge conversion already uses, then encoding it with that format's own codec -- xlsx goes through this exact same dispatch (DOCUMENT_FORMAT_CODECS.xlsx.content.write wraps ooxml.js's buildXlsxPackage), no longer a named exception. 'odf' still has no builder at all -- a standalone formula document has no write path from ContentDocument to begin with -- so it alone is rejected outright ahead of the registry lookup. export function buildDocumentBytes(pkg: DocumentPackage, target: DocumentFormat): Uint8Array { if (target === 'pdf') { - if (pkg.layout === undefined) { - throw new Error("this DocumentPackage has no layout -- only a package dumped from a -to-pdf or pdf-to- conversion carries one; a bridge conversion's own dump (e.g. odt-to-docx) never does, so 'pdf' is not a reachable target from it"); + if (pkg.pages === undefined) { + throw new Error("this DocumentPackage has no pages -- only a package dumped from a -to-pdf or pdf-to- conversion carries them; a bridge conversion's own dump (e.g. odt-to-docx) never does, so 'pdf' is not a reachable target from it"); } - return writePdf(pkg.layout); + return writePdf(layoutDocumentFromPackage(pkg)); } if (target === 'odf') { throw new Error("'odf' (a standalone formula document) cannot be built from a DocumentPackage -- there is no ContentDocument-to-odf builder"); @@ -20,3 +25,204 @@ export function buildDocumentBytes(pkg: DocumentPackage, target: DocumentFormat) } return requireArrayBufferBytes(content.write(pkg.content)); } + +// --- The frames-to-layout inverse ---------------------------------------------------------------- +// +// Rebuilds the pdf-codec LayoutDocument a package's own frames + pages describe: a mechanical inverse that walks the content tree and emits LayoutItems from each node's own recorded placements. This is the fusion-faithful direction -- the package now CARRIES the positions (a layout pass stamped them onto content's own nodes), so from-package reconstructs the pdf-codec view from them rather than needing a parallel layout side-channel, which is exactly the second-tree coupling the fused DocumentPackage design removed. +// +// Two honest limits, both structural properties of what a package records, not gaps in this walk: +// +// 1. A run's frames record POSITIONS, not the wrap decisions that distributed its text across them. Re-splitting the text would need the font metrics the original layout pass had; guessing a split would garble words. So a run's full text renders once, at its first recorded placement, and its further frames carry no additional text -- a single-frame run (the common case: an unwrapped line, a spreadsheet cell) round-trips exactly; a wrapped run re-renders as one long overflowing line. A spreadsheet cell is exempt by construction: sheets.ts lays cell text out as a single line, so a cell's own displayText at its own frame is an exact re-render. +// 2. No font registry and no positioned formulas survive a bare DocumentPackage, exactly as before the fusion: a formula block's frame records where it sat while its glyphs render as nothing, and text draws through the standard 14 or a caller-configured default face. + +interface FrameWalkState { + readonly pages: LayoutPage[]; + readonly images: Record; +} + +// The page a frame's own pageIndex names, or undefined when it points outside the package's own pages array -- an internally inconsistent or hand-edited package. There is nothing to render such a frame onto, so each emitter skips it; every other frame in the same tree still renders. +function pageOfFrame(state: FrameWalkState, frame: LayoutFrame): LayoutPage | undefined { + return state.pages[frame.pageIndex]; +} + +// One run's emission: the run's full text at its FIRST frame (the wrap-decision limit above), plus a LayoutLink alongside when the run is hyperlinked. Font resolution mirrors the layout engines' own defaults (shared.ts's runFont and NOMINAL_TEXT_SIZE_PT), so a run that carried no explicit formatting renders as it would have laid out. +function emitRun(state: FrameWalkState, run: ContentRun): void { + const frame = run.frames?.[0]; + if (frame === undefined) { + return; + } + const page = pageOfFrame(state, frame); + if (page === undefined) { + return; + } + const font = runFont(run); + const sizePt = run.sizePt ?? NOMINAL_TEXT_SIZE_PT; + const textItem: LayoutText = { kind: 'text', text: run.text, xPt: frame.xPt, yPt: frame.yPt, font, sizePt, color: run.color ?? COLOR_BLACK, underline: run.underline }; + page.items.push(textItem); + if (run.hyperlink !== undefined) { + const link: LayoutLink = { kind: 'link', uri: run.hyperlink, xPt: frame.xPt, yPt: frame.yPt, widthPt: frame.widthPt, heightPt: frame.heightPt }; + page.items.push(link); + } +} + +// A paragraph's own frames record its list-marker placements (engine.ts stamps the paragraph node, not any run, for the marker it derives from list membership). The marker text itself came from the engine's own per-numId counters, which a package does not carry, so there is nothing honest to re-render at those positions -- the frames stay recorded on the node (traceability) and emit nothing here. +function emitParagraph(state: FrameWalkState, paragraph: ContentParagraph): void { + for (const run of paragraph.runs) { + emitRun(state, run); + } +} + +function emitImageBlock(state: FrameWalkState, block: ContentImageBlock, frames: readonly LayoutFrame[] | undefined): void { + for (const frame of frames ?? []) { + const page = pageOfFrame(state, frame); + if (page === undefined) { + continue; + } + const imageId = registerImage(block, state.images); + const image: LayoutImage = { kind: 'image', imageId, xPt: frame.xPt, yPt: frame.yPt, widthPt: frame.widthPt, heightPt: frame.heightPt }; + page.items.push(image); + } +} + +// A table cell's own frame is the whole cell box: its declared background re-renders as the LayoutRect the engine emitted, and its declared borders as the same four edge lines pushCellBorderLines produces from a y-down frame -- flipY is its own exact inverse, so un-flipping through the package's own page height recovers the frame the original emission started from. +function emitTableCell(state: FrameWalkState, cell: ContentTableCell): void { + for (const frame of cell.frames ?? []) { + const page = pageOfFrame(state, frame); + if (page === undefined) { + continue; + } + if (cell.background !== undefined) { + page.items.push({ kind: 'rect', xPt: frame.xPt, yPt: frame.yPt, widthPt: frame.widthPt, heightPt: frame.heightPt, fill: cell.background }); + } + if (cell.borders !== undefined) { + const frameYDown = flipY({ xPt: frame.xPt, yPt: frame.yPt, widthPt: frame.widthPt, heightPt: frame.heightPt }, page.heightPt); + pushCellBorderLines(cell.borders, frameYDown, page.heightPt, cell.sourcePath, page.items); + } + } + for (const block of cell.blocks) { + if (block.kind === 'paragraph') { + emitParagraph(state, block); + } else if (block.kind === 'image') { + emitImageBlock(state, block, block.frames); + } else if (block.kind === 'table') { + emitTable(state, block); + } + } +} + +function emitTable(state: FrameWalkState, table: ContentTable): void { + for (const row of table.rows) { + for (const cell of row.cells) { + emitTableCell(state, cell); + } + } +} + +// One drawing vector: re-runs the layout engine's own single vector-to-item conversion against the frame's own page height, so the rebuilt geometry is identical to a fresh layout pass's emission by construction (one implementation, no drift) -- a vector's own frame plus the page height fully determine its placement, which is what makes the exact re-derivation possible where text wrapping is not. +function emitVector(state: FrameWalkState, vector: ContentVector): void { + for (const frame of vector.frames ?? []) { + const page = pageOfFrame(state, frame); + if (page === undefined) { + continue; + } + const items: LayoutItem[] = page.items; + convertVector(vector, page.heightPt, items); + } +} + +// One spreadsheet cell. A cell whose runs carry stamped frames renders those (per-run styling survives); a cell with no runs -- or none that rendered, e.g. a numeric overflow the engine replaced with a synthesised '###' -- falls back to its displayText at the cell's own frames through the same nominal font/size the sheets engine itself renders an unstyled cell at. Exact either way, per the single-line note in the module doc above. +function emitSheetCell(state: FrameWalkState, cell: ContentSheetCell): void { + const hasStampedRuns = (cell.runs ?? []).some((run) => (run.frames?.length ?? 0) > 0); + if (hasStampedRuns) { + for (const run of cell.runs ?? []) { + emitRun(state, run); + } + } else { + for (const frame of cell.frames ?? []) { + const page = pageOfFrame(state, frame); + if (page === undefined) { + continue; + } + page.items.push({ kind: 'text', text: cell.displayText, xPt: frame.xPt, yPt: frame.yPt, font: DEFAULT_LAYOUT_FONT, sizePt: NOMINAL_CELL_TEXT_SIZE_PT, color: COLOR_BLACK }); + } + } + for (const frame of cell.frames ?? []) { + const page = pageOfFrame(state, frame); + if (page === undefined) { + continue; + } + if (cell.background !== undefined) { + page.items.push({ kind: 'rect', xPt: frame.xPt, yPt: frame.yPt, widthPt: frame.widthPt, heightPt: frame.heightPt, fill: cell.background }); + } + if (cell.borders !== undefined) { + const frameYDown = flipY({ xPt: frame.xPt, yPt: frame.yPt, widthPt: frame.widthPt, heightPt: frame.heightPt }, page.heightPt); + pushCellBorderLines(cell.borders, frameYDown, page.heightPt, cell.sourcePath, page.items); + } + } +} + +// One sheet: every populated cell, then every floating (cell-anchored) image at its own recorded frames. +function emitSheet(state: FrameWalkState, sheet: ContentSheet): void { + for (const cell of sheet.cells) { + emitSheetCell(state, cell); + } + for (const image of sheet.images) { + for (const frame of image.frames ?? []) { + const page = pageOfFrame(state, frame); + if (page === undefined) { + continue; + } + const imageId = registerImage(image, state.images); + const layoutImage: LayoutImage = { kind: 'image', imageId, xPt: frame.xPt, yPt: frame.yPt, widthPt: frame.widthPt, heightPt: frame.heightPt }; + page.items.push(layoutImage); + } + } +} + +// The shared block walk for the three shape-carrying variants (wordprocessing sections, presentation slides, drawing pages): paragraphs/images/tables/embedded objects emit from their own frames wherever they sit in the tree. +function emitBlocks(state: FrameWalkState, blocks: readonly ContentBlock[]): void { + for (const block of blocks) { + if (block.kind === 'paragraph') { + emitParagraph(state, block); + } else if (block.kind === 'image') { + emitImageBlock(state, block, block.frames); + } else if (block.kind === 'table') { + emitTable(state, block); + } + // 'embeddedObject' and 'pageBreak' emit nothing: an embedded formula's glyphs rendered through writePdf's positioned-formulas channel (CID-font glyph runs with no LayoutItem kind, which never travelled in a DocumentPackage even before the fusion -- its frame is honoured as a position record on the node, and nothing renders from it), and a page break is structural, with no placement of its own. + } +} + +// The public inverse. Walks each ContentDocument variant's own tree in document order, so the items land on each page in the same order the original layout pass emitted them (paint order is array order); a package whose content carries no frames at all (a bridge dump, or fresh reader output) still rebuilds the pages themselves, empty. +export function layoutDocumentFromPackage(pkg: DocumentPackage): LayoutDocument { + const pages: LayoutPage[] = (pkg.pages ?? []).map((page) => ({ widthPt: page.widthPt, heightPt: page.heightPt, items: [] })); + const state: FrameWalkState = { pages, images: {} }; + const content = pkg.content; + if (content.kind === 'wordprocessing') { + for (const section of content.sections) { + emitBlocks(state, section.blocks); + } + } else if (content.kind === 'presentation') { + for (const slide of content.slides) { + for (const shape of slide.shapes) { + // A shape's own frames carry no renderable payload (a bare shape emits no item of its own -- its content blocks carry everything), so only its blocks walk. + emitBlocks(state, shape.blocks); + } + } + } else if (content.kind === 'spreadsheet') { + for (const sheet of content.sheets) { + emitSheet(state, sheet); + } + } else if (content.kind === 'drawing') { + for (const drawPage of content.pages) { + for (const vector of drawPage.vectors) { + emitVector(state, vector); + } + for (const shape of drawPage.shapes) { + emitBlocks(state, shape.blocks); + } + } + } + // 'formula' content has no frames to walk at all: a standalone formula document renders through writePdf's own formula positioning (see convert.ts's odfToPdf), of which a package carries no record beyond the page sizes themselves. + return { formatVersion: LAYOUT_FORMAT_VERSION, metadata: content.metadata, pages, images: state.images }; +} diff --git a/src/convert/local.test.ts b/src/convert/local.test.ts index b03723f9..3334be39 100644 --- a/src/convert/local.test.ts +++ b/src/convert/local.test.ts @@ -305,14 +305,14 @@ describe('createLocalDocumentConverter: convert', () => { expect(result.diagnostics.some((d) => d.code === 'pdf/xref-recovered')).toBe(true); }); - it('returns a package with correlated content and layout for a PDF-pivot conversion (docx -> pdf)', async () => { + it('returns a package with frame-stamped content and pages for a PDF-pivot conversion (docx -> pdf)', async () => { const converter = createLocalDocumentConverter(); const result = await converter.convert({ source: { format: 'docx', bytes: buildSampleDocx('Hi') }, targetFormat: 'pdf' }, { signal: new AbortController().signal }); expect(result.package).toBeDefined(); const pkg = result.package!; expect(pkg.content.kind).toBe('wordprocessing'); - expect(pkg.layout).toBeDefined(); + expect(pkg.pages).toBeDefined(); if (pkg.content.kind !== 'wordprocessing') { throw new Error('expected a wordprocessing ContentDocument'); } @@ -322,18 +322,19 @@ describe('createLocalDocumentConverter: convert', () => { } const run = paragraph.runs[0]; expect(run?.sourcePath).toBeDefined(); - const layoutText = pkg.layout?.pages[0]?.items.find((item) => item.kind === 'text' && item.sourcePath === run?.sourcePath); - expect(layoutText).toBeDefined(); + // The layout pass fused the run's rendered position onto the run node itself, on a page the package's own pages array describes. + expect(run?.frames?.length).toBeGreaterThan(0); + expect(run?.frames?.[0]?.pageIndex).toBe(0); }); - it('returns a package with content only (layout undefined) for a PDF-bypassing bridge conversion (odt -> docx)', async () => { + it('returns a package with content only (pages undefined) for a PDF-bypassing bridge conversion (odt -> docx)', async () => { const converter = createLocalDocumentConverter(); const result = await converter.convert({ source: { format: 'odt', bytes: minimalOdtBytes() }, targetFormat: 'docx' }, { signal: new AbortController().signal }); expect(result.package).toBeDefined(); const pkg = result.package!; expect(pkg.content.kind).toBe('wordprocessing'); - expect(pkg.layout).toBeUndefined(); + expect(pkg.pages).toBeUndefined(); }); }); diff --git a/src/convert/ondocument-timing.test.ts b/src/convert/ondocument-timing.test.ts index 49520702..393a2435 100644 --- a/src/convert/ondocument-timing.test.ts +++ b/src/convert/ondocument-timing.test.ts @@ -3,35 +3,35 @@ import type { DocumentPackage } from 'document-schema.js'; import { odsToPdf, odsToXlsx, pdfToXlsx, xlsxToMarkdown, xlsxToPdf } from './convert'; import { gridOdsBytes } from '../test-support/ods'; -// Regression guard: the original hand-written xlsxToPdf/pdfToXlsx/xlsxToMarkdown/markdownToXlsx forwarded onDocument to the LAST hop of their internal composition, so the caller received the package that actually produced the output bytes (content+layout for a toPdf final hop, content-only for a bridge final hop). The composition engine must preserve that: onDocument fires exactly once, on the LAST hop, not the first. +// Regression guard: the original hand-written xlsxToPdf/pdfToXlsx/xlsxToMarkdown/markdownToXlsx forwarded onDocument to the LAST hop of their internal composition, so the caller received the package that actually produced the output bytes (content+pages, frames stamped, for a toPdf/fromPdf final hop; content-only for a bridge final hop). The composition engine must preserve that: onDocument fires exactly once, on the LAST hop, not the first. describe('onDocument fires on the last hop of a composed path', () => { - it('xlsxToPdf reports a package with layout (the odsToPdf hop), not a content-only bridge package', () => { + it('xlsxToPdf reports a package with pages (the odsToPdf hop), not a content-only bridge package', () => { const xlsxBytes = odsToXlsx(gridOdsBytes()); let captured: DocumentPackage | undefined; xlsxToPdf(xlsxBytes, { onDocument: (pkg) => { captured = pkg; } }); if (captured === undefined) throw new Error('onDocument was not called'); - // The toPdf hop produces a layout; a bridge hop does not. The original xlsxToPdf forwarded onDocument to odsToPdf (the last hop), so layout must be defined. - expect(captured.layout).toBeDefined(); + // The toPdf hop runs a layout engine, so its package carries pages and frame-stamped content; a bridge hop carries neither. The original xlsxToPdf forwarded onDocument to odsToPdf (the last hop), so pages must be defined. + expect(captured.pages).toBeDefined(); expect(captured.content.kind).toBe('spreadsheet'); }); - it('pdfToXlsx reports a package with content only (the odsToXlsx bridge hop), layout undefined', () => { + it('pdfToXlsx reports a package with content only (the odsToXlsx bridge hop), pages undefined', () => { const pdfBytes = odsToPdf(gridOdsBytes()); let captured: DocumentPackage | undefined; pdfToXlsx(pdfBytes, { onDocument: (pkg) => { captured = pkg; } }); if (captured === undefined) throw new Error('onDocument was not called'); // The last hop is odsToXlsx (a bridge), which reports content only. - expect(captured.layout).toBeUndefined(); + expect(captured.pages).toBeUndefined(); expect(captured.content.kind).toBe('spreadsheet'); }); - it('xlsxToMarkdown reports a package from the pdfToMarkdown hop (wordprocessing content + layout)', () => { + it('xlsxToMarkdown reports a package from the pdfToMarkdown hop (wordprocessing content + pages)', () => { const xlsxBytes = odsToXlsx(gridOdsBytes()); let captured: DocumentPackage | undefined; xlsxToMarkdown(xlsxBytes, { onDocument: (pkg) => { captured = pkg; } }); if (captured === undefined) throw new Error('onDocument was not called'); - // The last hop is pdfToMarkdown (fromPdf), which reconstructs wordprocessing content and carries the readPdf layout. + // The last hop is pdfToMarkdown (fromPdf), which reconstructs wordprocessing content with frames attached and carries the read pages. expect(captured.content.kind).toBe('wordprocessing'); - expect(captured.layout).toBeDefined(); + expect(captured.pages).toBeDefined(); }); }); diff --git a/src/examples.test.ts b/src/examples.test.ts index ac655341..fefff28a 100644 --- a/src/examples.test.ts +++ b/src/examples.test.ts @@ -20,6 +20,9 @@ import { layoutDocumentWithSchema, } from 'document-schema.js'; import { decodePackage as decodeOoxmlPackage } from 'ooxml.js'; +import { createStandardFontMeasurer, loadMathFont } from 'pdf-codec'; +import { convertWordprocessingToLayout } from './layout/engine'; +const mathMetricsAt = (sizePt: number) => loadMathFont().metricsAt(sizePt); import { decodePackage as decodeOdfPackage } from 'odf.js'; import { docxToPdf } from './convert/convert'; import { readDocxContent } from './ooxml/docx/read'; @@ -76,7 +79,7 @@ function assertPackageExample(name: string, pkg: DocumentPackage): void { expect(result.kind, `${name}: schema`).toBe('DocumentPackage'); } -// The layout and package examples both come from a single docxToPdf run: its onDocument callback hands back the full DocumentPackage (content + layout) the conversion built, which is the README's own recommended way to obtain the intermediate pivot model. Computed once at module load so both examples share it. +// The package example comes from a real docxToPdf run: its onDocument callback hands back the fused DocumentPackage (content with its own frames stamped, plus the pages array) the conversion built, which is the README's own recommended way to obtain the intermediate pivot model. The LayoutDocument example comes from running the same conversion's own layout engine directly on the same read content -- the internal pdf-codec view a package no longer carries as a second half, still a real pivot model worth an example of its own (writePdf's own contract). function buildDocxPackage(): DocumentPackage { let captured: DocumentPackage | undefined; docxToPdf(minimalDocxBytes(), { onDocument: (pkg) => { @@ -90,6 +93,17 @@ function buildDocxPackage(): DocumentPackage { const DOCX_PACKAGE = buildDocxPackage(); +function buildDocxLayout(): LayoutDocument { + const content = readDocxContent(decodeOoxmlPackage(minimalDocxBytes())); + if (content.kind !== 'wordprocessing') { + throw new Error('readDocxContent returned a non-wordprocessing ContentDocument'); + } + const { document } = convertWordprocessingToLayout(content, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + return document; +} + +const DOCX_LAYOUT = buildDocxLayout(); + describe('examples', () => { // wordprocessing covers docx, odt, and markdown -- they all read into the identical wordprocessing-variant ContentDocument (the README documents this shared pivot). docx is the representative source here. it('wordprocessing.content.json (from docx)', () => { @@ -112,15 +126,11 @@ describe('examples', () => { assertContentExample('formula.content.json', readOdfFormulaContent(decodeOdfPackage(odfFormulaBytes(FRACTION_FORMULA)))); }); - it('layout-document.json (docxToPdf layout half)', () => { - if (DOCX_PACKAGE.layout) { - assertLayoutExample('layout-document.json', DOCX_PACKAGE.layout); - } else { - throw new Error('docxToPdf onDocument package carried no layout'); - } + it('layout-document.json (docxToPdf internal layout)', () => { + assertLayoutExample('layout-document.json', DOCX_LAYOUT); }); - it('document-package.json (docxToPdf content + layout)', () => { + it('document-package.json (docxToPdf fused content + pages)', () => { assertPackageExample('document-package.json', DOCX_PACKAGE); }); diff --git a/src/index.ts b/src/index.ts index 5d1edf96..f755779c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -356,7 +356,7 @@ export type { PresentationLayoutResult, SlidesLayoutOptions } from './layout/sli export { convertPresentationToLayout } from './layout/slides'; export type { SheetsLayoutOptions, SpreadsheetLayoutResult } from './layout/sheets'; export { convertSpreadsheetToLayout } from './layout/sheets'; -export type { DrawingLayoutOptions } from './layout/drawing'; +export type { DrawingLayoutOptions, DrawingLayoutResult } from './layout/drawing'; export { convertDrawingToLayout } from './layout/drawing'; export type { ReconstructOptions } from './layout/reconstruct'; export { reconstructDrawing, reconstructPresentation, reconstructSpreadsheet, reconstructWordprocessing } from './layout/reconstruct'; @@ -450,8 +450,8 @@ export { createLocalDocumentConverter } from './convert/local'; export { convertDocument, resolveCompositionPlan } from './convert/composition'; export type { UnifiedConversionOptions, ConversionPlan, CompositionHop } from './convert/composition'; -// --- A DocumentPackage (content + optional layout) -> any DocumentFormat's own bytes -- the reverse of what every ergonomic X-to-PDF/PDF-to-X conversion's own onDocument callback hands back. --- -export { buildDocumentBytes } from './convert/from-package'; +// --- 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 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'; diff --git a/src/layout/drawing.test.ts b/src/layout/drawing.test.ts index fc03f549..0e4fa6cf 100644 --- a/src/layout/drawing.test.ts +++ b/src/layout/drawing.test.ts @@ -46,7 +46,7 @@ function drawingDoc(pages: ContentDrawPage[]): Extract { @@ -324,7 +324,7 @@ describe('convertDrawingToLayout -> reconstructDrawing: paint order survives the expect(recoveredPage.shapes.map((s) => s.paintOrder)).toEqual([1]); // And laying the RECOVERED page out again reproduces the identical interleaving, rather than drifting back to vectors-then-shapes. - const relaid = convertDrawingToLayout(recovered, { measurer: fakeMeasurer() }); + const relaid = convertDrawingToLayout(recovered, { measurer: fakeMeasurer() }).document; expect(relaid.pages[0]?.items.map((item) => item.kind)).toEqual(['rect', 'text', 'rect']); }); }); diff --git a/src/layout/drawing.ts b/src/layout/drawing.ts index 5aa4a526..787c45cc 100644 --- a/src/layout/drawing.ts +++ b/src/layout/drawing.ts @@ -1,10 +1,62 @@ -import type { Box, ContentDocument, ContentDrawPage, ContentPathPoint, ContentVector, LayoutDocument, LayoutImageAsset, LayoutItem, LayoutPage, LayoutPathSegment, LayoutSubpath } from 'document-schema.js'; -import { LAYOUT_FORMAT_VERSION } from 'document-schema.js'; +import type { Box, ContentDocument, ContentDrawPage, ContentPathPoint, ContentVector, LayoutDocument, LayoutImageAsset, LayoutItem, LayoutPage, LayoutPathSegment, LayoutSubpath, PageSize } from 'document-schema.js'; import { flipY } from '../model/geometry'; import { mergeByPaintOrder } from '../model/paint-order'; import type { Point, TextMeasurer } from 'document-schema.js'; import { rotatePointAboutCenter } from '../model/geometry'; import { convertShape } from './slides'; +import { layoutDocumentOf, packagePagesOf, stampFrame } from './shared'; + +export interface DrawingLayoutResult { + readonly document: LayoutDocument; + // The DocumentPackage's own pages array (each rendered page's size, indexed to match every content node's own frames[].pageIndex) -- the input `doc` argument itself comes back with frames stamped in place, which together with this array is the fused unified DocumentPackage a conversion reports through onDocument. + readonly pages: readonly PageSize[]; +} + +// The axis-aligned PDF-space bounding box of one emitted vector item, the geometry a content node's own frame records for it. An unrotated rect/ellipse is its own box exactly; a rotated vector (emitted as a path) and a freeform path bound by the tight hull of all their points INCLUDING cubic controls -- the identical hull convention reconstruct.ts's own pathBoundingFrame documents (a cubic lies within the convex hull of its control points, so the frame contains the rendered curve). +function boundsOfPoints(points: readonly Point[]): Box { + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const point of points) { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + return { xPt: minX, yPt: minY, widthPt: maxX - minX, heightPt: maxY - minY }; +} + +function vectorItemBounds(item: Extract): Box { + if (item.kind === 'rect' || item.kind === 'ellipse') { + return { xPt: item.xPt, yPt: item.yPt, widthPt: item.widthPt, heightPt: item.heightPt }; + } + if (item.kind === 'line') { + return boundsOfPoints([{ x: item.x1Pt, y: item.y1Pt }, { x: item.x2Pt, y: item.y2Pt }]); + } + const points: Point[] = []; + for (const subpath of item.subpaths) { + points.push({ x: subpath.startXPt, y: subpath.startYPt }); + for (const segment of subpath.segments) { + if (segment.kind === 'cubic') { + points.push({ x: segment.c1xPt, y: segment.c1yPt }); + points.push({ x: segment.c2xPt, y: segment.c2yPt }); + } + points.push({ x: segment.xPt, y: segment.yPt }); + } + } + return boundsOfPoints(points); +} + +// Emits the vector's item(s) and stamps the vector node's own frame from the item that was emitted -- one frame per vector per page, at the exact placement the item carries (rotation already resolved into the geometry for rotated kinds). +function emitVector(vector: ContentVector, pageIndex: number, pageHeightPt: number, out: LayoutItem[]): void { + const before = out.length; + convertVector(vector, pageHeightPt, out); + const emitted = out.length > before ? out[out.length - 1] : undefined; + if (emitted !== undefined && (emitted.kind === 'rect' || emitted.kind === 'ellipse' || emitted.kind === 'line' || emitted.kind === 'path')) { + stampFrame(vector, pageIndex, vectorItemBounds(emitted)); + } +} // ContentDocument (the drawing variant, odf.js's .odg target) -> LayoutDocument: structurally the same shape as slides.ts's own pptx/odp direction (one ContentDrawPage per PDF page, direct placement, no pagination), extended with one new emission path -- ContentVector, the vector-primitive vocabulary a drawing carries that a slide typically doesn't. rect/ellipse/line vectors map onto the LayoutRect/LayoutEllipse/LayoutLine kinds documents.js already had before this module existed; 'path' is the one genuinely new LayoutItem kind (document-schema.js's LayoutPathSchema), constructed here as a plain value -- writePath (pdf-codec's content-write.ts) is what later turns that value into PDF content-stream operators, a separate, downstream concern from building it. ContentShape content (draw:frame text/image/table, and salvaged custom-shape text) reuses convertShape verbatim from slides.ts, which is what makes odg free-riding on odp's/pptx's own already-correct paragraph flow, image placement, and table layout, not a second reimplementation of any of it. // @@ -155,7 +207,8 @@ function convertPathVector(vector: PathVector, pageHeightPt: number, out: Layout out.push({ kind: 'path', subpaths, fill: vector.fill, fillRule: vector.fillRule, stroke: vector.stroke, sourcePath: vector.sourcePath }); } -function convertVector(vector: ContentVector, pageHeightPt: number, out: LayoutItem[]): void { +// Exported for reuse by src/convert/from-package.ts's frames-to-layout inverse: re-running the ONE vector-to-item conversion (against a package's own recorded page height) is what keeps a rebuilt LayoutDocument's vector geometry identical to what a fresh layout pass would emit, rather than a second, drifting reimplementation of the same placements. +export function convertVector(vector: ContentVector, pageHeightPt: number, out: LayoutItem[]): void { if (vector.kind === 'rect') { convertRectVector(vector, pageHeightPt, out); } else if (vector.kind === 'ellipse') { @@ -167,21 +220,23 @@ function convertVector(vector: ContentVector, pageHeightPt: number, out: LayoutI } } -function convertPage(page: ContentDrawPage, measurer: TextMeasurer, images: Record): LayoutPage { +function convertPage(page: ContentDrawPage, pageIndex: number, measurer: TextMeasurer, images: Record): LayoutPage { const items: LayoutItem[] = []; // One merged walk in true paint order, rather than the two sequential arrays the page stores them in -- see src/model/paint-order.ts for how the merge resolves, and for what a page missing the field anywhere falls back to. for (const entry of mergeByPaintOrder(page.vectors, page.shapes)) { if (entry.kind === 'vector') { - convertVector(entry.value, page.size.heightPt, items); + emitVector(entry.value, pageIndex, page.size.heightPt, items); } else { - convertShape(entry.value, page.size.heightPt, measurer, images, items); + convertShape(entry.value, page.size.heightPt, pageIndex, measurer, images, items); } } return { widthPt: page.size.widthPt, heightPt: page.size.heightPt, items }; } -export function convertDrawingToLayout(doc: DrawingContentDocument, options: DrawingLayoutOptions): LayoutDocument { +// BREAKING (documents.js 2.0.0): returns a DrawingLayoutResult ({ document, pages }) rather than a bare LayoutDocument, matching the shape the other three engines already return -- the pages half of the fused DocumentPackage, alongside the frames stamped in place on `doc`'s own nodes. +export function convertDrawingToLayout(doc: DrawingContentDocument, options: DrawingLayoutOptions): DrawingLayoutResult { const images: Record = {}; - const pages = doc.pages.map((page) => convertPage(page, options.measurer, images)); - return { formatVersion: LAYOUT_FORMAT_VERSION, metadata: doc.metadata, pages, images }; + const pages = doc.pages.map((page, pageIndex) => convertPage(page, pageIndex, options.measurer, images)); + // `doc` itself now carries every placement this pass computed, stamped in place on its own nodes (frames); the returned pages array plus that mutated content is the fused unified DocumentPackage a conversion reports through onDocument. + return { document: layoutDocumentOf(doc.metadata, pages, images), pages: packagePagesOf(pages) }; } diff --git a/src/layout/engine.ts b/src/layout/engine.ts index c5d1ce93..4918e2a3 100644 --- a/src/layout/engine.ts +++ b/src/layout/engine.ts @@ -1,12 +1,12 @@ -import type { ContentDocument, ContentEmbeddedObjectBlock, ContentImageBlock, ContentListMembership, ContentParagraph, ContentSection, ContentTable, LayoutDocument, LayoutImage, LayoutImageAsset, LayoutItem, LayoutLink, LayoutPage, LayoutText } from 'document-schema.js'; -import { COLOR_BLACK, LAYOUT_FORMAT_VERSION } from 'document-schema.js'; +import type { ContentDocument, ContentEmbeddedObjectBlock, ContentImageBlock, ContentListMembership, ContentParagraph, ContentSection, ContentTable, LayoutDocument, LayoutImage, LayoutImageAsset, LayoutItem, LayoutLink, LayoutPage, LayoutText, PageSize } from 'document-schema.js'; +import { COLOR_BLACK } from 'document-schema.js'; import { parseListNumId } from 'markdown-codec'; import { layoutFormula } from '../mathml/layout'; import { flipY } from '../model/geometry'; import { formulaOfBlock, formulaPlaceholderText } from '../model/formula'; import type { MathFontMetrics, PositionedFormula, TextMeasurer } from 'document-schema.js'; import { wrapRunsToWidth } from './text-layout'; -import { alignmentOffsetPt, effectiveStyledRuns, estimateRowHeightPt, formulaSizePtForFrame, headingStyleFor, justifyLineGapsPt, lineNaturalHeightPt, pushCellBorderLines, registerImage, sumColumnWidthsPt } from './shared'; +import { alignmentOffsetPt, effectiveStyledRuns, estimateRowHeightPt, formulaSizePtForFrame, headingStyleFor, justifyLineGapsPt, lineNaturalHeightPt, layoutDocumentOf, packagePagesOf, pushCellBorderLines, registerImage, stampFragmentFrame, stampFrame, sumColumnWidthsPt, textBoxForFragment } from './shared'; // ContentDocument (the wordprocessing variant) -> LayoutDocument: docx's hard direction. A docx page isn't a fixed canvas the way a pptx slide is -- content flows and paginates, so this engine tracks a vertical cursor per page and starts a new page whenever the next line (or table row) would overflow the current one, honoring explicit page breaks, w:pageBreakBefore, and a per-section page-size/margin change. Headers/footers and live PAGE/NUMPAGES substitution are not laid out here -- src/ooxml/docx/read.ts doesn't read them either, a deliberate, tracked narrowing from the plan's original scope (see that file's own module doc). @@ -41,6 +41,8 @@ export interface WordprocessingLayoutResult { readonly document: LayoutDocument; // Every embedded formula actually rendered via src/mathml, already positioned in PDF page space (bottom-left origin, y-up) -- pdf-codec's write.ts's own WritePdfOptions.formulas consumes this directly. See that module's own comment for why a formula's CID-font glyph runs can't travel through LayoutDocument.pages[].items itself. readonly formulas: readonly PositionedFormula[]; + // The DocumentPackage's own pages array (each rendered page's size, indexed to match every content node's own frames[].pageIndex) -- the input `doc` argument itself comes back with frames stamped in place, which together with this array is the fused unified package a conversion reports through onDocument. + readonly pages: readonly PageSize[]; } type WordprocessingContentDocument = Extract; @@ -95,6 +97,8 @@ function layoutParagraphFlow( lines.forEach((line, lineIndex) => { const lineHeightPt = lineNaturalHeightPt(line, measurer, fallbackRun) * (paragraph.lineSpacing ?? 1); ensureRoom(state, section, pages, lineHeightPt, contentBottomYDown); + // Read after ensureRoom, not before: a mid-paragraph page break means different lines of this one paragraph place on different pages, and each line's own stamps must carry the page it actually landed on. pages.length is the index of the page currently being filled (flushPage is what increments it). + const pageIndex = pages.length; const baselineYDown = state.cursorYDown + line.ascentPt; // First-line indent shifts only where the first line starts, not its wrap point -- see src/layout/slides.ts's identical note on the same simplification. @@ -103,18 +107,21 @@ function layoutParagraphFlow( // Only a WRAPPED, non-final line of a justified paragraph gets its inter-word gaps stretched -- the paragraph's own final line (or a paragraph that never wraps at all, i.e. lines.length === 1) renders left-aligned instead, the standard justification convention Word/LibreOffice both follow. const justifyGapsPt = paragraph.alignment === 'justify' && lineIndex < lines.length - 1 ? justifyLineGapsPt(line, paragraphWidthPt, measurer) : undefined; - // The marker sits one indent step to the left of the paragraph's own (already-indented) text, on the paragraph's first line only -- the same hanging-indent convention a word processor uses, so wrapped continuation lines line up under the text, not under the marker. + // The marker sits one indent step to the left of the paragraph's own (already-indented) text, on the paragraph's first line only -- the same hanging-indent convention a word processor uses, so wrapped continuation lines line up under the text, not under the marker. The marker derives from the paragraph's own list membership rather than from any run, so its frame stamps the PARAGRAPH node itself. if (lineIndex === 0 && paragraph.list !== undefined) { - state.items.push({ + const markerText = listMarkerText(paragraph.list, listCounters); + const markerItem: LayoutText = { kind: 'text', - text: listMarkerText(paragraph.list, listCounters), + text: markerText, xPt: paragraphLeftXDown - LIST_INDENT_STEP_PT, yPt: section.pageSize.heightPt - baselineYDown, font: fallbackRun.font, sizePt: fallbackRun.sizePt, color: fallbackRun.color, sourcePath: paragraph.sourcePath, - }); + }; + state.items.push(markerItem); + stampFrame(paragraph, pageIndex, textBoxForFragment(markerItem, measurer.widthOfTextAtSize(markerText, fallbackRun.font, fallbackRun.sizePt), line.ascentPt, line.descentPt)); } line.fragments.forEach((fragment, fragmentIndex) => { @@ -132,6 +139,8 @@ function layoutParagraphFlow( sourcePath: fragment.sourcePath, }; state.items.push(textItem); + // One frame per rendered placement, on the run that placement renders -- a hyperlinked fragment's LayoutLink rides the same placement, so it stamps nothing additional. + stampFragmentFrame(paragraph.runs, fragment, pageIndex, textItem, measurer, line); if (fragment.hyperlink !== undefined) { const fragmentWidthPt = measurer.widthOfTextAtSize(fragment.text, fragment.font, fragment.sizePt); @@ -153,8 +162,8 @@ function layoutParagraphFlow( state.cursorYDown += paragraph.spacingAfterPt ?? 0; } -// A simpler variant for text inside a table cell: the row's own row-atomic placement (see layoutTableFlow) already guaranteed the whole row fits before any cell content is laid out, so no page-break checking happens per line here -- only wrapping and stacking, returning the new cursor position. Nested tables inside a cell are not laid out (read.ts can represent one recursively, but rendering one is out of v1 scope -- rare in practice, and cheap to add later without touching this function's contract). -function layoutParagraphInCell(paragraph: ContentParagraph, cellLeftXDown: number, cellWidthPt: number, startYDown: number, pageHeightPt: number, measurer: TextMeasurer, out: LayoutItem[], listCounters: ListCounters): number { +// A simpler variant for text inside a table cell: the row's own row-atomic placement (see layoutTableFlow) already guaranteed the whole row fits before any cell content is laid out, so no page-break checking happens per line here -- only wrapping and stacking, returning the new cursor position. `pageIndex` is that row's own settled page, threaded in once per row rather than re-derived per line. Nested tables inside a cell are not laid out (read.ts can represent one recursively, but rendering one is out of v1 scope -- rare in practice, and cheap to add later without touching this function's contract). +function layoutParagraphInCell(paragraph: ContentParagraph, cellLeftXDown: number, cellWidthPt: number, startYDown: number, pageHeightPt: number, pageIndex: number, measurer: TextMeasurer, out: LayoutItem[], listCounters: ListCounters): number { let cursorYDown = startYDown + (paragraph.spacingBeforePt ?? 0); const effectiveRuns = effectiveStyledRuns(paragraph.runs, 1, headingStyleFor(paragraph.styleId)); const fallbackRun = effectiveRuns[0]!; @@ -171,19 +180,22 @@ function layoutParagraphInCell(paragraph: ContentParagraph, cellLeftXDown: numbe // See layoutParagraphFlow's identical note: only a wrapped, non-final line of a justified paragraph gets stretched. const justifyGapsPt = paragraph.alignment === 'justify' && lineIndex < lines.length - 1 ? justifyLineGapsPt(line, paragraphWidthPt, measurer) : undefined; if (lineIndex === 0 && paragraph.list !== undefined) { - out.push({ + const markerText = listMarkerText(paragraph.list, listCounters); + const markerItem: LayoutText = { kind: 'text', - text: listMarkerText(paragraph.list, listCounters), + text: markerText, xPt: paragraphLeftXDown - LIST_INDENT_STEP_PT, yPt: pageHeightPt - baselineYDown, font: fallbackRun.font, sizePt: fallbackRun.sizePt, color: fallbackRun.color, sourcePath: paragraph.sourcePath, - }); + }; + out.push(markerItem); + stampFrame(paragraph, pageIndex, textBoxForFragment(markerItem, measurer.widthOfTextAtSize(markerText, fallbackRun.font, fallbackRun.sizePt), line.ascentPt, line.descentPt)); } line.fragments.forEach((fragment, fragmentIndex) => { - out.push({ + const textItem: LayoutText = { kind: 'text', text: fragment.text, xPt: paragraphLeftXDown + firstLineIndentPt + alignOffsetPt + fragment.xOffsetPt + (justifyGapsPt?.[fragmentIndex] ?? 0), @@ -193,7 +205,9 @@ function layoutParagraphInCell(paragraph: ContentParagraph, cellLeftXDown: numbe color: fragment.color, underline: fragment.underline, sourcePath: fragment.sourcePath, - }); + }; + out.push(textItem); + stampFragmentFrame(paragraph.runs, fragment, pageIndex, textItem, measurer, line); }); cursorYDown += lineHeightPt; }); @@ -209,6 +223,8 @@ function layoutTableFlow(table: ContentTable, section: ContentSection, pages: La for (const row of table.rows) { const rowHeightPt = row.heightPt ?? estimateRowHeightPt(row, measurer, table.columnWidthsPt, scale); ensureRoom(state, section, pages, rowHeightPt, contentBottomYDown); + // The row's own settled page -- read after ensureRoom, and shared by every cell in it (row-atomic placement means the whole row, decorations and content, is one page's content). + const pageIndex = pages.length; let cellXDown = contentLeftXDown; let colIndex = 0; @@ -216,11 +232,12 @@ function layoutTableFlow(table: ContentTable, section: ContentSection, pages: La const span = cell.colSpan ?? 1; const cellWidthPt = sumColumnWidthsPt(table.columnWidthsPt, colIndex, span) * scale; - // A cell's decoration paints under its own content, in the order a real word processor draws it: background fill first, then the border lines sitting on that same frame's edges, then (below) the cell's paragraphs on top of both. ContentTableCell carries a real sourcePath of its own now, so a cell's rect/lines are attributed to the exact cell that declared them, falling back to the containing table only for a cell that has none. + // A cell's decoration paints under its own content, in the order a real word processor draws it: background fill first, then the border lines sitting on that same frame's edges, then (below) the cell's paragraphs on top of both. ContentTableCell carries a real sourcePath of its own now, so a cell's rect/lines are attributed to the exact cell that declared them, falling back to the containing table only for a cell that has none. The cell's own frame stamps the CELL node once, PDF-space -- background, borders, and any content runs inside all belong to this one placement of this one cell. const cellFrameYDown = { xPt: cellXDown, yPt: state.cursorYDown, widthPt: cellWidthPt, heightPt: rowHeightPt }; const cellSourcePath = cell.sourcePath ?? table.sourcePath; + const cellFrame = flipY(cellFrameYDown, section.pageSize.heightPt); + stampFrame(cell, pageIndex, cellFrame); if (cell.background !== undefined) { - const cellFrame = flipY(cellFrameYDown, section.pageSize.heightPt); state.items.push({ kind: 'rect', xPt: cellFrame.xPt, yPt: cellFrame.yPt, widthPt: cellFrame.widthPt, heightPt: cellFrame.heightPt, fill: cell.background, sourcePath: cellSourcePath }); } if (cell.borders !== undefined) { @@ -230,7 +247,7 @@ function layoutTableFlow(table: ContentTable, section: ContentSection, pages: La let cellCursorYDown = state.cursorYDown; for (const block of cell.blocks) { if (block.kind === 'paragraph') { - cellCursorYDown = layoutParagraphInCell(block, cellXDown, cellWidthPt, cellCursorYDown, section.pageSize.heightPt, measurer, state.items, listCounters); + cellCursorYDown = layoutParagraphInCell(block, cellXDown, cellWidthPt, cellCursorYDown, section.pageSize.heightPt, pageIndex, measurer, state.items, listCounters); } } @@ -247,6 +264,7 @@ function layoutImageFlow(block: ContentImageBlock, section: ContentSection, page const flippedFrame = flipY({ xPt: contentLeftXDown, yPt: state.cursorYDown, widthPt: block.widthPt, heightPt: block.heightPt }, section.pageSize.heightPt); const imageItem: LayoutImage = { kind: 'image', imageId, xPt: flippedFrame.xPt, yPt: flippedFrame.yPt, widthPt: flippedFrame.widthPt, heightPt: flippedFrame.heightPt, sourcePath: block.sourcePath }; state.items.push(imageItem); + stampFrame(block, pages.length, flippedFrame); state.cursorYDown += block.heightPt; } @@ -275,6 +293,8 @@ function layoutFormulaFlow(block: ContentEmbeddedObjectBlock, section: ContentSe ensureRoom(state, section, pages, box.heightPt, contentBottomYDown); const flippedFrame = flipY({ xPt: contentLeftXDown, yPt: state.cursorYDown, widthPt: box.widthPt, heightPt: box.heightPt }, section.pageSize.heightPt); formulas.push({ pageIndex: pages.length, xPt: flippedFrame.xPt, yPt: flippedFrame.yPt, box }); + // The block's frame records where the formula was placed even though its glyphs render through the formulas side channel rather than as a LayoutItem -- a consumer rebuilding a layout from frames (src/convert/from-package.ts) still knows where the block sat, and can still not re-render its math (the same honest limit that side channel has always had). + stampFrame(block, pages.length, flippedFrame); state.cursorYDown += box.heightPt; } @@ -314,5 +334,6 @@ export function convertWordprocessingToLayout(doc: WordprocessingContentDocument for (const section of doc.sections) { paginateSection(section, options.measurer, images, pages, options, formulas, listCounters); } - return { document: { formatVersion: LAYOUT_FORMAT_VERSION, metadata: doc.metadata, pages, images }, formulas }; + // `doc` itself now carries every placement this pass computed, stamped in place on its own nodes (frames); the returned pages array plus that mutated content is the fused unified DocumentPackage a conversion reports through onDocument. + return { document: layoutDocumentOf(doc.metadata, pages, images), formulas, pages: packagePagesOf(pages) }; } diff --git a/src/layout/shared.ts b/src/layout/shared.ts index 08a9d14c..404f657c 100644 --- a/src/layout/shared.ts +++ b/src/layout/shared.ts @@ -1,14 +1,52 @@ import { base64ToBytes } from 'ooxml.js'; -import type { Box, ContentBorder, ContentCellBorders, ContentImageBlock, ContentRun, ContentTableRow, LayoutImageAsset, LayoutItem, MathFontMetrics, MathMlNode } from 'document-schema.js'; +import type { Box, ContentBorder, ContentCellBorders, ContentImageBlock, ContentRun, ContentTableRow, LayoutDocument, LayoutFrame, LayoutImageAsset, LayoutItem, LayoutPage, LayoutText, MathFontMetrics, MathMlNode, PageSize, TextMeasurer, WrappedLine } from 'document-schema.js'; +import { LAYOUT_FORMAT_VERSION } from 'document-schema.js'; import { layoutFormula } from '../mathml/layout'; -import type { Alignment, LayoutFont } from 'document-schema.js'; +import type { Alignment, LayoutFont, LayoutMetadata, StyledRun } from 'document-schema.js'; import { COLOR_BLACK, DEFAULT_LAYOUT_FONT } from 'document-schema.js'; -import type { StyledRun, TextMeasurer, WrappedLine } from 'document-schema.js'; import { crc32, decodePng, readJpegInfo } from 'byte-codec'; import { wrapRunsToWidth } from './text-layout'; +import type { SourcedFragment, SourcedRun } from './text-layout'; // Layout logic genuinely shared between src/layout/slides.ts (pptx, direct placement) and src/layout/engine.ts (docx, flow/pagination): run styling, line-height measurement, alignment, and image-asset registration have no format-specific knowledge of their own -- duplicating them between the two engines would just be two copies to keep in sync. +// Records one rendered placement onto a content node's own frames array, in place -- the single mechanism every layout engine and reconstructor in this package uses to fuse positions into the content tree (the schema's DocumentPackage design: a node's frames ARE its rendered page positions, in PDF user space, so no second LayoutDocument needs to be correlated back by sourcePath). Mutating the caller's own content tree here is the deliberate design, not an oversight: the correspondence between a node and its position is in hand at exactly this moment and would otherwise be thrown away (see ExaDev/documents.js#569). +export function stampFrame(node: { frames?: LayoutFrame[] }, pageIndex: number, box: Box): void { + const frame: LayoutFrame = { pageIndex, xPt: box.xPt, yPt: box.yPt, widthPt: box.widthPt, heightPt: box.heightPt }; + if (node.frames === undefined) { + node.frames = [frame]; + } else { + node.frames.push(frame); + } +} + +// The bounding box of one rendered text placement: the fragment's own measured width, and the vertical extent its line's ascent/descent give around the baseline the item's yPt carries. widthOfTextAtSize is the identical measurement the wrapping pass already made for this fragment, so the frame's width and the emitted LayoutText agree by construction. +export function textBoxForFragment(item: LayoutText, textWidthPt: number, ascentPt: number, descentPt: number): Box { + return { xPt: item.xPt, yPt: item.yPt + descentPt, widthPt: textWidthPt, heightPt: ascentPt - descentPt }; +} + +// Stamps one emitted LayoutText's box onto the ContentRun node that fragment came from -- the per-fragment stamping step every text-laying engine (engine.ts's flow and cell paths, slides.ts's shape path) runs right after pushing the item. A fragment with no runIndex (an empty paragraph's synthesised fallback run) or one whose index resolves to no node stamps nothing: there is no real content node to position, and fabricating a position on some other node would be worse than leaving it unplaced. The item is an argument rather than re-derived here so the frame always matches the exact item that was emitted, justify offsets and all. +export function stampFragmentFrame(runs: readonly ContentRun[], fragment: SourcedFragment, pageIndex: number, item: LayoutText, measurer: TextMeasurer, line: { readonly ascentPt: number; readonly descentPt: number }): void { + if (fragment.runIndex === undefined) { + return; + } + const run = runs[fragment.runIndex]; + if (run === undefined) { + return; + } + stampFrame(run, pageIndex, textBoxForFragment(item, measurer.widthOfTextAtSize(fragment.text, fragment.font, fragment.sizePt), line.ascentPt, line.descentPt)); +} + +// Every LayoutDocument this package's own engines produce carries the package's pages array directly derivable from its own pages -- each rendered page's own size, indexed to match every node's own frames[].pageIndex (document-schema.js's DocumentPackageSchema contract). One helper rather than four per-engine copies of the same map. +export function packagePagesOf(pages: readonly LayoutPage[]): PageSize[] { + return pages.map((page) => ({ widthPt: page.widthPt, heightPt: page.heightPt })); +} + +// The LayoutDocument every engine's result carries as its `document` half -- pdf-codec's writePdf contract, unchanged by the frames fusion (the internal LayoutDocument stays the one shape writePdf consumes; frames are the additional record fused onto content). +export function layoutDocumentOf(metadata: LayoutMetadata, pages: LayoutPage[], images: Record): LayoutDocument { + return { formatVersion: LAYOUT_FORMAT_VERSION, metadata, pages, images }; +} + // A nominal fallback text size, used only when a ContentRun/paragraph has no resolvable size of its own (a wholly empty paragraph, or a run whose cascade never set one) -- ContentParagraph/ContentRun don't retain the cascade-resolved default for this case, only what ended up on an actual run. export const NOMINAL_TEXT_SIZE_PT = 18; @@ -56,8 +94,8 @@ export function runFont(run: ContentRun, headingBold?: boolean): LayoutFont { }; } -export function toStyledRuns(runs: readonly ContentRun[], fontScale = 1, headingStyle?: { bold: boolean; sizePt: number }): StyledRun[] { - return runs.map((run) => ({ +export function toStyledRuns(runs: readonly ContentRun[], fontScale = 1, headingStyle?: { bold: boolean; sizePt: number }): SourcedRun[] { + return runs.map((run, runIndex) => ({ text: run.text, font: runFont(run, headingStyle?.bold), sizePt: (run.sizePt ?? headingStyle?.sizePt ?? NOMINAL_TEXT_SIZE_PT) * fontScale, @@ -65,11 +103,12 @@ export function toStyledRuns(runs: readonly ContentRun[], fontScale = 1, heading underline: run.underline, hyperlink: run.hyperlink, sourcePath: run.sourcePath, + runIndex, })); } -// A paragraph's runs, with a synthesised nominal fallback substituted when there are none at all -- so callers can wrap and measure unconditionally rather than special-casing an empty paragraph. -export function effectiveStyledRuns(runs: readonly ContentRun[], fontScale = 1, headingStyle?: { bold: boolean; sizePt: number }): StyledRun[] { +// A paragraph's runs, with a synthesised nominal fallback substituted when there are none at all -- so callers can wrap and measure unconditionally rather than special-casing an empty paragraph. The synthesised run carries no runIndex: it corresponds to no ContentRun node, so a stamping caller finds nothing to stamp -- the correct outcome, not a guard to work around. +export function effectiveStyledRuns(runs: readonly ContentRun[], fontScale = 1, headingStyle?: { bold: boolean; sizePt: number }): SourcedRun[] { const styled = toStyledRuns(runs, fontScale, headingStyle); return styled.length > 0 ? styled : [{ text: '', font: DEFAULT_LAYOUT_FONT, sizePt: NOMINAL_TEXT_SIZE_PT * fontScale, color: COLOR_BLACK }]; } diff --git a/src/layout/sheets.ts b/src/layout/sheets.ts index ed018cb1..fe5cdac6 100644 --- a/src/layout/sheets.ts +++ b/src/layout/sheets.ts @@ -16,8 +16,9 @@ import type { LayoutLine, LayoutPage, LayoutText, + PageSize, } from 'document-schema.js'; -import { columnIndexToLetters, LAYOUT_FORMAT_VERSION } from 'document-schema.js'; +import { columnIndexToLetters } from 'document-schema.js'; import { layoutFormula } from '../mathml/layout'; import type { Color as LayoutColor } from 'document-schema.js'; import { COLOR_BLACK, rgbHexToColor } from 'document-schema.js'; @@ -27,7 +28,7 @@ import { flipY } from '../model/geometry'; import { throwIfAborted } from '../ports/abort'; import type { MathFontMetrics, PositionedFormula, StyledFragment, StyledRun, TextMeasurer } from 'document-schema.js'; import { wrapRunsToWidth } from './text-layout'; -import { alignmentOffsetPt, formulaSizePtForFrame, justifyLineGapsPt, lineNaturalHeightPt, pushCellBorderLines, registerImage, sumColumnWidthsPt, toStyledRuns } from './shared'; +import { alignmentOffsetPt, formulaSizePtForFrame, justifyLineGapsPt, lineNaturalHeightPt, layoutDocumentOf, packagePagesOf, pushCellBorderLines, registerImage, stampFragmentFrame, stampFrame, sumColumnWidthsPt, toStyledRuns } from './shared'; // ContentDocument (the spreadsheet variant) -> LayoutDocument: ods/xlsx's own layout direction, genuinely distinct from both docx's flow/pagination (engine.ts) and pptx's direct placement (slides.ts). A sheet paginates over TWO axes at once (column bands x row bands, not just rows), print settings (range/scale/fit-to-page/repeat rows-columns/gridlines/headers/page order/manual breaks) drive the page grid directly rather than being ignored the way a docx section's margins alone would be, and cell overflow is bounded per cell (###, spill, truncate) rather than wrapped the way paragraph text is. This is also the first layout algorithm in the package genuinely long-running enough (a real sheet can carry tens of thousands of populated cells) to need cooperative cancellation wired into its own per-cell emission loop, not just checked once at the top of the function the way reconstruct.ts's own page/slide loops do. // @@ -46,6 +47,8 @@ export interface SpreadsheetLayoutResult { readonly document: LayoutDocument; // Every cell-anchored embedded formula actually rendered via src/mathml, already positioned in PDF page space (bottom-left origin, y-up) -- pdf-codec's write.ts's own WritePdfOptions.formulas consumes this directly. Structurally identical to src/layout/engine.ts's WordprocessingLayoutResult.formulas and src/layout/slides.ts's PresentationLayoutResult.formulas; see the former's own comment for why a formula's CID-font glyph runs can't travel through LayoutDocument.pages[].items itself. readonly formulas: readonly PositionedFormula[]; + // The DocumentPackage's own pages array (each rendered page's size, indexed to match every content node's own frames[].pageIndex) -- the input `doc` argument itself comes back with frames stamped in place, which together with this array is the fused unified package a conversion reports through onDocument. + readonly pages: readonly PageSize[]; } type SpreadsheetContentDocument = Extract; @@ -93,7 +96,7 @@ const DEFAULT_COLUMN_WIDTH_PT = 64; const DEFAULT_ROW_HEIGHT_PT = 15; // A nominal fallback text size for a cell with no runs of its own (the common case -- ContentSheetCell.runs is populated only for genuinely mixed inline formatting, per its own schema comment) and therefore no resolvable size anywhere in the model. Deliberately its own constant, distinct from shared.ts's NOMINAL_TEXT_SIZE_PT (18pt, a docx/pptx PARAGRAPH fallback) -- applying that size to an ordinary spreadsheet cell would visually swamp a real row height. Matches Excel/Calc's own common 10-11pt body-cell default. -const NOMINAL_CELL_TEXT_SIZE_PT = 10; +export const NOMINAL_CELL_TEXT_SIZE_PT = 10; // The row/column header-gutter's own label size -- smaller again, matching Excel/Calc's own small grey header-label chrome. const HEADER_LABEL_SIZE_PT = 8; @@ -377,6 +380,7 @@ function verticalLineTopYDownPt(verticalAlignment: 'top' | 'middle' | 'bottom', function renderCellText( cell: ContentSheetCell, frameYDown: Box, + pageIndex: number, rowCells: ReadonlyMap | undefined, columnAxis: PositionedAxis, pageHeightPt: number, @@ -444,6 +448,8 @@ function renderCellText( sourcePath: fragment.sourcePath, }; out.push(textItem); + // Stamps the run the fragment came from; a synthesised fallback run (a cell with no runs of its own) or an overflow replacement ('###' stand-in text) has no originating node, and stamps nothing -- the run's own text genuinely did not render there. + stampFragmentFrame(cell.runs ?? [], fragment, pageIndex, textItem, measurer, naturalLine); }); } @@ -522,6 +528,7 @@ function renderAnchoredFormulas( }; const flipped = flipY(boxYDown, pageHeightPt); out.push({ pageIndex, xPt: flipped.xPt, yPt: flipped.yPt, box }); + // No frame is stamped here, unlike engine.ts's and slides.ts's own formula placements: a sheet-anchored embedded object is a ContentEmbeddedObject, the one embedded-object shape document-schema.js deliberately left WITHOUT a frames field (only the in-flow ContentEmbeddedObjectBlock carries one), so there is no node field to stamp -- the rendered position lives in the PositionedFormula array this loop already records. } } @@ -533,6 +540,7 @@ function renderAnchoredImages( gridLeftXPt: number, gridTopYDownPt: number, pageHeightPt: number, + pageIndex: number, hiddenColumnIndices: ReadonlySet, hiddenRowIndices: ReadonlySet, out: LayoutItem[], @@ -554,6 +562,7 @@ function renderAnchoredImages( const flipped = flipY(boxYDown, pageHeightPt); const imageItem: LayoutImage = { kind: 'image', imageId, xPt: flipped.xPt, yPt: flipped.yPt, widthPt: image.widthPt, heightPt: image.heightPt, sourcePath: image.sourcePath }; out.push(imageItem); + stampFrame(image, pageIndex, { xPt: imageItem.xPt, yPt: imageItem.yPt, widthPt: imageItem.widthPt, heightPt: imageItem.heightPt }); } } @@ -674,11 +683,13 @@ function convertSheetToPages(sheet: ContentSheet, measurer: TextMeasurer, signal if (cellFrame === undefined) { continue; } + // The cell's own placement stamps the CELL node once per page it renders on (a repeat-row cell, or a cell re-printed across column bands, genuinely occupies several pages); the runs inside stamp their own finer-grained frames through renderCellText below. + stampFrame(cell, out.length, flipY(cellFrame, pageSize.heightPt)); renderCellBackground(cell, cellFrame, pageSize.heightPt, backgroundItems); if (cell.borders !== undefined) { pushCellBorderLines(cell.borders, cellFrame, pageSize.heightPt, cell.sourcePath, borderItems); } - renderCellText(cell, cellFrame, rowCells, columnAxis, pageSize.heightPt, measurer, textItems); + renderCellText(cell, cellFrame, out.length, rowCells, columnAxis, pageSize.heightPt, measurer, textItems); } } @@ -695,7 +706,7 @@ function convertSheetToPages(sheet: ContentSheet, measurer: TextMeasurer, signal // pageIndex is this page's own index in the whole LayoutDocument, so it is read BEFORE the push -- `out` is shared across every sheet in the document, exactly as PositionedFormula.pageIndex requires. renderAnchoredFormulas(formulas, columnAxis, rowAxis, gridLeftXPt, gridTopYDownPt, pageSize.heightPt, out.length, hiddenColumnIndices, hiddenRowIndices, formulasOut, mathMetricsAt); // Images are LayoutItems (unlike formulas), so they push straight into this page's own `items` rather than a separate out-array -- appended after cell text so a floating image paints over the grid, matching how a real spreadsheet layers a floating draw:frame above the cells it overlaps. - renderAnchoredImages(sheet.images, columnAxis, rowAxis, gridLeftXPt, gridTopYDownPt, pageSize.heightPt, hiddenColumnIndices, hiddenRowIndices, items, images); + renderAnchoredImages(sheet.images, columnAxis, rowAxis, gridLeftXPt, gridTopYDownPt, pageSize.heightPt, out.length, hiddenColumnIndices, hiddenRowIndices, items, images); out.push({ widthPt: pageSize.widthPt, heightPt: pageSize.heightPt, items }); } } @@ -710,5 +721,6 @@ export function convertSpreadsheetToLayout(doc: SpreadsheetContentDocument, opti for (const sheet of doc.sheets) { convertSheetToPages(sheet, options.measurer, options.signal, pages, formulas, images, options.mathMetricsAt); } - return { document: { formatVersion: LAYOUT_FORMAT_VERSION, metadata: doc.metadata, pages, images }, formulas }; + // `doc` itself now carries every placement this pass computed, stamped in place on its own nodes (frames); the returned pages array plus that mutated content is the fused unified DocumentPackage a conversion reports through onDocument. + return { document: layoutDocumentOf(doc.metadata, pages, images), formulas, pages: packagePagesOf(pages) }; } diff --git a/src/layout/slides.ts b/src/layout/slides.ts index 9f96103a..df12ce71 100644 --- a/src/layout/slides.ts +++ b/src/layout/slides.ts @@ -1,5 +1,5 @@ -import type { ContentDocument, ContentEmbeddedObjectBlock, ContentParagraph, ContentShape, ContentSlide, ContentTable, LayoutDocument, LayoutImage, LayoutImageAsset, LayoutItem, LayoutLink, LayoutPage, LayoutText } from 'document-schema.js'; -import { COLOR_BLACK, LAYOUT_FORMAT_VERSION } from 'document-schema.js'; +import type { ContentDocument, ContentEmbeddedObjectBlock, ContentParagraph, ContentShape, ContentSlide, ContentTable, LayoutDocument, LayoutImage, LayoutImageAsset, LayoutItem, LayoutLink, LayoutPage, LayoutText, PageSize } from 'document-schema.js'; +import { COLOR_BLACK } from 'document-schema.js'; import { layoutFormula } from '../mathml/layout'; import type { Box } from 'document-schema.js'; import { flipY } from '../model/geometry'; @@ -7,7 +7,7 @@ import { formulaOfBlock } from '../model/formula'; import type { MathFontMetrics, Point, PositionedFormula, TextMeasurer } from 'document-schema.js'; import { wrapRunsToWidth } from './text-layout'; import { rotatePointAboutCenter } from '../model/geometry'; -import { alignmentOffsetPt, effectiveStyledRuns, estimateRowHeightPt, formulaSizePtForFrame, justifyLineGapsPt, lineNaturalHeightPt, registerImage, sumColumnWidthsPt } from './shared'; +import { alignmentOffsetPt, effectiveStyledRuns, estimateRowHeightPt, formulaSizePtForFrame, justifyLineGapsPt, lineNaturalHeightPt, layoutDocumentOf, packagePagesOf, registerImage, stampFragmentFrame, stampFrame, sumColumnWidthsPt } from './shared'; // ContentDocument (the presentation variant) -> LayoutDocument: pptx's tractable layout direction. No pagination -- one slide is always exactly one PDF page (slide size maps directly to the page's own widthPt/heightPt) -- and no group-transform resolution either, since src/ooxml/pptx/read.ts already flattened every group into absolute shape positions at read time. What's left is genuinely just: wrap each shape's text within its own box (reusing the exact wrapRunsToWidth docx also uses), place images at their shape's frame, render table grids directly from explicit column widths/row heights, and apply the one deliberate Y-flip from OOXML's top-left/y-down space into PDF's bottom-left/y-up space. @@ -20,6 +20,8 @@ export interface PresentationLayoutResult { readonly document: LayoutDocument; // Every embedded formula actually rendered via src/mathml, already positioned in PDF page space -- see src/layout/engine.ts's own WordprocessingLayoutResult.formulas for why this can't travel through LayoutDocument.pages[].items itself. readonly formulas: readonly PositionedFormula[]; + // The DocumentPackage's own pages array (each rendered page's size, indexed to match every content node's own frames[].pageIndex) -- the input `doc` argument itself comes back with frames stamped in place, which together with this array is the fused unified package a conversion reports through onDocument. + readonly pages: readonly PageSize[]; } type PresentationContentDocument = Extract; @@ -53,6 +55,7 @@ function layoutParagraph( contentWidthPt: number, startYDown: number, slideHeightPt: number, + pageIndex: number, placement: ShapePlacement, fontScale: number, spacingScale: number, @@ -94,6 +97,8 @@ function layoutParagraph( sourcePath: fragment.sourcePath, }; out.push(textItem); + // One frame per rendered placement, on the run that placement renders (the placement transform is already baked into the item's own xPt/yPt, so the frame matches the placed geometry exactly); a hyperlinked fragment's LayoutLink rides the same placement and stamps nothing additional. + stampFragmentFrame(paragraph.runs, fragment, pageIndex, textItem, measurer, line); if (fragment.hyperlink !== undefined) { const fragmentWidthPt = measurer.widthOfTextAtSize(fragment.text, fragment.font, fragment.sizePt); @@ -118,7 +123,7 @@ function layoutParagraph( } // Renders a table's grid directly from its own explicit column widths and row heights (falling back to content-derived estimates only when a row's own height is missing) rather than proportionally estimating, since pptx tables -- unlike docx's -- already carry this geometry. Cell background rects are skipped entirely when the containing shape is rotated: LayoutRect has no rotation field of its own, and a misplaced (unrotated) rect would be a worse defect than a missing one for what is, in practice, a rare case. -function layoutTable(table: ContentTable, contentLeftXDown: number, contentWidthPt: number, startYDown: number, slideHeightPt: number, placement: ShapePlacement, measurer: TextMeasurer, out: LayoutItem[]): number { +function layoutTable(table: ContentTable, contentLeftXDown: number, contentWidthPt: number, startYDown: number, slideHeightPt: number, pageIndex: number, placement: ShapePlacement, measurer: TextMeasurer, out: LayoutItem[]): number { let cursorYDown = startYDown; const gridWidthPt = table.columnWidthsPt.reduce((sum, w) => sum + w, 0); const scale = gridWidthPt > 0 ? contentWidthPt / gridWidthPt : 1; @@ -132,8 +137,12 @@ function layoutTable(table: ContentTable, contentLeftXDown: number, contentWidth const span = cell.colSpan ?? 1; const cellWidthPt = sumColumnWidthsPt(table.columnWidthsPt, colIndex, span) * scale; + // The cell's own frame stamps the CELL node (PDF-space, unrotated -- the same no-rotation constraint the background rect below already obeys); the runs inside stamp their own frames through layoutParagraph below. + const cellFrame = flipY({ xPt: cellXDown, yPt: cursorYDown, widthPt: cellWidthPt, heightPt: rowHeightPt }, slideHeightPt); + if (placement.layoutRotationDeg === undefined) { + stampFrame(cell, pageIndex, cellFrame); + } if (cell.background !== undefined && placement.layoutRotationDeg === undefined) { - const cellFrame = flipY({ xPt: cellXDown, yPt: cursorYDown, widthPt: cellWidthPt, heightPt: rowHeightPt }, slideHeightPt); // ContentTableCell has no sourcePath of its own (only ContentTable does -- see document-schema.js), so a per-cell background rect can only be attributed at the table's own granularity, not to the specific cell. out.push({ kind: 'rect', xPt: cellFrame.xPt, yPt: cellFrame.yPt, widthPt: cellFrame.widthPt, heightPt: cellFrame.heightPt, fill: cell.background, sourcePath: table.sourcePath }); } @@ -141,7 +150,7 @@ function layoutTable(table: ContentTable, contentLeftXDown: number, contentWidth let cellCursorYDown = cursorYDown; for (const block of cell.blocks) { if (block.kind === 'paragraph') { - cellCursorYDown = layoutParagraph(block, cellXDown, cellWidthPt, cellCursorYDown, slideHeightPt, placement, 1, 1, measurer, out); + cellCursorYDown = layoutParagraph(block, cellXDown, cellWidthPt, cellCursorYDown, slideHeightPt, pageIndex, placement, 1, 1, measurer, out); } } @@ -163,11 +172,15 @@ function layoutShapeFormula(block: ContentEmbeddedObjectBlock, flippedFrame: Box const metrics = formulaContext.mathMetricsAt(sizePt); const { box } = layoutFormula(formula.mathml, { metrics, sizePt, color: COLOR_BLACK }); formulaContext.positioned.push({ pageIndex: formulaContext.pageIndex, xPt: flippedFrame.xPt, yPt: flippedFrame.yPt, box }); + // The block's frame records where the formula was placed even though its glyphs render through the formulas side channel rather than as a LayoutItem -- see engine.ts's identical note on its own formula-flow stamp. + stampFrame(block, formulaContext.pageIndex, flippedFrame); } // Exported for reuse by src/layout/drawing.ts: a drawing page's own ContentShape entries (draw:frame text/table/image content, and unrecognised custom-shape presets salvaged as text -- see odf.js's typed/draw/shapes.ts) are the exact same ContentShapeSchema-typed value a slide's shapes are, so odg gets slide-quality paragraph flow, image placement, and table layout for free rather than a second, drifting copy of this function. `formulaContext` is optional and appended last precisely so drawing.ts's own existing 5-argument call site keeps compiling unchanged -- readOdgContent runs no embedded-formula detection pass of its own (src/odf/odg/read.ts), so a drawing page never carries a formula block for that call site to need one for, and convertDrawingToLayout has no PositionedFormula output to record one into either. -export function convertShape(shape: ContentShape, slideHeightPt: number, measurer: TextMeasurer, images: Record, out: LayoutItem[], formulaContext?: ShapeFormulaContext): void { +export function convertShape(shape: ContentShape, slideHeightPt: number, pageIndex: number, measurer: TextMeasurer, images: Record, out: LayoutItem[], formulaContext?: ShapeFormulaContext): void { const flippedFrame = flipY(shape.frame, slideHeightPt); + // The shape's own placement, stamped on the shape node itself (PDF-space) -- a shape with no renderable content of its own (an empty text box) still records where it sat, and a consumer walking frames knows which page a shape belongs to without consulting any second tree. + stampFrame(shape, pageIndex, flippedFrame); const placement = shapePlacement(flippedFrame, shape.rotationDeg); const contentLeftXDown = shape.frame.xPt + shape.insetLeftPt; const contentWidthPt = Math.max(0, shape.frame.widthPt - shape.insetLeftPt - shape.insetRightPt); @@ -177,14 +190,15 @@ export function convertShape(shape: ContentShape, slideHeightPt: number, measure for (const block of shape.blocks) { if (block.kind === 'paragraph') { - cursorYDown = layoutParagraph(block, contentLeftXDown, contentWidthPt, cursorYDown, slideHeightPt, placement, fontScale, spacingScale, measurer, out); + cursorYDown = layoutParagraph(block, contentLeftXDown, contentWidthPt, cursorYDown, slideHeightPt, pageIndex, placement, fontScale, spacingScale, measurer, out); } else if (block.kind === 'image') { const imageId = registerImage(block, images); const placed = placement.place({ x: flippedFrame.xPt, y: flippedFrame.yPt }); const imageItem: LayoutImage = { kind: 'image', imageId, xPt: placed.x, yPt: placed.y, widthPt: flippedFrame.widthPt, heightPt: flippedFrame.heightPt, rotationDeg: placement.layoutRotationDeg, sourcePath: block.sourcePath }; out.push(imageItem); + stampFrame(block, pageIndex, { xPt: placed.x, yPt: placed.y, widthPt: imageItem.widthPt, heightPt: imageItem.heightPt }); } else if (block.kind === 'table') { - cursorYDown = layoutTable(block, contentLeftXDown, contentWidthPt, cursorYDown, slideHeightPt, placement, measurer, out); + cursorYDown = layoutTable(block, contentLeftXDown, contentWidthPt, cursorYDown, slideHeightPt, pageIndex, placement, measurer, out); } else if (block.kind === 'embeddedObject' && block.objectKind === 'formula' && formulaContext !== undefined) { layoutShapeFormula(block, flippedFrame, formulaContext); } @@ -196,7 +210,7 @@ function convertSlide(slide: ContentSlide, slideIndex: number, measurer: TextMea const items: LayoutItem[] = []; const formulaContext: ShapeFormulaContext = { pageIndex: slideIndex, positioned, mathMetricsAt }; for (const shape of slide.shapes) { - convertShape(shape, slide.size.heightPt, measurer, images, items, formulaContext); + convertShape(shape, slide.size.heightPt, slideIndex, measurer, images, items, formulaContext); } // Notes are carried as a private page-dictionary entry (LayoutPage.notes, see pdf/write.ts), never painted as visible content -- PDF has no native concept of hidden presenter notes, so this is purely a round-trip mechanism for this package's own pptxToPdf/pdfToPptx pair, not a real PDF feature. return { widthPt: slide.size.widthPt, heightPt: slide.size.heightPt, items, ...(slide.notes.length > 0 ? { notes: slide.notes } : {}) }; @@ -206,5 +220,6 @@ export function convertPresentationToLayout(doc: PresentationContentDocument, op const images: Record = {}; const formulas: PositionedFormula[] = []; const pages = doc.slides.map((slide, slideIndex) => convertSlide(slide, slideIndex, options.measurer, images, formulas, options.mathMetricsAt)); - return { document: { formatVersion: LAYOUT_FORMAT_VERSION, metadata: doc.metadata, pages, images }, formulas }; + // `doc` itself now carries every placement this pass computed, stamped in place on its own nodes (frames); the returned pages array plus that mutated content is the fused unified DocumentPackage a conversion reports through onDocument. + return { document: layoutDocumentOf(doc.metadata, pages, images), formulas, pages: packagePagesOf(pages) }; } diff --git a/src/layout/text-layout.ts b/src/layout/text-layout.ts index aab99901..39e65bd3 100644 --- a/src/layout/text-layout.ts +++ b/src/layout/text-layout.ts @@ -2,9 +2,22 @@ import type { StyledRun, StyledFragment, WrappedLine, WrapOptions, TextMeasurer, // The text-wrapping primitive this package's layout engines (engine.ts/slides.ts/sheets.ts) share, moved out of pdf-codec (which had zero internal callers for it) into the layout engine that owns it. Pure over the injected TextMeasurer port -- no PDF knowledge, no font-parsing -- so a layout engine wraps text against whatever metrics its caller supplied without reaching into a backend. pdf-codec's own copy is dropped in a follow-up breaking step now that nothing imports it. +// Local extensions of the schema's StyledRun/StyledFragment/WrappedLine carrying the index of the originating run (the position it held in the caller's own runs array, set by shared.ts's toStyledRuns and preserved through every split/merge below). Atomisation deliberately merges fragments from DIFFERENT runs into one unbreakable box atom, so run identity is real information the pipeline owns and cannot be re-derived afterwards -- without it, a layout engine could not stamp a rendered position back onto the exact ContentRun node it came from (ExaDev/documents.js#569). runIndex is optional because a synthesised fallback run (an empty paragraph's substitute, shared.ts's effectiveStyledRuns) has no originating node to point at. +export interface SourcedRun extends StyledRun { + readonly runIndex?: number; +} + +export interface SourcedFragment extends StyledFragment { + readonly runIndex?: number; +} + +export interface SourcedWrappedLine extends Omit { + readonly fragments: readonly (SourcedFragment & { readonly xOffsetPt: number })[]; +} + interface BoxAtom { readonly kind: 'box'; - readonly fragments: readonly StyledFragment[]; + readonly fragments: readonly SourcedFragment[]; readonly widthPt: number; } interface GlueAtom { @@ -20,9 +33,9 @@ type Atom = BoxAtom | GlueAtom | BreakAtom; const WORD_OR_WHITESPACE_PATTERN = /\n|\s+|\S+/g; // Splits `runs` into word-shaped "box" atoms, "glue" (space) atoms, and explicit line-"break" atoms. Critically, atomisation happens *across run boundaries*: a word split by a formatting change (e.g. "hel" in a plain run immediately followed by "lo" in a bold run) becomes a single box atom carrying both styled fragments, so it can never be broken apart -- only between boxes, and boxes are word-shaped regardless of how the source text was split across runs. -function atomizeRuns(runs: readonly StyledRun[], measurer: TextMeasurer): Atom[] { +function atomizeRuns(runs: readonly SourcedRun[], measurer: TextMeasurer): Atom[] { const atoms: Atom[] = []; - let wordFragments: StyledFragment[] = []; + let wordFragments: SourcedFragment[] = []; let wordWidth = 0; function flushWord(): void { @@ -43,7 +56,7 @@ function atomizeRuns(runs: readonly StyledRun[], measurer: TextMeasurer): Atom[] flushWord(); atoms.push({ kind: 'glue', widthPt: measurer.widthOfTextAtSize(token, run.font, run.sizePt) }); } else { - wordFragments.push({ text: token, font: run.font, sizePt: run.sizePt, color: run.color, underline: run.underline, hyperlink: run.hyperlink, sourcePath: run.sourcePath }); + wordFragments.push({ text: token, font: run.font, sizePt: run.sizePt, color: run.color, underline: run.underline, hyperlink: run.hyperlink, sourcePath: run.sourcePath, runIndex: run.runIndex }); wordWidth += measurer.widthOfTextAtSize(token, run.font, run.sizePt); } } @@ -52,7 +65,7 @@ function atomizeRuns(runs: readonly StyledRun[], measurer: TextMeasurer): Atom[] return atoms; } -function fragmentsWidth(fragments: readonly StyledFragment[], measurer: TextMeasurer): number { +function fragmentsWidth(fragments: readonly SourcedFragment[], measurer: TextMeasurer): number { let total = 0; for (const f of fragments) { total += measurer.widthOfTextAtSize(f.text, f.font, f.sizePt); @@ -81,7 +94,7 @@ function splitTextToWidth(text: string, font: LayoutFont, sizePt: number, measur // Splits a box atom that alone exceeds maxWidthPt into a `fit` part (placed on the current line) and an optional `rest` part (requeued for the next line), splitting only within the one fragment where the width budget runs out. function splitBoxToWidth(atom: BoxAtom, measurer: TextMeasurer, maxWidthPt: number): { fit: BoxAtom; rest: BoxAtom | undefined } { - const fitFragments: StyledFragment[] = []; + const fitFragments: SourcedFragment[] = []; let fitWidth = 0; for (let idx = 0; idx < atom.fragments.length; idx++) { const fragment = atom.fragments[idx]!; @@ -95,7 +108,7 @@ function splitBoxToWidth(atom: BoxAtom, measurer: TextMeasurer, maxWidthPt: numb if (fitText.length > 0) { fitFragments.push({ ...fragment, text: fitText }); } - const restFragments: StyledFragment[] = []; + const restFragments: SourcedFragment[] = []; if (restText.length > 0) { restFragments.push({ ...fragment, text: restText }); } @@ -111,8 +124,8 @@ function splitBoxToWidth(atom: BoxAtom, measurer: TextMeasurer, maxWidthPt: numb return { fit: atom, rest: undefined }; } -function buildLine(atoms: readonly Atom[], measurer: TextMeasurer): WrappedLine { - const fragments: (StyledFragment & { xOffsetPt: number })[] = []; +function buildLine(atoms: readonly Atom[], measurer: TextMeasurer): SourcedWrappedLine { + const fragments: (SourcedFragment & { xOffsetPt: number })[] = []; let xOffsetPt = 0; let maxSizePt = 0; let ascentPt = 0; @@ -134,7 +147,7 @@ function buildLine(atoms: readonly Atom[], measurer: TextMeasurer): WrappedLine } // Empty-paragraph or forced-break case: the line has no content but still needs a plausible height, derived from whatever run supplied the paragraph's own (possibly empty) run list. -function buildEmptyLine(runs: readonly StyledRun[], measurer: TextMeasurer): WrappedLine { +function buildEmptyLine(runs: readonly SourcedRun[], measurer: TextMeasurer): SourcedWrappedLine { const first = runs[0]; if (first === undefined) { return { fragments: [], widthPt: 0, maxSizePt: 0, ascentPt: 0, descentPt: 0 }; @@ -149,7 +162,7 @@ function buildEmptyLine(runs: readonly StyledRun[], measurer: TextMeasurer): Wra } // Greedy first-fit line breaking over word-shaped atoms -- the same algorithm Word itself uses (an optimal-fit breaker like Knuth-Plass would produce different, not merely better, line breaks, which is the opposite of matching Word's own output). Never breaks inside a word, regardless of how many runs it spans; an over-long single word is emergency-split at the character level, always making at least one character of progress. -export function wrapRunsToWidth(runs: readonly StyledRun[], measurer: TextMeasurer, maxWidthPt: number, options: WrapOptions = {}): WrappedLine[] { +export function wrapRunsToWidth(runs: readonly SourcedRun[], measurer: TextMeasurer, maxWidthPt: number, options: WrapOptions = {}): SourcedWrappedLine[] { const breakLongWords = options.breakLongWords ?? true; if (maxWidthPt <= 0) { @@ -159,7 +172,7 @@ export function wrapRunsToWidth(runs: readonly StyledRun[], measurer: TextMeasur } const queue = atomizeRuns(runs, measurer); - const lines: WrappedLine[] = []; + const lines: SourcedWrappedLine[] = []; let current: Atom[] = []; let currentWidth = 0; From e9ed3908d56e6fbc04c67b38ed30d2efbeaf74be Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Mon, 17 Aug 2026 17:02:34 +0100 Subject: [PATCH 2/2] feat: attach frames from the exact clustered items onto reconstructed content nodes The four reconstructors stamp every node they build with frames taken from the exact LayoutItems it was clustered from -- each recovered run carries its source item's box, each paragraph the bounding box of its line, each lattice-recovered cell and table its measured grid box, each recovered vector the PDF-space box of the item it came from -- so PDF-to-X conversions surface genuine positions in the same unified package shape the layout engines produce. sourcePath survives on recovered items as traceability only. --- src/layout/frames.test.ts | 226 ++++++++++++++++++++++++++++++++ src/layout/reconstruct.test.ts | 7 +- src/layout/reconstruct.ts | 227 ++++++++++++++++++++++++--------- 3 files changed, 396 insertions(+), 64 deletions(-) create mode 100644 src/layout/frames.test.ts diff --git a/src/layout/frames.test.ts b/src/layout/frames.test.ts new file mode 100644 index 00000000..434e2d3c --- /dev/null +++ b/src/layout/frames.test.ts @@ -0,0 +1,226 @@ +import { bytesToBase64 } from 'ooxml.js'; +import { describe, expect, it } from 'vitest'; +import type { ContentDocument, ContentDrawPage, ContentImageBlock, ContentParagraph, ContentRun, ContentSection, ContentSheet, ContentSheetCell, ContentSheetPrintSettings, ContentShape, ContentSlide, ContentTable, ContentVector, LayoutItem } from 'document-schema.js'; +import { CONTENT_FORMAT_VERSION, DOCUMENT_PACKAGE_FORMAT_VERSION, type DocumentPackage } from 'document-schema.js'; +import { encodePng } from 'byte-codec'; +import { createStandardFontMeasurer, loadMathFont } from 'pdf-codec'; +import { convertWordprocessingToLayout } from './engine'; +import { convertDrawingToLayout } from './drawing'; +import { reconstructWordprocessing } from './reconstruct'; +import { convertSpreadsheetToLayout } from './sheets'; +import { convertPresentationToLayout } from './slides'; +import { layoutDocumentFromPackage } from '../convert/from-package'; +const mathMetricsAt = (sizePt: number) => loadMathFont().metricsAt(sizePt); + +// The frames half of the unified DocumentPackage (ExaDev/documents.js#569): every layout engine stamps each placement it computes onto the corresponding content node's own frames array (PDF user-space, pageIndex into the package's own pages), every reconstructor attaches frames from the exact items each reconstructed node was clustered from, and from-package's inverse rebuilds a LayoutDocument from those frames alone. These tests pin that stamping at each layer; sourcepath.test.ts pins the older sourcePath traceability that survives alongside it. + +function run(text: string, overrides: Partial = {}): ContentRun { + return { text, ...overrides }; +} + +function paragraph(runs: ContentRun[], overrides: Partial = {}): ContentParagraph { + return { kind: 'paragraph', runs, ...overrides }; +} + +function section(blocks: ContentSection['blocks'], overrides: Partial = {}): ContentSection { + return { pageSize: { widthPt: 100, heightPt: 50 }, margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 }, blocks, ...overrides }; +} + +function wordprocessingDoc(sections: ContentSection[]): Extract { + return { kind: 'wordprocessing', formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, sections }; +} + +function shape(overrides: Partial = {}): ContentShape { + return { frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 50 }, insetLeftPt: 0, insetTopPt: 0, insetRightPt: 0, insetBottomPt: 0, blocks: [], ...overrides }; +} + +function slide(shapes: ContentShape[], size = { widthPt: 960, heightPt: 540 }): ContentSlide { + return { size, shapes, notes: '' }; +} + +function presentationDoc(slides: ContentSlide[]): Extract { + return { kind: 'presentation', formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, slides }; +} + +function drawPage(overrides: Partial = {}): ContentDrawPage { + return { size: { widthPt: 400, heightPt: 300 }, shapes: [], vectors: [], ...overrides }; +} + +function drawingDoc(pages: ContentDrawPage[]): Extract { + return { kind: 'drawing', formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, pages }; +} + +function tinyPngBlock(overrides: Partial = {}): ContentImageBlock { + const bytes = encodePng({ width: 2, height: 2, channels: 3, data: new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]) }); + return { kind: 'image', format: 'png', base64: bytesToBase64(bytes), widthPt: 20, heightPt: 20, ...overrides }; +} + +function sheet(cells: ContentSheetCell[]): ContentSheet { + const printSettings: ContentSheetPrintSettings = { pageSize: { widthPt: 400, heightPt: 300 }, margins: { topPt: 10, rightPt: 10, bottomPt: 10, leftPt: 10 }, gridlines: false, headers: false, pageOrder: 'downThenOver' }; + return { name: 'Sheet1', cells, columns: [{ index: 0, widthPt: 60 }, { index: 1, widthPt: 60 }], rows: [{ index: 0, heightPt: 20 }], images: [], printSettings }; +} + +describe('engine frames: wordprocessing (engine.ts)', () => { + it('stamps one frame per wrapped fragment onto the originating run, in place on the caller\'s own content', () => { + // "Hello from docx" wraps to three words -> three fragments -> three frames on the ONE run they all came from, in reading order. + const doc = wordprocessingDoc([section([paragraph([run('Hello from docx', { sizePt: 10 })])], { pageSize: { widthPt: 50, heightPt: 50 } })]); + const theRun = doc.sections[0]!.blocks[0]!; + const { pages } = convertWordprocessingToLayout(doc, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + expect(theRun.kind).toBe('paragraph'); + if (theRun.kind !== 'paragraph') throw new Error('unreachable'); + expect(theRun.runs[0]!.frames).toHaveLength(3); + expect(pages).toEqual([{ widthPt: 50, heightPt: 50 }]); + for (const frame of theRun.runs[0]!.frames ?? []) { + expect(frame.pageIndex).toBe(0); + expect(frame.heightPt).toBeGreaterThan(0); + expect(frame.widthPt).toBeGreaterThan(0); + } + // Reading order: each successive frame starts to the right of the previous one's end on its own line (here all three share one line, so x is strictly increasing). + const frames = theRun.runs[0]!.frames!; + expect(frames[1]!.xPt).toBeGreaterThanOrEqual(frames[0]!.xPt + frames[0]!.widthPt - 0.01); + }); + + it('stamps distinct frames per fragment even when a run splits across a page boundary', () => { + // One hugely oversized word from one run: the emergency character split forces it across several pages, and every fragment's frame names the page it actually landed on. + const doc = wordprocessingDoc([section([paragraph([run('Huge', { sizePt: 1000 })])])]); + convertWordprocessingToLayout(doc, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + const block = doc.sections[0]!.blocks[0]!; + if (block.kind !== 'paragraph') throw new Error('expected a paragraph'); + const frames = block.runs[0]!.frames!; + expect(frames.length).toBeGreaterThan(1); + expect(new Set(frames.map((frame) => frame.pageIndex)).size).toBeGreaterThan(1); + }); + + it('leaves frames undefined for an empty paragraph\'s synthesised fallback run', () => { + const doc = wordprocessingDoc([section([paragraph([])])]); + const { pages } = convertWordprocessingToLayout(doc, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + expect(pages).toEqual([{ widthPt: 100, heightPt: 50 }]); + expect(doc.sections[0]!.blocks[0]!.kind).toBe('paragraph'); + }); + + it('stamps the cell node\'s frame and each in-cell run\'s own frames for a table', () => { + const table: ContentTable = { + kind: 'table', + columnWidthsPt: [100], + rows: [{ heightPt: 20, cells: [{ blocks: [paragraph([run('Cell', { sizePt: 10 })])], background: { r: 1, g: 0, b: 0 } }] }], + }; + const doc = wordprocessingDoc([section([table])]); + convertWordprocessingToLayout(doc, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + const cell = table.rows[0]!.cells[0]!; + expect(cell.frames).toEqual([{ pageIndex: 0, xPt: 0, yPt: 50 - 20, widthPt: 100, heightPt: 20 }]); // PDF space: y flipped about the 50pt page + expect(cell.blocks[0]).toMatchObject({ kind: 'paragraph' }); + const cellParagraph = cell.blocks[0]; + if (cellParagraph?.kind !== 'paragraph') throw new Error('expected a paragraph'); + expect(cellParagraph.runs[0]!.frames?.[0]?.pageIndex).toBe(0); + }); + + it('stamps a list marker\'s frame onto the paragraph node itself', () => { + const doc = wordprocessingDoc([section([paragraph([run('item', { sizePt: 10 })], { list: { numId: 'md1:bullet', level: 0 } })])]); + const theParagraph = doc.sections[0]!.blocks[0]!; + convertWordprocessingToLayout(doc, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + if (theParagraph.kind !== 'paragraph') throw new Error('expected a paragraph'); + expect(theParagraph.frames).toHaveLength(1); // the marker -- the one item derived from the paragraph itself rather than from any run + expect(theParagraph.frames?.[0]?.pageIndex).toBe(0); + }); + + it('stamps an image block\'s frame at its placed position', () => { + const block = tinyPngBlock(); + const doc = wordprocessingDoc([section([block])]); + convertWordprocessingToLayout(doc, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + expect(block.frames).toEqual([{ pageIndex: 0, xPt: 0, yPt: 50 - 20, widthPt: 20, heightPt: 20 }]); + }); +}); + +describe('engine frames: presentation (slides.ts)', () => { + it('stamps the shape\'s own placement and each run\'s fragment frames', () => { + const s = shape({ frame: { xPt: 50, yPt: 40, widthPt: 300, heightPt: 100 }, blocks: [paragraph([run('Hi', { sizePt: 10 })])] }); + const doc = presentationDoc([slide([s])]); + const { pages } = convertPresentationToLayout(doc, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + expect(s.frames).toEqual([{ pageIndex: 0, xPt: 50, yPt: 540 - 40 - 100, widthPt: 300, heightPt: 100 }]); + const p = s.blocks[0]; + if (p?.kind !== 'paragraph') throw new Error('expected a paragraph'); + expect(p.runs[0]!.frames?.[0]?.pageIndex).toBe(0); + expect(pages).toEqual([{ widthPt: 960, heightPt: 540 }]); + }); +}); + +describe('engine frames: spreadsheet (sheets.ts)', () => { + it('stamps each populated cell\'s frame at its grid position', () => { + const doc: Extract = { kind: 'spreadsheet', formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, sheets: [sheet([{ row: 0, column: 0, value: { kind: 'string', value: 'A' }, displayText: 'A' }])] }; + convertSpreadsheetToLayout(doc, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + const cell = doc.sheets[0]!.cells[0]!; + expect(cell.frames).toEqual([{ pageIndex: 0, xPt: 10, yPt: 300 - 10 - 20, widthPt: 60, heightPt: 20 }]); + }); +}); + +describe('engine frames: drawing (drawing.ts)', () => { + it('stamps each vector\'s frame from its emitted item, flipped into PDF space', () => { + const vector: ContentVector = { kind: 'rect', frame: { xPt: 10, yPt: 20, widthPt: 30, heightPt: 40 }, fill: { r: 1, g: 0, b: 0 } }; + const doc = drawingDoc([drawPage({ vectors: [vector] })]); + const { pages } = convertDrawingToLayout(doc, { measurer: createStandardFontMeasurer() }); + expect(vector.frames).toEqual([{ pageIndex: 0, xPt: 10, yPt: 300 - 20 - 40, widthPt: 30, heightPt: 40 }]); + expect(pages).toEqual([{ widthPt: 400, heightPt: 300 }]); + }); +}); + +describe('reconstruct frames: wordprocessing (reconstruct.ts)', () => { + it('attaches each reconstructed paragraph and run frames from the exact items they were clustered from', () => { + const items: LayoutItem[] = [ + { kind: 'text', text: 'Hello', xPt: 72, yPt: 700, font: { family: 'Helvetica', weight: 'normal', style: 'normal' }, sizePt: 12, color: { r: 0, g: 0, b: 0 }, widthPt: 27 }, + { kind: 'text', text: 'world', xPt: 72.5 + 27, yPt: 700, font: { family: 'Helvetica', weight: 'normal', style: 'normal' }, sizePt: 12, color: { r: 0, g: 0, b: 0 }, widthPt: 28 }, + ]; + const content = reconstructWordprocessing({ formatVersion: 1, metadata: {}, pages: [{ widthPt: 612, heightPt: 792, items }], images: {} }); + if (content.kind !== 'wordprocessing') throw new Error('expected wordprocessing'); + const block = content.sections[0]!.blocks[0]; + if (block?.kind !== 'paragraph') throw new Error('expected a paragraph'); + // One line clustered -> one paragraph frame (the line's bounding box); two runs -> each carries its own item's box, both naming page 0. + expect(block.frames).toHaveLength(1); + expect(block.frames?.[0]?.pageIndex).toBe(0); + expect(block.runs).toHaveLength(2); + expect(block.runs[0]!.frames?.[0]?.xPt).toBe(72); + expect(block.runs[1]!.frames?.[0]?.xPt).toBe(72.5 + 27); + for (const run of block.runs) { + expect(run.frames?.[0]?.pageIndex).toBe(0); + } + }); +}); + +describe('from-package inverse (from-package.ts)', () => { + it('rebuilds a LayoutDocument whose pages match the package\'s own and whose text comes from the runs\' frames', () => { + const doc = wordprocessingDoc([section([paragraph([run('Hi', { sizePt: 10 })])])]); + const { pages } = convertWordprocessingToLayout(doc, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + const pkg: DocumentPackage = { formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content: doc, pages: [...pages] }; + const layout = layoutDocumentFromPackage(pkg); + expect(layout.pages.map(({ widthPt, heightPt }) => ({ widthPt, heightPt }))).toEqual(pages); + const texts = layout.pages.flatMap((page) => page.items.filter((item): item is Extract => item.kind === 'text')); + expect(texts.map((item) => item.text)).toEqual(['Hi']); + // The rebuilt item sits at the run's own recorded frame. + const block = doc.sections[0]!.blocks[0]; + if (block?.kind !== 'paragraph') throw new Error('expected a paragraph'); + const frame = block.runs[0]!.frames![0]!; + expect(texts[0]!.xPt).toBe(frame.xPt); + expect(texts[0]!.yPt).toBe(frame.yPt); + }); + + it('renders one frame per image placement and rebuilds vectors exactly through the drawing engine\'s own conversion', () => { + const vector: ContentVector = { kind: 'rect', frame: { xPt: 10, yPt: 20, widthPt: 30, heightPt: 40 }, fill: { r: 1, g: 0, b: 0 } }; + const image = tinyPngBlock(); + const doc = drawingDoc([drawPage({ vectors: [vector], shapes: [shape({ blocks: [image], frame: { xPt: 100, yPt: 100, widthPt: 20, heightPt: 20 } })] })]); + const { pages } = convertDrawingToLayout(doc, { measurer: createStandardFontMeasurer() }); + const layout = layoutDocumentFromPackage({ formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content: doc, pages: [...pages] }); + const rebuiltItems = layout.pages[0]!.items; + expect(rebuiltItems.map((item) => item.kind)).toEqual(['rect', 'image']); + const rebuiltRect = rebuiltItems.find((item): item is Extract => item.kind === 'rect'); + expect(rebuiltRect).toMatchObject({ xPt: 10, yPt: 300 - 20 - 40, widthPt: 30, heightPt: 40, fill: { r: 1, g: 0, b: 0 } }); + const rebuiltImage = rebuiltItems.find((item): item is Extract => item.kind === 'image'); + expect(rebuiltImage).toMatchObject({ xPt: 100, yPt: 300 - 100 - 20, widthPt: 20, heightPt: 20 }); + }); + + it('renders a spreadsheet cell\'s displayText at its own frame (single-line by construction)', () => { + const doc: Extract = { kind: 'spreadsheet', formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, sheets: [sheet([{ row: 0, column: 0, value: { kind: 'string', value: 'A' }, displayText: 'A' }])] }; + const { pages } = convertSpreadsheetToLayout(doc, { measurer: createStandardFontMeasurer(), mathMetricsAt }); + const layout = layoutDocumentFromPackage({ formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content: doc, pages: [...pages] }); + const texts = layout.pages[0]!.items.filter((item): item is Extract => item.kind === 'text'); + expect(texts.map((item) => item.text)).toEqual(['A']); + }); +}); diff --git a/src/layout/reconstruct.test.ts b/src/layout/reconstruct.test.ts index 81a25044..58b1b022 100644 --- a/src/layout/reconstruct.test.ts +++ b/src/layout/reconstruct.test.ts @@ -275,21 +275,21 @@ describe('reconstructDrawing: vector mapping', () => { const item: LayoutItem = { kind: 'rect', xPt: 20, yPt: 30, widthPt: 100, heightPt: 40, fill: RED }; const doc = reconstructDrawing(docFrom([page(400, 300, [item])])); const [pg] = drawPages(doc); - expect(pg!.vectors).toEqual([{ kind: 'rect', frame: { xPt: 20, yPt: 230, widthPt: 100, heightPt: 40 }, fill: RED, stroke: undefined, sourcePath: undefined, paintOrder: 0 }]); // yPt = 300 - 30 - 40 + expect(pg!.vectors).toEqual([{ kind: 'rect', frame: { xPt: 20, yPt: 230, widthPt: 100, heightPt: 40 }, fill: RED, stroke: undefined, sourcePath: undefined, paintOrder: 0, frames: [{ pageIndex: 0, xPt: 20, yPt: 30, widthPt: 100, heightPt: 40 }] }]); // yPt = 300 - 30 - 40; the vector's own frames carry the exact PDF-space box it was recovered from }); it('maps a LayoutEllipse to an ellipse ContentVector via the exact flipY inverse', () => { const item: LayoutItem = { kind: 'ellipse', xPt: 50, yPt: 60, widthPt: 80, heightPt: 20, stroke: { color: BLACK, widthPt: 1 } }; const doc = reconstructDrawing(docFrom([page(400, 300, [item])])); const [pg] = drawPages(doc); - expect(pg!.vectors).toEqual([{ kind: 'ellipse', frame: { xPt: 50, yPt: 220, widthPt: 80, heightPt: 20 }, fill: undefined, stroke: { color: BLACK, widthPt: 1 }, sourcePath: undefined, paintOrder: 0 }]); // yPt = 300 - 60 - 20 + expect(pg!.vectors).toEqual([{ kind: 'ellipse', frame: { xPt: 50, yPt: 220, widthPt: 80, heightPt: 20 }, fill: undefined, stroke: { color: BLACK, widthPt: 1 }, sourcePath: undefined, paintOrder: 0, frames: [{ pageIndex: 0, xPt: 50, yPt: 60, widthPt: 80, heightPt: 20 }] }]); // yPt = 300 - 60 - 20 }); it('maps a LayoutLine to a line ContentVector with a synthesized stroke object', () => { const item: LayoutItem = { kind: 'line', x1Pt: 10, y1Pt: 20, x2Pt: 90, y2Pt: 60, color: BLACK, widthPt: 2 }; const doc = reconstructDrawing(docFrom([page(400, 300, [item])])); const [pg] = drawPages(doc); - expect(pg!.vectors).toEqual([{ kind: 'line', from: { xPt: 10, yPt: 280 }, to: { xPt: 90, yPt: 240 }, stroke: { color: BLACK, widthPt: 2 }, sourcePath: undefined, paintOrder: 0 }]); // yPt = 300 - y1Pt/y2Pt + expect(pg!.vectors).toEqual([{ kind: 'line', from: { xPt: 10, yPt: 280 }, to: { xPt: 90, yPt: 240 }, stroke: { color: BLACK, widthPt: 2 }, sourcePath: undefined, paintOrder: 0, frames: [{ pageIndex: 0, xPt: 10, yPt: 20, widthPt: 80, heightPt: 40 }] }]); // yPt = 300 - y1Pt/y2Pt; the line's own frame is the bounding box of its two endpoints }); it('maps a LayoutPath to a path ContentVector with a tight bounding-box frame and localized subpath points', () => { @@ -310,6 +310,7 @@ describe('reconstructDrawing: vector mapping', () => { stroke: undefined, sourcePath: undefined, paintOrder: 0, + frames: [{ pageIndex: 0, xPt: 50, yPt: 250, widthPt: 100, heightPt: 0 }], }, ]); }); diff --git a/src/layout/reconstruct.ts b/src/layout/reconstruct.ts index 34097da1..53163741 100644 --- a/src/layout/reconstruct.ts +++ b/src/layout/reconstruct.ts @@ -20,6 +20,7 @@ import type { ContentTableRow, ContentVector, LayoutDocument, + LayoutFrame, LayoutEllipse, LayoutImage, LayoutImageAsset, @@ -38,6 +39,7 @@ import type { Box, Margins } from 'document-schema.js'; import { flipY } from '../model/geometry'; import type { Alignment } from 'document-schema.js'; import { throwIfAborted } from '../ports/abort'; +import { stampFrame } from './shared'; import type { CellTypeInference, CellTypeInferenceSink } from './cell-typing'; import { inferCellValue } from './cell-typing'; import type { GridLattice } from './lattice'; @@ -102,8 +104,31 @@ function textItemToContentRun(item: LayoutText): ContentRun { // A small absolute floor (not font-size-relative) below which two adjacent items are treated as directly continuing the same word (e.g. a bold/italic sub-run split mid-word) rather than separate words needing a space -- guards against float-rounding noise producing a spurious tiny positive gap. const MIN_WORD_GAP_PT = 0.5; +// The PDF-space box one recovered text item occupied -- the exact frame stamped onto the ContentRun node rebuilt from it (and, aggregated over a line's items, onto the paragraph that line became). Uses the same real AFM ascent/descent metrics textItemVerticalExtent derives, so a run's own frame matches the geometry its source glyph run was rendered with. +function textBoxOfItem(item: LayoutText, pageIndex: number): LayoutFrame { + const { ascentPt, descentPt } = textItemVerticalExtent(item); + return { pageIndex, xPt: item.xPt, yPt: item.yPt - descentPt, widthPt: item.widthPt ?? 0, heightPt: ascentPt + descentPt }; +} + +// The PDF-space bounding box of a whole clustered line -- the frame stamped onto the ContentParagraph a line (or a one-line block) became. +function lineBox(line: TextLine, pageIndex: number): LayoutFrame { + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const item of line.items) { + const { ascentPt, descentPt } = textItemVerticalExtent(item); + minX = Math.min(minX, item.xPt); + maxX = Math.max(maxX, item.xPt + (item.widthPt ?? 0)); + minY = Math.min(minY, item.yPt - descentPt); + maxY = Math.max(maxY, item.yPt + ascentPt); + } + return { pageIndex, xPt: minX, yPt: minY, widthPt: maxX - minX, heightPt: maxY - minY }; +} + // Appends ContentRuns for one line's items, inserting the word-space or tab a caller reading the reconstructed text needs between them: PDF text extraction carries no literal space characters between separately-shown words (interpret.ts's word-wrapping and per-item positioning use xOffsetPt, not embedded spaces), so a positive gap between consecutive items must be turned back into an actual space (or, when it's large enough to read as tabbed/columnar content, a tab) rather than silently concatenating adjacent words together. -function pushRunsForLine(runs: ContentRun[], line: TextLine): void { +// pageIndex threads through so every run rebuilt from an item carries that item's own rendered position as its frame -- the PDF->X half of the frames fusion, where each reconstructed node's frames are exactly the items it was clustered from (sourcePath survives on items as traceability only). +function pushRunsForLine(runs: ContentRun[], line: TextLine, pageIndex: number): void { line.items.forEach((item, itemIndex) => { if (itemIndex > 0) { const prevItem = line.items[itemIndex - 1]!; @@ -114,7 +139,9 @@ function pushRunsForLine(runs: ContentRun[], line: TextLine): void { runs[runs.length - 1]!.text += ' '; } } - runs.push(textItemToContentRun(item)); + const run = textItemToContentRun(item); + stampFrame(run, pageIndex, textBoxOfItem(item, pageIndex)); + runs.push(run); }); } @@ -188,16 +215,18 @@ export function reconstructWordprocessing(doc: LayoutDocument, options?: Reconst const signal = options?.signal; const sections: ContentSection[] = []; let currentGroup: LayoutPage[] = []; + let groupStartPageIndex = 0; for (const page of doc.pages) { throwIfAborted(signal); if (currentGroup.length > 0 && !samePageSize(currentGroup[0]!, page)) { - sections.push(buildSection(currentGroup, doc.images)); + sections.push(buildSection(currentGroup, groupStartPageIndex, doc.images)); + groupStartPageIndex += currentGroup.length; currentGroup = []; } currentGroup.push(page); } if (currentGroup.length > 0) { - sections.push(buildSection(currentGroup, doc.images)); + sections.push(buildSection(currentGroup, groupStartPageIndex, doc.images)); } return { kind: 'wordprocessing', formatVersion: CONTENT_FORMAT_VERSION, metadata: doc.metadata, sections }; } @@ -207,20 +236,21 @@ function samePageSize(a: LayoutPage, b: LayoutPage): boolean { } // Margins have no PDF equivalent to recover -- there is no principled way to distinguish "intentional margin" from "wherever the content happened to start" from geometry alone, so this deliberately reports zero rather than fabricating a plausible-looking value (ZERO_MARGINS, defined above). -function buildSection(pages: readonly LayoutPage[], images: Record): ContentSection { +// startPageIndex is this section's own first page's absolute index in the whole LayoutDocument -- a frame's pageIndex names a page of the SOURCE document, not a page within one section, so every block this section builds stamps absolute indices derived from it. +function buildSection(pages: readonly LayoutPage[], startPageIndex: number, images: Record): ContentSection { const blocks: ContentBlock[] = []; pages.forEach((page, i) => { if (i > 0) { blocks.push({ kind: 'pageBreak' }); } - blocks.push(...reconstructPageBlocks(page, images)); + blocks.push(...reconstructPageBlocks(page, startPageIndex + i, images)); }); return { pageSize: { widthPt: pages[0]!.widthPt, heightPt: pages[0]!.heightPt }, margins: ZERO_MARGINS, blocks }; } // Table recovery runs FIRST, because it decides what is left for everything after it: text inside a recovered lattice belongs to the table, not to the page's paragraph flow, and the lattice's own strokes belong to the table's structure, not to the recovered vector content. Both recoveries are no-ops on a page without the geometry to support them, so a text-only page produces exactly the blocks it always did. See the shared recovery section below for the full reasoning behind each gate. -function reconstructPageBlocks(page: LayoutPage, images: Record): ContentBlock[] { - const recoveredTable = recoverTable(page); +function reconstructPageBlocks(page: LayoutPage, pageIndex: number, images: Record): ContentBlock[] { + const recoveredTable = recoverTable(page, pageIndex); const consumedText = recoveredTable?.consumedText; const textItems = page.items.filter((i): i is LayoutText => i.kind === 'text' && consumedText?.has(i) !== true); const imageItems = page.items.filter((i): i is LayoutImage => i.kind === 'image'); @@ -229,18 +259,20 @@ function reconstructPageBlocks(page: LayoutPage, images: Record l.items[0]!.xPt), 1); const alignment: Alignment | undefined = paragraph.lines.every((l) => Math.abs(l.items[0]!.xPt - dominantLeftX) <= LEFT_ALIGN_TOLERANCE_PT) ? 'left' : undefined; - const runs: ContentRun[] = []; + const result: ContentParagraph = { kind: 'paragraph', runs: [], alignment }; paragraph.lines.forEach((line, lineIndex) => { + // One frame per clustered line, stamped on the paragraph node itself -- the paragraph's own rendered placements, aggregated from exactly the items it was clustered from (the runs inside carry their own finer-grained frames via pushRunsForLine). + stampFrame(result, pageIndex, lineBox(line, pageIndex)); // Lines within a paragraph join with a single space -- deliberately not de-hyphenating a trailing hyphen, since the "looks like a soft hyphen" heuristic corrupts genuine hyphenated compounds about as often as it fixes wrapped words (plan Step 10). if (lineIndex > 0) { - const lastRun = runs[runs.length - 1]; + const lastRun = result.runs[result.runs.length - 1]; if (lastRun !== undefined) { lastRun.text += ' '; } } - pushRunsForLine(runs, line); + pushRunsForLine(result.runs, line, pageIndex); }); - return { kind: 'paragraph', runs, alignment }; + return result; } // --------------------------------------------------------------------------- @@ -313,42 +347,44 @@ function paragraphToContentParagraph(paragraph: TextParagraph): ContentParagraph export function reconstructPresentation(doc: LayoutDocument, options?: ReconstructOptions): ContentDocument { const signal = options?.signal; - const slides = doc.pages.map((page) => { + const slides = doc.pages.map((page, pageIndex) => { throwIfAborted(signal); - return reconstructSlide(page, doc.images); + return reconstructSlide(page, pageIndex, doc.images); }); return { kind: 'presentation', formatVersion: CONTENT_FORMAT_VERSION, metadata: doc.metadata, slides }; } // Table and vector recovery run here on exactly the same terms as in reconstructPageBlocks above -- same detector, same gates, same exclusions -- differing only in the container each result has to be wrapped in: a slide holds nothing but ContentShapes, so a recovered table and a recovered drawing each become a shape framed at the geometry they were recovered from, rather than a bare block placed in a flow. -function reconstructSlide(page: LayoutPage, images: Record): ContentSlide { - const recoveredTable = recoverTable(page); +function reconstructSlide(page: LayoutPage, pageIndex: number, images: Record): ContentSlide { + const recoveredTable = recoverTable(page, pageIndex); const consumedText = recoveredTable?.consumedText; const textItems = page.items.filter((i): i is LayoutText => i.kind === 'text' && consumedText?.has(i) !== true); const imageItems = page.items.filter((i): i is LayoutImage => i.kind === 'image'); const lines = clusterIntoLines(textItems); const blocks = clusterIntoBlocks(lines); - const textShapes = blocks.map((block) => blockToShape(block, page.heightPt)); + const textShapes = blocks.map((block) => blockToShape(block, page.heightPt, pageIndex)); const imageShapes: ContentShape[] = []; for (const img of imageItems) { - const shape = imageToShape(img, page.heightPt, images); + const shape = imageToShape(img, page.heightPt, pageIndex, images); if (shape !== undefined) { imageShapes.push(shape); } } - const recoveredVectors = recoverPageVectors(page, recoveredTable?.latticeItems ?? NO_ITEMS); + const recoveredVectors = recoverPageVectors(page, pageIndex, recoveredTable?.latticeItems ?? NO_ITEMS); // Vectors paint behind everything else, matching src/layout/drawing.ts's own documented vectors-then-shapes fallback for a page whose true interleaving is unknown -- and it is unknown here for the same reason: a slide's shapes array carries no ordering field relating it to content recovered outside it. - const vectorShapes: ContentShape[] = recoveredVectors === undefined ? [] : [wrapBlockInShape(recoveredVectors.block, { xPt: 0, yPt: 0, widthPt: page.widthPt, heightPt: page.heightPt })]; - const tableShapes: ContentShape[] = recoveredTable === undefined ? [] : [wrapBlockInShape(recoveredTable.table, recoveredTable.frame)]; + const vectorShapes: ContentShape[] = recoveredVectors === undefined ? [] : [wrapBlockInShape(recoveredVectors.block, { xPt: 0, yPt: 0, widthPt: page.widthPt, heightPt: page.heightPt }, page.heightPt, pageIndex)]; + const tableShapes: ContentShape[] = recoveredTable === undefined ? [] : [wrapBlockInShape(recoveredTable.table, recoveredTable.frame, page.heightPt, pageIndex)]; // Images before text shapes in z-order (plan Step 10). notes recovers LayoutPage's own private page-dictionary entry (see pdf/write.ts/read.ts) when the source PDF was produced by this package's own pptxToPdf -- absent (falls back to '') for a PDF from any other producer, since nothing else would ever write it. return { size: { widthPt: page.widthPt, heightPt: page.heightPt }, shapes: [...vectorShapes, ...imageShapes, ...tableShapes, ...textShapes], notes: page.notes ?? '' }; } -// A single recovered block as its own containing shape, with the zero insets and no rotation every other shape this module produces already uses -- a slide has no container for a bare block, and a table or a drawing recovered from a page is exactly one block. -function wrapBlockInShape(block: ContentBlock, frame: Box): ContentShape { - return { frame, insetLeftPt: 0, insetTopPt: 0, insetRightPt: 0, insetBottomPt: 0, blocks: [block] }; +// A single recovered block as its own containing shape, with the zero insets and no rotation every other shape this module produces already uses -- a slide has no container for a bare block, and a table or a drawing recovered from a page is exactly one block. The wrapper shape's frame records where the wrapped content sat (frame arrives y-down; the stamped frame is its PDF-space flip). +function wrapBlockInShape(block: ContentBlock, frame: Box, pageHeightPt: number, pageIndex: number): ContentShape { + const shape: ContentShape = { frame, insetLeftPt: 0, insetTopPt: 0, insetRightPt: 0, insetBottomPt: 0, blocks: [block] }; + stampFrame(shape, pageIndex, flipY(frame, pageHeightPt)); + return shape; } interface TextBlock { @@ -426,39 +462,47 @@ function computeBlockFrame(block: TextBlock, slideHeightPt: number): Box { return flipY({ xPt: minX, yPt: minY, widthPt: maxX - minX, heightPt: maxY - minY }, slideHeightPt); } -function lineToParagraph(line: TextLine): ContentParagraph { - const runs: ContentRun[] = []; - pushRunsForLine(runs, line); - return { kind: 'paragraph', runs }; +function lineToParagraph(line: TextLine, pageIndex: number): ContentParagraph { + const paragraph: ContentParagraph = { kind: 'paragraph', runs: [] }; + stampFrame(paragraph, pageIndex, lineBox(line, pageIndex)); + pushRunsForLine(paragraph.runs, line, pageIndex); + return paragraph; } -function blockToShape(block: TextBlock, slideHeightPt: number): ContentShape { - return { +// A recovered text block's own shape frame is stamped from the PDF-space bounding box of exactly the items clustered into it -- computeBlockFrame returns that same box flipped into top-left/y-down space for the shape's own frame field, so the stamp records the pre-flip original. +function blockToShape(block: TextBlock, slideHeightPt: number, pageIndex: number): ContentShape { + const shape: ContentShape = { frame: computeBlockFrame(block, slideHeightPt), insetLeftPt: 0, insetTopPt: 0, insetRightPt: 0, insetBottomPt: 0, - blocks: block.lines.map(lineToParagraph), + blocks: block.lines.map((line) => lineToParagraph(line, pageIndex)), }; + stampFrame(shape, pageIndex, flipY(shape.frame, slideHeightPt)); + return shape; } // The inverse of content-write.ts's own placement convention: LayoutImage.rotationDeg is counter-clockwise-positive (matrix.ts's convention, via matrixRotationDegrees), while ContentShape.rotationDeg is clockwise (DrawingML's a:xfrm/@rot convention) -- negated here, the one place PDF-space image rotation crosses into OOXML-space. -function imageToShape(img: LayoutImage, slideHeightPt: number, images: Record): ContentShape | undefined { +function imageToShape(img: LayoutImage, slideHeightPt: number, pageIndex: number, images: Record): ContentShape | undefined { const asset = images[img.imageId]; if (asset === undefined) { return undefined; } const frame = flipY({ xPt: img.xPt, yPt: img.yPt, widthPt: img.widthPt, heightPt: img.heightPt }, slideHeightPt); - return { + const block: ContentBlock = { kind: 'image', format: asset.format, base64: asset.base64, widthPt: img.widthPt, heightPt: img.heightPt }; + stampFrame(block, pageIndex, { xPt: img.xPt, yPt: img.yPt, widthPt: img.widthPt, heightPt: img.heightPt }); + const shape: ContentShape = { frame, rotationDeg: img.rotationDeg !== undefined ? -img.rotationDeg : undefined, insetLeftPt: 0, insetTopPt: 0, insetRightPt: 0, insetBottomPt: 0, - blocks: [{ kind: 'image', format: asset.format, base64: asset.base64, widthPt: img.widthPt, heightPt: img.heightPt }], + blocks: [block], }; + stampFrame(shape, pageIndex, { xPt: img.xPt, yPt: img.yPt, widthPt: img.widthPt, heightPt: img.heightPt }); + return shape; } // --------------------------------------------------------------------------- @@ -467,28 +511,31 @@ function imageToShape(img: LayoutImage, slideHeightPt: number, images: Record { + const pages: ContentDrawPage[] = doc.pages.map((page, pageIndex) => { throwIfAborted(signal); - return reconstructDrawPage(page, doc.images); + return reconstructDrawPage(page, pageIndex, doc.images); }); return { kind: 'drawing', formatVersion: CONTENT_FORMAT_VERSION, metadata: doc.metadata, pages }; } // ContentDrawPageSchema still keeps shapes and vectors as two separate arrays, but both ContentVector and ContentShape carry a shared `paintOrder` recording their true relative position -- the field drawing.ts's own convertDrawingToLayout merges by when going the other direction. reconstructDrawPage produces exactly that field here: it already walked page.items once in real paint order (a LayoutPage's items ARE its paint order, front-to-back by array position) and bucketed each into whichever array its own kind belongs to, so recording the walk position as it goes is all that is needed for the relative order between the two arrays to survive at all. A page that genuinely interleaves the two consequently round-trips its interleaving exactly, rather than collapsing to all-vectors-then-all-shapes the way it had to before the schema carried the field. 'link' items have no drawing-page equivalent and are dropped, matching reconstructPageBlocks/reconstructSlide's own existing precedent above of ignoring link items entirely -- a dropped item consumes no paintOrder slot either, so the stamped values stay a dense 0..n-1 run over what was actually recovered. -function reconstructDrawPage(page: LayoutPage, images: Record): ContentDrawPage { +function reconstructDrawPage(page: LayoutPage, pageIndex: number, images: Record): ContentDrawPage { const vectors: ContentVector[] = []; const shapes: ContentShape[] = []; let paintOrder = 0; for (const item of page.items) { const vector = layoutItemToVector(item, page.heightPt); if (vector !== undefined) { + stampVectorFrame(vector, item, pageIndex); vectors.push({ ...vector, paintOrder: paintOrder++ }); continue; } if (item.kind === 'text') { - shapes.push({ ...layoutTextToShape(item, page.heightPt), paintOrder: paintOrder++ }); + const shape = layoutTextToShape(item, page.heightPt, pageIndex); + stampFrame(shape, pageIndex, flipY(shape.frame, page.heightPt)); + shapes.push({ ...shape, paintOrder: paintOrder++ }); } else if (item.kind === 'image') { - const shape = imageToShape(item, page.heightPt, images); + const shape = imageToShape(item, page.heightPt, pageIndex, images); if (shape !== undefined) { shapes.push({ ...shape, paintOrder: paintOrder++ }); } @@ -497,6 +544,33 @@ function reconstructDrawPage(page: LayoutPage, images: Record ContentVector classification in this package, shared verbatim by all three reconstruction directions: reconstructDrawing (above), and -- via recoverPageVectors below -- reconstructWordprocessing and reconstructPresentation. Which items reach it at all is a per-direction decision; what a rect/ellipse/line/path becomes once it does is not, and deliberately has no second implementation anywhere. Returns undefined for every non-vector kind (text/image/link), so a caller can use it as the "is this vector geometry?" test and its own converter in one step. // // How much this actually recovers is a property of pdf-codec's own content-stream interpreter, not of this function: its shape-pattern detection recognises an axis-aligned closed four-corner subpath as a real LayoutRect (any fill/stroke combination, and a 90-degree-rotated CTM as well as an unrotated one), a closed four-cubic kappa-ratio subpath as a real LayoutEllipse, and an open single-straight-segment stroke-only subpath as a real LayoutLine. Anything outside those patterns -- an off-axis rotation, a freeform curve, a multi-subpath figure -- stays a generic LayoutPath and is recovered as a 'path' vector, which is an honest narrowing of KIND only: the recovered geometry itself is exact either way. @@ -604,8 +678,12 @@ function layoutPathToVector(item: LayoutPath, pageHeightPt: number): ContentVect } // A single LayoutText item maps to exactly one ContentShape holding one single-run paragraph -- reuses computeBlockFrame/textItemToContentRun verbatim rather than inventing a second frame-estimation approach (the same real AFM ascent/descent math reconstructPresentation's own blockToShape already uses above, degenerating correctly to a one-line, one-item block). Unlike blockToShape (which can merge several LayoutText items into one block and therefore cannot assign a single rotation to the merged result), this mapping is genuinely 1:1, so item.rotationDeg carries straight across, negated -- the same LayoutImage counter-clockwise -> ContentShape clockwise convention imageToShape already applies below. -function layoutTextToShape(item: LayoutText, pageHeightPt: number): ContentShape { +function layoutTextToShape(item: LayoutText, pageHeightPt: number, pageIndex: number): ContentShape { const frame = computeBlockFrame({ lines: [{ items: [item], baselineY: item.yPt }] }, pageHeightPt); + const run = textItemToContentRun(item); + stampFrame(run, pageIndex, textBoxOfItem(item, pageIndex)); + const paragraph: ContentParagraph = { kind: 'paragraph', runs: [run] }; + stampFrame(paragraph, pageIndex, textBoxOfItem(item, pageIndex)); return { frame, rotationDeg: item.rotationDeg !== undefined ? -item.rotationDeg : undefined, @@ -613,7 +691,7 @@ function layoutTextToShape(item: LayoutText, pageHeightPt: number): ContentShape insetTopPt: 0, insetRightPt: 0, insetBottomPt: 0, - blocks: [{ kind: 'paragraph', runs: [textItemToContentRun(item)] }], + blocks: [paragraph], }; } @@ -640,7 +718,7 @@ interface RecoveredVectors { } // Every vector primitive on a page, in paint order, as one embedded drawing block -- or undefined when the page has none, so a text-only page's output is byte-identical to what it was before this recovery existed. `excluded` carries the items already claimed as a table's own gridlines. -function recoverPageVectors(page: LayoutPage, excluded: ReadonlySet): RecoveredVectors | undefined { +function recoverPageVectors(page: LayoutPage, pageIndex: number, excluded: ReadonlySet): RecoveredVectors | undefined { const vectors: ContentVector[] = []; for (const item of page.items) { if (excluded.has(item)) { @@ -648,6 +726,7 @@ function recoverPageVectors(page: LayoutPage, excluded: ReadonlySet) } const vector = layoutItemToVector(item, page.heightPt); if (vector !== undefined) { + stampVectorFrame(vector, item, pageIndex); // paintOrder is the recovery index, exactly as reconstructDrawPage stamps it: a LayoutPage's items ARE its paint order, front-to-back by array position. vectors.push({ ...vector, paintOrder: vectors.length }); } @@ -656,7 +735,10 @@ function recoverPageVectors(page: LayoutPage, excluded: ReadonlySet) return undefined; } const topYDownPt = Math.min(...vectors.map(vectorTopYDownPt)); - return { block: buildDrawingBlock({ widthPt: page.widthPt, heightPt: page.heightPt }, vectors), topYPt: page.heightPt - topYDownPt }; + const block = buildDrawingBlock({ widthPt: page.widthPt, heightPt: page.heightPt }, vectors); + // The wrapper block sat across the whole page -- the vectors inside it are page-anchored by construction (see buildDrawingBlock's own doc), so its own placement is the page itself. + stampFrame(block, pageIndex, { xPt: 0, yPt: 0, widthPt: page.widthPt, heightPt: page.heightPt }); + return { block, topYPt: page.heightPt - topYDownPt }; } // --- Table recovery, gated on an unambiguously detected gridline lattice --------------------------------- @@ -674,11 +756,11 @@ interface RecoveredTable { } // One cell's own text, as one ContentParagraph per recovered line. A table cell's text genuinely can wrap across lines (unlike a spreadsheet cell's -- see buildGridFromTextClustering's own note), and geometry alone cannot say whether two stacked lines in a cell were one wrapped paragraph or two separate ones, so each line stays its own paragraph rather than being joined on a guess. This is the same choice reconstructPresentation's own blockToShape already makes for a slide text box, for the same reason. -function cellBlocksFromItems(items: readonly LayoutText[]): ContentBlock[] { - return clusterIntoLines(items).map(lineToParagraph); +function cellBlocksFromItems(items: readonly LayoutText[], pageIndex: number): ContentBlock[] { + return clusterIntoLines(items).map((line) => lineToParagraph(line, pageIndex)); } -function recoverTable(page: LayoutPage): RecoveredTable | undefined { +function recoverTable(page: LayoutPage, pageIndex: number): RecoveredTable | undefined { const lattice = detectGridLattice(page.items); if (lattice === undefined) { return undefined; @@ -711,8 +793,14 @@ function recoverTable(page: LayoutPage): RecoveredTable | undefined { for (let i = 0; i < rowCount; i++) { const cells: ContentTableCell[] = []; for (let j = 0; j < columnCount; j++) { - // A cell with no text recovered inside it is emitted as a genuinely empty cell rather than skipped: a ContentTableRow's cells are positional, so dropping one would shift every cell after it into the wrong column. - cells.push({ blocks: cellBlocksFromItems(groups.get(groupKey(i, j)) ?? []) }); + // A cell with no text recovered inside it is emitted as a genuinely empty cell rather than skipped: a ContentTableRow's cells are positional, so dropping one would shift every cell after it into the wrong column. Every cell carries its own lattice-measured frame -- the exact box the drawn gridline lattice gave it, in PDF space. + const cell: ContentTableCell = { blocks: cellBlocksFromItems(groups.get(groupKey(i, j)) ?? [], pageIndex) }; + const cellLeftXPt = lattice.columnBoundariesAscPt[j]!; + const cellRightXPt = lattice.columnBoundariesAscPt[j + 1]!; + const cellTopYPt = lattice.rowBoundariesDescPt[i]!; + const cellBottomYPt = lattice.rowBoundariesDescPt[i + 1]!; + stampFrame(cell, pageIndex, { xPt: cellLeftXPt, yPt: cellBottomYPt, widthPt: cellRightXPt - cellLeftXPt, heightPt: cellTopYPt - cellBottomYPt }); + cells.push(cell); } rows.push({ cells, heightPt: lattice.rowBoundariesDescPt[i]! - lattice.rowBoundariesDescPt[i + 1]! }); } @@ -721,8 +809,11 @@ function recoverTable(page: LayoutPage): RecoveredTable | undefined { const rightXPt = lattice.columnBoundariesAscPt[columnCount]!; const topYPt = lattice.rowBoundariesDescPt[0]!; const bottomYPt = lattice.rowBoundariesDescPt[rowCount]!; - const frame = flipY({ xPt: leftXPt, yPt: bottomYPt, widthPt: rightXPt - leftXPt, heightPt: topYPt - bottomYPt }, page.heightPt); - return { table: { kind: 'table', rows, columnWidthsPt }, frame, topYPt, consumedText, latticeItems: lattice.sourceItems }; + const pdfBox = { xPt: leftXPt, yPt: bottomYPt, widthPt: rightXPt - leftXPt, heightPt: topYPt - bottomYPt }; + const frame = flipY(pdfBox, page.heightPt); + const table: ContentTable = { kind: 'table', rows, columnWidthsPt }; + stampFrame(table, pageIndex, pdfBox); + return { table, frame, topYPt, consumedText, latticeItems: lattice.sourceItems }; } const NO_ITEMS: ReadonlySet = new Set(); @@ -767,7 +858,7 @@ function addToGroup(groups: Map, row: number, column: numb } // Every recovered cell ALWAYS carries its own rendered text verbatim in displayText, and additionally carries a heuristically re-typed `value` wherever src/layout/cell-typing.ts finds exactly one defensible reading of that text (see its own module doc for the confidence bar, and this section's top-of-block note for why the whole step is probabilistic). A cell whose text is ambiguous, or not number/date/boolean-shaped at all, keeps `value` as the plain string it was recovered as -- so `value.kind !== 'string'` is itself the flag distinguishing an inferred value from an untouched one, with the reporting sink below carrying the reason behind either outcome. A (row, column) position with no text assigned to it at all is simply never emitted, matching the sparse cell model buildOdsPackage's own appendCell already expects. -function buildCellsFromGroups(groups: ReadonlyMap, context: CellTypingContext): ContentSheetCell[] { +function buildCellsFromGroups(groups: ReadonlyMap, pageIndex: number, context: CellTypingContext): ContentSheetCell[] { const cells: ContentSheetCell[] = []; for (const [key, items] of groups) { const displayText = joinCellText(items); @@ -777,7 +868,21 @@ function buildCellsFromGroups(groups: ReadonlyMap const [rowPart, columnPart] = key.split(','); const row = Number(rowPart); const column = Number(columnPart); - cells.push({ row, column, value: inferredCellValue(displayText, row, column, context), displayText }); + const cell: ContentSheetCell = { row, column, value: inferredCellValue(displayText, row, column, context), displayText }; + // The cell's frame is the PDF-space bounding box of exactly the items clustered into it -- the printed extent of that cell's own content, which is all a rendered PDF carries about where the cell was. + let minX = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + for (const item of items) { + const { ascentPt, descentPt } = textItemVerticalExtent(item); + minX = Math.min(minX, item.xPt); + maxX = Math.max(maxX, item.xPt + (item.widthPt ?? 0)); + minY = Math.min(minY, item.yPt - descentPt); + maxY = Math.max(maxY, item.yPt + ascentPt); + } + stampFrame(cell, pageIndex, { xPt: minX, yPt: minY, widthPt: maxX - minX, heightPt: maxY - minY }); + cells.push(cell); } cells.sort((a, b) => a.row - b.row || a.column - b.column); return cells; @@ -807,7 +912,7 @@ interface ReconstructedGrid { } // The gridline positions ARE the cell boundaries -- column/row widths are the exact, genuinely measured gap between consecutive drawn lines, not estimated from text at all. -function buildGridFromLattice(textItems: readonly LayoutText[], lattice: GridLattice, context: CellTypingContext): ReconstructedGrid { +function buildGridFromLattice(textItems: readonly LayoutText[], lattice: GridLattice, pageIndex: number, context: CellTypingContext): ReconstructedGrid { const groups = new Map(); for (const item of textItems) { const row = findRowIndex(lattice.rowBoundariesDescPt, item.yPt); @@ -825,7 +930,7 @@ function buildGridFromLattice(textItems: readonly LayoutText[], lattice: GridLat for (let i = 0; i < lattice.rowBoundariesDescPt.length - 1; i++) { rows.push({ index: i, heightPt: lattice.rowBoundariesDescPt[i]! - lattice.rowBoundariesDescPt[i + 1]! }); } - return { cells: buildCellsFromGroups(groups, context), columns, rows, gridlines: true }; + return { cells: buildCellsFromGroups(groups, pageIndex, context), columns, rows, gridlines: true }; } // --- Path 2: text-position clustering, no gridlines present ----------------------------------------------------------- @@ -881,7 +986,7 @@ function lastColumnWidthPt(groups: ReadonlyMap, c } // Rows reuse clusterIntoLines directly -- a spreadsheet cell's own text is never wrapped across lines (sheets.ts's own module doc), so a text line already IS a row, with no separate row-clustering pass needed. Each line is then split into segments wherever a large horizontal gap occurs, reusing splitLineByLargeGaps verbatim -- the same >2em-gap signal reconstructPresentation's own block clustering already uses to tell "still one cluster of text" from "a new one" -- since a single cell's own text can arrive as several directly adjacent LayoutText fragments (a run-level style change mid-cell) that must be treated as one cell candidate, not several. Row heights are the genuinely measured baseline-to-baseline gap to the next row; the last row (no following baseline to measure against) falls back to this page's own modal line spacing, the same already-justified estimateModalLineSpacing this module uses for paragraph/block clustering above. -function buildGridFromTextClustering(textItems: readonly LayoutText[], context: CellTypingContext): ReconstructedGrid { +function buildGridFromTextClustering(textItems: readonly LayoutText[], pageIndex: number, context: CellTypingContext): ReconstructedGrid { const lines = clusterIntoLines(textItems); if (lines.length === 0) { return { cells: [], columns: [], rows: [], gridlines: false }; @@ -914,7 +1019,7 @@ function buildGridFromTextClustering(textItems: readonly LayoutText[], context: return { index: j, widthPt: nextPosition !== undefined ? nextPosition - position : lastColumnWidthPt(groups, j, position) }; }); - return { cells: buildCellsFromGroups(groups, context), columns, rows, gridlines: false }; + return { cells: buildCellsFromGroups(groups, pageIndex, context), columns, rows, gridlines: false }; } // --- Orchestration: one ContentSheet per PDF page ----------------------------------------------------------- @@ -924,7 +1029,7 @@ function reconstructSheet(page: LayoutPage, pageIndex: number, sink: CellTypeInf const textItems = page.items.filter((i): i is LayoutText => i.kind === 'text'); const lattice = detectGridLattice(page.items); const context: CellTypingContext = { sheetIndex: pageIndex, sink }; - const grid = lattice !== undefined ? buildGridFromLattice(textItems, lattice, context) : buildGridFromTextClustering(textItems, context); + const grid = lattice !== undefined ? buildGridFromLattice(textItems, lattice, pageIndex, context) : buildGridFromTextClustering(textItems, pageIndex, context); // Margins have no PDF equivalent to recover, mirroring buildSection's own ZERO_MARGINS reasoning above. gridlines reflects whichever detection path actually ran; headers is always false -- a header-gutter row-number/column-letter label has no reliable geometric signal distinguishing it from an ordinary short cell, so this makes no attempt to detect one (any such label sitting outside the detected grid lattice is simply dropped by findRowIndex/findColumnIndex returning undefined for it, rather than being misread as real cell content). No print range/scale/fit-to-page/repeat-rows/repeat-columns/manual-breaks assumption is made at all -- a rendered page carries no trace of print INTENT, only what was visually printed. const printSettings: ContentSheetPrintSettings = {