Skip to content

feat!: promote DocumentPackage to the tree form at the package boundary - #634

Merged
Mearman merged 14 commits into
mainfrom
feat/tree-package-boundary
Aug 18, 2026
Merged

feat!: promote DocumentPackage to the tree form at the package boundary#634
Mearman merged 14 commits into
mainfrom
feat/tree-package-boundary

Conversation

@Mearman

@Mearman Mearman commented Aug 18, 2026

Copy link
Copy Markdown
Member

Closes #623.

The keystone of the DocumentPackage promotion: schema 4.0.0 defines the tree, and this PR makes it real at this package's boundary. One helper, assemblePackage(content, pages?) = decompose then factorStyles, behind every construction site; flattenPackage at every consumer; the readers and builders keep producing and consuming the flat ContentDocument unchanged.

Port — decompose/flatten brought in from document-outline.js's phase-1 reference (its pre-re-charter history) onto schema 4's own tree vocabulary. The one forced adaptation beyond types: the reference decomposed a 3.x package's content field and rebuilt envelopes through a separate helper; schema 4's DocumentPackage is the tree, so decompose takes the flat content directly and the envelope rides the root. Ownership discipline intact — decompose wraps, never copies, so layout-stamped frames are the same objects in both encodings.

MintingfactorStyles walks the fresh tree outermost-first and hoists property tuples occurring two or more times onto a group-wrapper ref plus a styles entry. Exactness (law ii) rests on two rules forced by gap-fill-never-overwrite resolution: a tuple's keys must be carried by every paragraph/run of the wrapper's whole extent, and an ancestor's minted keys are frozen below it. list never factors (a grouping signal the tree must keep stating); frames/sourcePath/styleId are per-node facts the schema's strict entries refuse outright. Deterministic s1, s2, ... ordering; idempotent by construction.

Sites — the composition engine's three executors plus convert.ts's special cases (odfToPdf, odbReportToPdf, odbToXlsx, odbToCsv) all emit the tree, and every site fires onDocument after the output bytes exist (executeBridge historically fired before the build — reordered).

ConsumebuildDocumentBytes, layoutDocumentFromPackage, and lintMathCoherence flatten once at the boundary; contractVersion 6 to 7 records that ConversionResult.package changed type.

Demotion — the layout-importing files flip to pdf-codec's item-family exports; LayoutMetadata/LayoutFont/LayoutFrame stay schema-sourced.

The merge gate — the three laws re-run over this repo's real corpus (readers per format, editor builds, xlsx via ooxml.js, csv/svg text, odf formulas standalone and embedded, .odb extraction and report, multi-section concatenation, reconstruction captures, onDocument captures with real frames/pages): 137 tests green, including an assertion that minting genuinely fires somewhere so the gate cannot pass vacuously.

Also handles schema 4's now-optional ContentListMembership.numId (layout markers, docx numbering synthesis) and regenerates the examples as per-variant tree goldens (the pdf-codec LayoutDocument golden is dropped with the demotion — no schema-stamped envelope to validate against).

Bumps document-schema.js to 4.0.0 together with the schema-4 siblings (ooxml.js 3.0.0, odf.js 4.0.0, markdown-codec 3.0.0) — dual schema copies in the pnpm tree otherwise fail typecheck.

Generated by Claude Code

Mearman added 12 commits August 18, 2026 13:19
document-schema.js 4.0.0 retires the flat { formatVersion, content, pages }
DocumentPackage envelope for the tree form, drops the per-arm ContentDocument
formatVersion literal, and moves the Layout* item family to pdf-codec; ooxml.js
3.0.0, odf.js 4.0.0, and markdown-codec 3.0.0 are their schema-4 migrations.
All four bump together: a schema-4 sibling beside a schema-3 one leaves two
copies of document-schema.js in the pnpm tree, whose nominally-unrelated
ContentDocument types fail typecheck.
Schema 4.0.0 made ContentDocument a purely in-process codec-exchange type:
versioning lives only at the serialised package boundary, in the release-pinned
$schema URI documentPackageWithSchema stamps. Every reader wrapper, variant
bridge, and fixture builder that constructed a content literal with
CONTENT_FORMAT_VERSION drops the field; nothing about the content model itself
changed.

