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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/documents.js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ const pdfBytes = docxToPdf(docxBytes, {

The tree and the flat `ContentDocument` are one format in two encodings, related by three laws (stated on [document-schema.js#20](https://github.com/ExaDev/document-schema.js/issues/20), proven over this package's real corpus by the bijection suite in `src/convert/bijection.test.ts`): (i) `flattenTree(assembleTree(c))` reproduces `c` exactly, up to one declared normalisation (a present-but-empty sheet `embeddedObjects` array normalises to the field absent); (ii) effective-property equality holds universally — a factored and an unfactored serialisation of one document resolve to the same properties; (iii) minting is idempotent — factoring a second time produces the identical styles table.

Three flat-form signals drive the grouping, and all three are reproduced exactly on the way back: `headingLevel`, `list.level`, and — since document-schema.js 4.2.0 — the `constructStart`/`constructEnd` block pair that delimits a fidelity construct (a docx SDT, an ODF field, a tracked-change span, a bookmark, a hyperlink region, a division). `decompose` promotes each marker pair to a construct group carrying the `ConstructDescriptor` and holding the delimited region as its children, decomposed on its own; `flattenTree` writes the pair back around that region. A construct is a semantic wrapper rather than a container, so it neither disturbs the enclosing heading/list nesting it sits inside nor resets the style chain resolving onto it — content inside a construct still inherits the ambient heading's or section's factored properties, exactly as if the construct were not there. Markers must pair up within one container's block flow: an unmatched `constructEnd`, or a `constructStart` a container never closes, throws document-schema.js's `ConstructMarkerImbalanceError` (carrying its `ConstructMarkerImbalance` payload, so the offending block index is available without parsing the message) rather than being repaired into a plausible tree. The format codecs emit and consume these markers on both sides today — ooxml.js's docx pair (SDTs, bookmarks, tracked changes, block-scoped fields), odf.js's odt reader for divisions, index wrappers, and cross-paragraph bookmarks with its writer covering the construct kinds it models and refusing the rest by name, markdown-codec, rtf-codec, and epub-codec in both directions, and this package's own PDF reconstruction emitting division and anchor pairs — so the marker machinery has real producers and consumers, not just the boundary transform. Reaching further than the codecs is still a mixed picture, not a blanket guarantee: `buildMarkdownText` passes markers through to markdown-codec's own bracket-resolving writer, which renders each construct it has a markdown spelling for (a footnote definition, a blockquote division, a titled image's link wrapper) and renders the rest transparently with a diagnostic — markdown-codec's own read side emits those pairs, so this package's editor and conversion round trips depend on it; building docx bytes from marker-carrying flat content writes a bookmark anchor's pair as real `w:bookmarkStart`/`w:bookmarkEnd` around the blocks it spans and a content control's pair as a real `w:sdt` region (the descriptor driving `w:sdtPr`, the blocks between the markers landing inside its own `w:sdtContent`), and the odt builder does the bookmark pair through `text:bookmark-start`/`-end` and a division's pair as a real `text:section` region the between-blocks land inside — so the one wrapper kind each format natively spells at body scope now survives the build, round-tripping through its own reader as the identical construct pair — while the remaining kinds (a tracked change's region machinery, an ODF index wrapper's generated-content model) are dropped rather than written, since neither editor model has a surface for them; and the layout engines (`convertWordprocessingToLayout`, `convertShape`) silently skip a marker block during pagination — harmless there, since a marker carries no content of its own to render.
Three flat-form signals drive the grouping, and all three are reproduced exactly on the way back: `headingLevel`, `list.level`, and — since document-schema.js 4.2.0 — the `constructStart`/`constructEnd` block pair that delimits a fidelity construct (a docx SDT, an ODF field, a tracked-change span, a bookmark, a hyperlink region, a division). `decompose` promotes each marker pair to a construct group carrying the `ConstructDescriptor` and holding the delimited region as its children, decomposed on its own; `flattenTree` writes the pair back around that region. A construct is a semantic wrapper rather than a container, so it neither disturbs the enclosing heading/list nesting it sits inside nor resets the style chain resolving onto it — content inside a construct still inherits the ambient heading's or section's factored properties, exactly as if the construct were not there. Markers must pair up within one container's block flow: an unmatched `constructEnd`, or a `constructStart` a container never closes, throws document-schema.js's `ConstructMarkerImbalanceError` (carrying its `ConstructMarkerImbalance` payload, so the offending block index is available without parsing the message) rather than being repaired into a plausible tree. The format codecs emit and consume these markers on both sides today — ooxml.js's docx pair (SDTs, bookmarks, tracked changes, block-scoped fields), odf.js's odt reader for divisions, index wrappers, and cross-paragraph bookmarks with its writer covering the construct kinds it models and refusing the rest by name, markdown-codec, rtf-codec, and epub-codec in both directions, and this package's own PDF reconstruction emitting division and anchor pairs — so the marker machinery has real producers and consumers, not just the boundary transform. Reaching further than the codecs is still a mixed picture, not a blanket guarantee: `buildMarkdownText` passes markers through to markdown-codec's own bracket-resolving writer, which renders each construct it has a markdown spelling for (a footnote definition, a blockquote division, a titled image's link wrapper) and renders the rest transparently with a diagnostic — markdown-codec's own read side emits those pairs, so this package's editor and conversion round trips depend on it; building docx bytes from marker-carrying flat content writes a bookmark anchor's pair as real `w:bookmarkStart`/`w:bookmarkEnd` around the blocks it spans, a content control's pair as a real `w:sdt` region (the descriptor driving `w:sdtPr`, the blocks between the markers landing inside its own `w:sdtContent`), and a tracked change's pair as a real `w:ins`/`w:del`/`w:moveFrom`/`w:moveTo` region whose author and date ride the element and whose deletion interiors spell their runs `w:delText` — and the odt builder does the bookmark pair through `text:bookmark-start`/`-end`, a division's pair as a real `text:section` region the between-blocks land inside, and an index contentControl's pair as a real index wrapper region (`text:table-of-content` or one of its six siblings, named by the descriptor's `*-source` residue) whose cached blocks land inside its own `text:index-body` — so every wrapper kind each format natively spells at body scope survives the build, round-tripping through its own reader as the identical construct pair. A `formatChange` pair stays dropped on the docx side (its Word spellings are property-layer, with no block-level element to open) and a residue-less index descriptor stays dropped on the odt side (nothing names which of the seven wrappers to write); and the layout engines (`convertWordprocessingToLayout`, `convertShape`) silently skip a marker block during pagination — harmless there, since a marker carries no content of its own to render.

`assembleTree` is the one constructor behind every construction site — decompose, then `factorStyles`, the minting pass that hoists property tuples occurring two or more times onto a group-wrapper ref plus a `styles` table entry (deterministic order; `frames`/`sourcePath`/`styleId` are per-node facts and never factor). The transform belongs to `document-schema.js`, which owns both encodings and publishes `assembleTree`, `decompose`, `flattenTree`, `factorStyles`, `ConstructMarkerImbalanceError`, and the `TreeChildren` type for any caller composing its own boundary — import them from there, not from this package. documents.js consumes that transform at its own boundary and re-exports none of it; the readers, builders, layout engines, and editors here keep producing and consuming the flat form, so the tree exists only where a `DocumentTree` is constructed or consumed:

Expand Down
127 changes: 127 additions & 0 deletions packages/documents.js/src/edit/docx/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,133 @@ describe("buildDocxPackage", () => {
expect(insideText).not.toContain("outside");
});

it("round-trips a tracked-change construct pair through a real w:del region, with delText spelling", () => {
const content = wordDoc([
{
pageSize: { widthPt: 612, heightPt: 792 },
margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 },
blocks: [
{
kind: "constructStart",
descriptor: {
kind: "provenance",
change: "deletion",
author: "A. N. Author",
dateIso: "2026-09-10T10:00:00Z",
},
},
{ kind: "paragraph", runs: [{ text: "gone in this revision" }] },
{ kind: "constructEnd" },
{ kind: "paragraph", runs: [{ text: "still here" }] },
],
},
]);
const rereadDoc = readDocxContent(buildDocxPackage(content));
if (rereadDoc.kind !== "wordprocessing") {
throw new Error("expected a wordprocessing ContentDocument");
}
const marker = rereadDoc.sections[0]!.blocks[0];
if (marker?.kind !== "constructStart") {
throw new Error("expected the first block to be the construct marker");
}
expect(marker.descriptor).toEqual({
kind: "provenance",
change: "deletion",
author: "A. N. Author",
dateIso: "2026-09-10T10:00:00Z",
});

// The deleted paragraph is genuinely inside the w:del, its runs spell w:delText, and the live paragraph stays outside with plain w:t.
const pkg = buildDocxPackage(content);
const documentRoot = rootElement(pkg.parts["word/document.xml"]);
if (documentRoot === undefined) {
throw new Error("expected a word/document.xml root element");
}
const dels = descendants(documentRoot, "w:del");
expect(dels).toHaveLength(1);
expect(textContent(dels[0]!)).toContain("gone in this revision");
expect(
dels[0]!.children.some((c) => c.type === "element" && c.tag === "w:p"),
).toBe(true);
expect(descendants(dels[0]!, "w:delText")).toHaveLength(1);
expect(descendants(dels[0]!, "w:t")).toHaveLength(0);
const liveTexts = descendants(documentRoot, "w:t");
expect(liveTexts.some((t) => textContent(t) === "still here")).toBe(true);
});

it("round-trips an insertion region as w:ins with author and date", () => {
const content = wordDoc([
{
pageSize: { widthPt: 612, heightPt: 792 },
margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 },
blocks: [
{
kind: "constructStart",
descriptor: { kind: "provenance", change: "insertion" },
},
{ kind: "paragraph", runs: [{ text: "newly added" }] },
{ kind: "constructEnd" },
],
},
]);
const rereadDoc = readDocxContent(buildDocxPackage(content));
if (rereadDoc.kind !== "wordprocessing") {
throw new Error("expected a wordprocessing ContentDocument");
}
const marker = rereadDoc.sections[0]!.blocks[0];
if (marker?.kind !== "constructStart") {
throw new Error("expected the first block to be the construct marker");
}
// An author/date-free insertion reads back with exactly the fields the source stated -- no invented author, no minted date.
expect(marker.descriptor).toEqual({
kind: "provenance",
change: "insertion",
});
const pkg = buildDocxPackage(content);
const documentRoot = rootElement(pkg.parts["word/document.xml"]);
if (documentRoot === undefined) {
throw new Error("expected a word/document.xml root element");
}
const insElements = descendants(documentRoot, "w:ins");
expect(insElements).toHaveLength(1);
expect(textContent(insElements[0]!)).toContain("newly added");
// An insertion's runs stay plain w:t -- only deletions and move-froms re-spell.
expect(descendants(insElements[0]!, "w:t")).toHaveLength(1);
expect(descendants(insElements[0]!, "w:delText")).toHaveLength(0);
});

it("drops a formatChange pair by name rather than half-writing it", () => {
// formatChange has no block-level element (its Word spellings are property-layer w:rPrChange/w:pPrChange), so the pair restores nothing on read and the written document carries no wrapper for it.
const content = wordDoc([
{
pageSize: { widthPt: 612, heightPt: 792 },
margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 },
blocks: [
{
kind: "constructStart",
descriptor: { kind: "provenance", change: "formatChange" },
},
{ kind: "paragraph", runs: [{ text: "reformatted" }] },
{ kind: "constructEnd" },
],
},
]);
const pkg = buildDocxPackage(content);
const documentRoot = rootElement(pkg.parts["word/document.xml"]);
if (documentRoot === undefined) {
throw new Error("expected a word/document.xml root element");
}
for (const tag of ["w:ins", "w:del", "w:moveFrom", "w:moveTo"]) {
expect(descendants(documentRoot, tag)).toHaveLength(0);
}
// The paragraph itself still writes -- dropping the pair is not dropping the content it bracketed.
expect(
descendants(documentRoot, "w:t").some(
(t) => textContent(t) === "reformatted",
),
).toBe(true);
});

it("round-trips nested contentControl regions and a checkbox control's state", () => {
const content = wordDoc([
{
Expand Down
5 changes: 5 additions & 0 deletions packages/documents.js/src/edit/docx/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ class ConstructMarkerState {
body.openContentControlRegion(detail);
return;
}
if (detail.kind === "provenance" && body.openProvenanceRegion(detail)) {
// A tracked-change region: the blocks between the markers land inside the change's own w:ins/w:del/w:moveFrom/w:moveTo element, with the deletion spellings re-spelling their runs' text w:delText on close. formatChange falls through to the dropped stack below -- it has no block-level element to open.
this.open.push({ kind: "region" });
return;
}
// Every other construct kind is wrapper-shaped through machinery this builder has no editor surface for (a tracked-change w:ins/w:del region, an ODF division with no Word spelling at block scope) or carries no write path at all, and is dropped as the README's construct-marker note states -- stacked here so its own end marker still balances.
this.open.push({ kind: "dropped" });
}
Expand Down
Loading