BREAKING CHANGE: CONTENT_FORMAT_VERSION no longer exists and ContentDocument
arms carry no formatVersion field.
Schema 4.0.0 demoted LayoutDocument and the whole Layout* item family to
pdf-codec: a layout document is that codec's own read/write artefact, not a
shared pivot, so the layout engines, reconstructors, PDF editors, the metadata
write path, and the codec registry take the item types, LAYOUT_FORMAT_VERSION,
and LayoutDocumentSchema from pdf-codec's barrel. LayoutMetadata, LayoutFont,
and LayoutFrame stay schema-sourced -- the content model itself references
them. The registry states its two-function layout entry as a local structural
type, the shape document-schema.js's retired LayoutCodec port used to give it.

BREAKING CHANGE: LAYOUT_FORMAT_VERSION and the Layout* item types re-export
from pdf-codec; layoutDocumentWithSchema and LayoutDocumentJson no longer exist.
…mbering

ContentListMembership.numId became optional at schema 4.0.0 -- OOXML drawing
paragraphs carry only a level, and a de-numIded bridge product is legal
content. The wordprocessing layout engine's marker pass treats an absent numId
as carrying no ordering information (only markdown-codec's minted md{n}: ids
ever name an ordered list, so the bullet cycle is the only honest marker). The
docx numbering pre-pass keys its map by numId-or-absence so every membership
lands on a numbering definition, with all no-numId paragraphs sharing one w:num
-- exactly right for a synthesiser whose every level is the same bullet
template anyway -- and DocxParagraph.list now refuses a numId-less membership
loudly, naming the pre-pass that mints one, rather than writing a w:numPr that
references nothing.

The engine's import line also picks up the pdf-codec Layout flip this change
builds on.
The lossless tree <-> flat pair, ported from document-outline.js's phase-1
reference implementation (that repo's pre-re-charter history) onto schema 4's
own tree vocabulary: decompose takes the flat ContentDocument and embeds its
node objects into per-container groups -- mandatory section groups carrying
pre-layout geometry, one shape group per shape so a slide's paragraphs never
flatten across its boundaries, a sheet's grid riding its node while children
carry images and embedded objects, embedded documents staying whole leaves.
flattenPackage is the exact inverse, with style-ref resolution fused into the
walk because the flat codec-exchange form is always fully materialised.

The one forced adaptation beyond types: the reference decomposed a 3.x
package's content field and rebuilt envelopes through a separate helper, while
schema 4's DocumentPackage IS the tree, so decompose takes the content directly
and the envelope rides the package root assemblePackage builds. canonicalise is
the shared canonical-key recipe (stableContentHash's own, ported so tuple
identity and the bijection comparator cannot drift from it).

Ownership discipline unchanged from the reference: decompose wraps, it never
copies, so the frames a layout pass stamped onto content are the same objects
in both encodings; flatten without a styles table returns those same objects.
document-schema.js#21's factoring pass, which the schema deliberately leaves to
documents.js: assemblePackage = decompose then factorStyles, separate passes so
minting idempotence stays independently testable. The mint walks the fresh tree
outermost-first and hoists property tuples occurring two or more times onto a
group-wrapper ref plus a styles-table entry -- pure compression over the one
tree a conversion just built, never a second authority for content.

Exactness (the promotion's law ii) rests on two rules, both consequences of the
resolution helpers being gap-fill-never-overwrite: a minted tuple's keys must
be carried by every paragraph and every extent run of the wrapper's whole
subtree extent, and a key already minted by an ancestor entry is frozen for
everything below it so a nested re-mint can never shadow the value the
ancestor's ref restores. Mintable keys are the schema's own style halves minus
list -- membership is a grouping signal the tree itself must keep stating, and
frames/sourcePath/styleId are per-node facts the schema's strict entries refuse
outright. Ids are s1, s2, ... in descending-frequency, first-occurrence,
canonical-tuple order, so the pass is deterministic; factorStyles flattens its
input first, which makes re-factoring mint the identical table by construction.
One helper, all sites: assemblePackage(content, pages?) = decompose then
factorStyles, called wherever a conversion used to hand-construct the flat
{ formatVersion, content, pages } envelope. The composition engine's three
executors (bridge, toPdf, fromPdf) and convert.ts's special cases (odfToPdf,
odbReportToPdf, odbToXlsx, odbToCsv) all emit the tree now, and every site
fires onDocument AFTER the output bytes are built -- executeBridge
historically fired before the build and is reordered, so a callback that
inspects the tree can never observe a half-built conversion. The codecs'
readers and builders keep producing and consuming the flat ContentDocument
unchanged; the tree exists only at the boundary.

index.ts exports the boundary itself (assemblePackage, decompose,
flattenPackage, factorStyles, PackageChildren) beside the pdf-codec item-family
re-exports, and drops the retired CONTENT_FORMAT_VERSION /
layoutDocumentWithSchema / LayoutDocumentJson surface.

BREAKING CHANGE: onDocument and ConversionResult.package carry the tree-form
DocumentPackage of schema 4.0.0 -- children hold the decomposed groups and the
minted styles table instead of the flat content envelope.
Every package consumer flattens once and keeps working over the flat form:
buildDocumentBytes feeds content.write the materialised ContentDocument,
layoutDocumentFromPackage walks the flattened content (one tree-to-flat
authority, and the frames walk stays the flat-document walk it always was --
the deliberate one-implementation choice, documented on the function), and
lintMathCoherence flattens at entry while keeping its exported DocumentPackage
signature so callers hand back exactly what onDocument gave them.

BREAKING CHANGE: a tree-form package is the only accepted input shape.
ConversionResult.package changed type with the tree promotion, which is
exactly what the version-history comment on contractVersion exists to record:
a consumer reading the field must flatten (documents.js exports
flattenPackage) or walk the tree, where the flat envelope it used to read is
gone. The supported-conversions table is unchanged -- the port's pairs were
always derived from the composition pathfinder, whose routes did not move.
The promotion's merge gate: the three laws re-run over this repo's actual
output -- reader outputs for every format, editor builds encoded and read back,
xlsx through ooxml.js's own pair, csv and svg text, standalone and
sheet-embedded formulas, an .odb table extraction, a rendered .odb report, a
multi-section concatenation, reconstruction captures, and onDocument captures
from every conversion family carrying real frames and pages. Law (i) asserts
flatten(assemble(c)) reproduces c up to the declared empty-embeddedObjects
normalisation, law (ii) asserts the materialised flat form is ref-free and
structurally identical to the source, law (iii) asserts reassembly and
re-factoring mint identical trees -- each against a structuredClone snapshot
taken before the round trip, never an identity assertion, because decompose
shares node objects by design. A closing assertion pins that the corpus really
exercises minting, so the gate cannot pass vacuously.
…ldens

One committed package golden per variant now -- wordprocessing, presentation,
spreadsheet, drawing, and formula -- each captured from that variant's own
to-pdf conversion so the committed tree carries real frames, real pages, and a
real schema-valid shape; layout-document.json is dropped because the demoted
pdf-codec LayoutDocument has no $schema-stamped JSON envelope to validate a
golden against. The README's intermediate-package section states the tree, the
three laws with their merge-gate suite, and the assemblePackage boundary, and
the pivot/architecture/references wording follows the demotion (the shared
model is ContentDocument; LayoutDocument is pdf-codec's view).
The spreadsheet layout engine's fixtures follow the same content-literal drop
as every other test file, with the Layout item types the engine imports picked
up from pdf-codec alongside it.
@github-code-quality

github-code-quality Bot commented Aug 18, 2026

Copy link
Copy Markdown

Code Coverage Overview

Languages: TypeScript

TypeScript / unit

The overall coverage in commit 8ccb27b in the feat/tree-package-bo... branch remains at 91%, unchanged from commit 4b2cffb in the main branch.

Show a code coverage summary of the most impacted files.
File main 4b2cffb feat/tree-package-bo... 8ccb27b +/-
src/svg/read.ts 89% 89% 0%
src/edit/docx/paragraph.ts 73% 73% 0%
src/convert/composition.ts 93% 93% 0%
src/layout/engine.ts 93% 93% 0%
src/convert/convert.ts 93% 94% +1%
src/convert/flatten.ts 0% 97% +97%
src/convert/factor-styles.ts 0% 98% +98%
src/convert/decompose.ts 0% 98% +98%
src/convert/canonicalise.ts 0% 100% +100%

Updated August 18, 2026 12:38 UTC

… list

The dist-parity smoke check still asserted the retired schema-io helper; the
boundary functions that replaced the export's neighbours in the public surface
(assemblePackage, decompose, flattenPackage, factorStyles) join the list so the
built artifact keeps proving both builds carry the package boundary.
The live-view gotcha predates the tree promotion and still spoke of mutating
a content field the package no longer carries; the tree's own content nodes
are what a post-layout mutation would touch now.
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Run decompose at the package boundary and flatten at the builders (DocumentPackage tree promotion)

1 participant