diff --git a/packages/documents.js/README.md b/packages/documents.js/README.md index 7a399d7f9..6e1d89c47 100644 --- a/packages/documents.js/README.md +++ b/packages/documents.js/README.md @@ -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: diff --git a/packages/documents.js/src/edit/docx/content.test.ts b/packages/documents.js/src/edit/docx/content.test.ts index 15379b1d6..b1f6a2a90 100644 --- a/packages/documents.js/src/edit/docx/content.test.ts +++ b/packages/documents.js/src/edit/docx/content.test.ts @@ -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([ { diff --git a/packages/documents.js/src/edit/docx/content.ts b/packages/documents.js/src/edit/docx/content.ts index c2c07ea35..386274e9e 100644 --- a/packages/documents.js/src/edit/docx/content.ts +++ b/packages/documents.js/src/edit/docx/content.ts @@ -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" }); } diff --git a/packages/documents.js/src/edit/docx/editor.ts b/packages/documents.js/src/edit/docx/editor.ts index b93e8d50d..1753037c3 100644 --- a/packages/documents.js/src/edit/docx/editor.ts +++ b/packages/documents.js/src/edit/docx/editor.ts @@ -17,7 +17,10 @@ import { buildParagraph, DocxParagraph } from "./paragraph"; import type { ParagraphInit } from "./paragraph"; import { createEmptyDocxPackage } from "./scaffold"; import { buildTable, DocxTable } from "./table"; -import type { ContentControlDescriptor } from "document-schema.js"; +import type { + ContentControlDescriptor, + ProvenanceDescriptor, +} from "document-schema.js"; import type { TableInit } from "./table"; const DOCUMENT_PART_PATH = "word/document.xml"; @@ -33,6 +36,8 @@ export interface DocxBody { appendBookmarkEnd(id: number): void; // A content-control (SDT) region: every append between openContentControlRegion and closeRegion lands inside the control's own w:sdtContent rather than as a body sibling -- the block-flow spelling of an SDT, which Word itself writes as w:sdt > w:sdtPr + w:sdtContent around the content it governs. The descriptor drives w:sdtPr (w:id minted per document, w:tag/w:alias/w:lock, and the one type element each controlType maps to -- the exact inverse of ooxml.js's own reader, so a written control reads back as the same descriptor). Regions nest to arbitrary depth; the one field with no spelling here is columnCount-style geometry an SDT does not carry. openContentControlRegion(descriptor: ContentControlDescriptor): void; + // Opens a tracked-change region (w:ins/w:del/w:moveFrom/w:moveTo wrapping the blocks appended until the matching closeRegion). Answers false for a change kind with no block-level element (formatChange), which the caller treats as a refusal. + openProvenanceRegion(descriptor: ProvenanceDescriptor): boolean; closeRegion(): void; } @@ -72,9 +77,13 @@ function bodyElementIndicesByTag(body: XmlElement, tag: string): number[] { } class DocxBodyImpl implements DocxBody { - // The w:sdtContent elements of every content-control region currently open, innermost last. Appends target the innermost open region's own children (plain push -- a w:sdtContent has no w:sectPr to insert before) and fall back to the body's own insertion point when empty. - private readonly openRegions: XmlElement[] = []; + // Every open region's append target, innermost last: a content control's own w:sdtContent, or a tracked change's own w:ins/w:del/w:moveFrom/w:moveTo element (whose children are the wrapped blocks directly -- those elements have no separate content container). `deletion` names a region whose runs must spell their text w:delText rather than w:t when it closes, Word's own spelling for text inside a tracked deletion or move-from (ooxml.js's reader reads both identically, so the round trip holds either way; this is the real-consumer-correct spelling). Appends target the innermost open region's own children (plain push -- neither container has a w:sectPr to insert before) and fall back to the body's own insertion point when empty. + private readonly openRegions: { + container: XmlElement; + deletion: boolean; + }[] = []; private nextSdtId = 1; + private nextProvenanceId = 1; constructor( private readonly body: XmlElement, @@ -85,7 +94,7 @@ class DocxBodyImpl implements DocxBody { private appendToBody(element: XmlElement): void { const region = this.openRegions.at(-1); if (region !== undefined) { - region.children.push(element); + region.container.children.push(element); return; } this.body.children.splice(bodyInsertionPoint(this.body), 0, element); @@ -94,7 +103,8 @@ class DocxBodyImpl implements DocxBody { appendParagraph(init?: ParagraphInit): DocxParagraph { const paragraphElement = buildParagraph(init); this.appendToBody(paragraphElement); - const container = this.openRegions.at(-1)?.children ?? this.body.children; + const container = + this.openRegions.at(-1)?.container.children ?? this.body.children; return new DocxParagraph( container, paragraphElement, @@ -122,7 +132,8 @@ class DocxBodyImpl implements DocxBody { appendTable(init: TableInit): DocxTable { const tableElement = buildTable(init); this.appendToBody(tableElement); - const container = this.openRegions.at(-1)?.children ?? this.body.children; + const container = + this.openRegions.at(-1)?.container.children ?? this.body.children; return new DocxTable(container, tableElement); } @@ -233,11 +244,73 @@ class DocxBodyImpl implements DocxBody { } const sdtContent = el("w:sdtContent", {}, []); this.appendToBody(el("w:sdt", {}, [el("w:sdtPr", {}, sdtPr), sdtContent])); - this.openRegions.push(sdtContent); + this.openRegions.push({ container: sdtContent, deletion: false }); + } + + openProvenanceRegion(descriptor: ProvenanceDescriptor): boolean { + // The block-flow spelling of a tracked change: the w:ins/w:del/w:moveFrom/w:moveTo element wrapping the extent's own blocks, the exact inverse ooxml.js's own reader recovers (readProvenanceDescriptor reads w:author/w:date back; collectFlowNodes recurses into the element and records its construct extent). formatChange has no block-level element at all -- its Word spellings (w:rPrChange/w:pPrChange) are property-layer children of runs and paragraph properties, so a block region for one would have nothing to write through; the caller treats false as a refusal. + const tag = + descriptor.change === "insertion" + ? "w:ins" + : descriptor.change === "deletion" + ? "w:del" + : descriptor.change === "moveFrom" + ? "w:moveFrom" + : descriptor.change === "moveTo" + ? "w:moveTo" + : undefined; + if (tag === undefined) { + return false; + } + const attributes: { name: string; value: string }[] = [ + { name: "w:id", value: String(this.nextProvenanceId) }, + ]; + this.nextProvenanceId += 1; + if (descriptor.author !== undefined) { + attributes.push({ + name: "w:author", + value: encodeXmlText(descriptor.author), + }); + } + if (descriptor.dateIso !== undefined) { + attributes.push({ + name: "w:date", + value: encodeXmlText(descriptor.dateIso), + }); + } + const region: XmlElement = { + type: "element", + tag, + attributes, + children: [], + }; + this.appendToBody(region); + this.openRegions.push({ + container: region, + deletion: + descriptor.change === "deletion" || descriptor.change === "moveFrom", + }); + return true; } closeRegion(): void { - this.openRegions.pop(); + const entry = this.openRegions.pop(); + if (entry?.deletion === true) { + spellDeletedText(entry.container); + } + } +} + +// Rewrites every w:t under a closed deletion region's subtree to w:delText, the spelling Word itself gives text inside a tracked deletion or move-from -- ooxml.js's own reader reads the two identically (readRunText accepts both), so this is real-consumer correctness rather than a round-trip requirement. Recursive over the whole region: a deletion's interior is deleted content throughout (a nested construct inside a deletion rides the same deletion), which is also exactly how the reader's carryDeletions walk treats it. +function spellDeletedText(element: XmlElement): void { + for (const child of element.children) { + if (child.type !== "element") { + continue; + } + if (child.tag === "w:t") { + child.tag = "w:delText"; + } + spellDeletedText(child); } } diff --git a/packages/documents.js/src/edit/odt/content.test.ts b/packages/documents.js/src/edit/odt/content.test.ts index 919b55114..d123a3adb 100644 --- a/packages/documents.js/src/edit/odt/content.test.ts +++ b/packages/documents.js/src/edit/odt/content.test.ts @@ -72,6 +72,133 @@ function readFlowVectors(pkg: Package): ContentVector[] { } describe("buildOdtPackage", () => { + it("round-trips an index contentControl pair through a real text:table-of-content region", () => { + // The descriptor's *-source residue is what names the wrapper (odfIndexWrapperTag's own rule): a TOC descriptor carries the serialised source element, exactly as the odt reader quarantines it. + const content = wordDoc([ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 }, + blocks: [ + { + kind: "constructStart", + descriptor: { + kind: "contentControl", + controlType: "index", + tag: "Table of Contents", + source: { + format: "odt", + xml: '', + }, + }, + }, + { kind: "paragraph", runs: [{ text: "cached entry" }] }, + { kind: "constructEnd" }, + { kind: "paragraph", runs: [{ text: "outside" }] }, + ], + }, + ]); + const rereadDoc = readOdtContent(buildOdtPackage(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"); + } + // The reread descriptor's own *-source residue is the BARE element this writer emits (the residue policy every odf writer follows), so compare everything except the residue's inner spelling: kind/controlType/tag recover exactly. + expect(marker.descriptor).toMatchObject({ + kind: "contentControl", + controlType: "index", + tag: "Table of Contents", + }); + if (marker.descriptor.kind !== "contentControl") { + throw new Error("expected a contentControl descriptor"); + } + expect(marker.descriptor.source?.format).toBe("odt"); + + // The wrapper is a real text:table-of-content whose index-body holds the cached entry, with the bare *-source child the ODF schema requires. + const pkg = buildOdtPackage(content); + const contentXml = pkg.parts["content.xml"]; + if (contentXml?.kind !== "xml") { + throw new Error("expected a content.xml part"); + } + const wrappers: XmlElement[] = []; + const walk = (nodes: readonly XmlNode[]): void => { + for (const node of nodes) { + if (node.type === "element") { + if (node.tag === "text:table-of-content") { + wrappers.push(node); + } + walk(node.children); + } + } + }; + walk(contentXml.nodes); + expect(wrappers).toHaveLength(1); + const wrapper = wrappers[0]!; + expect( + wrapper.children.some( + (child) => + child.type === "element" && + child.tag === "text:table-of-content-source", + ), + ).toBe(true); + const body = wrapper.children.find( + (child): child is XmlElement => + child.type === "element" && child.tag === "text:index-body", + ); + if (body === undefined) { + throw new Error("expected the wrapper to carry a text:index-body"); + } + const textOf = (nodes: readonly XmlNode[]): string => + nodes + .map((node) => + node.type === "text" + ? node.value + : node.type === "element" + ? textOf(node.children) + : "", + ) + .join(""); + expect(textOf(body.children)).toContain("cached entry"); + expect(textOf(body.children)).not.toContain("outside"); + }); + + it("drops a residue-less index contentControl pair by name rather than guessing a wrapper", () => { + // No *-source residue means no fact naming which of the seven wrappers to write -- the pair restores nothing, and the bracketed content still writes. + const content = wordDoc([ + { + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 }, + blocks: [ + { + kind: "constructStart", + descriptor: { kind: "contentControl", controlType: "index" }, + }, + { kind: "paragraph", runs: [{ text: "still written" }] }, + { kind: "constructEnd" }, + ], + }, + ]); + const pkg = buildOdtPackage(content); + const contentXml = pkg.parts["content.xml"]; + if (contentXml?.kind !== "xml") { + throw new Error("expected a content.xml part"); + } + const wrapperTags = [ + "text:table-of-content", + "text:alphabetical-index", + "text:bibliography", + ]; + const walk = (nodes: readonly XmlNode[]): boolean => + nodes.some( + (node) => + (node.type === "element" && wrapperTags.includes(node.tag)) || + (node.type === "element" && walk(node.children)), + ); + expect(walk(contentXml.nodes)).toBe(false); + }); + it("round-trips a division construct pair through a real text:section region", () => { const content = wordDoc([ { diff --git a/packages/documents.js/src/edit/odt/content.ts b/packages/documents.js/src/edit/odt/content.ts index cc947b819..fe5249d47 100644 --- a/packages/documents.js/src/edit/odt/content.ts +++ b/packages/documents.js/src/edit/odt/content.ts @@ -375,6 +375,13 @@ class ConstructMarkerState { body.openDivisionRegion(detail); return; } + if (detail.kind === "contentControl" && detail.controlType === "index") { + // An index-wrapper region (text:table-of-content or a sibling): only when the descriptor's *-source residue names which of the seven wrappers to write -- a residue-less index descriptor (e.g. one built by hand, or the docx TOC-gallery spelling) carries no such fact and stays dropped below. + if (body.openIndexRegion(detail)) { + this.open.push({ kind: "region" }); + return; + } + } // Every other construct kind has no editor-surface spelling here (a field or note anchor that is not block-scoped in this builder's model, a tracked-change range, a wrapper kind ODF spells through machinery this builder does not carry) and is dropped as the README's construct-marker note states -- stacked so its own end marker still balances. this.open.push({ kind: "dropped" }); } diff --git a/packages/documents.js/src/edit/odt/editor.ts b/packages/documents.js/src/edit/odt/editor.ts index b645128cc..1ea04c5b8 100644 --- a/packages/documents.js/src/edit/odt/editor.ts +++ b/packages/documents.js/src/edit/odt/editor.ts @@ -8,10 +8,15 @@ import type { Package, XmlElement } from "odf.js"; import { decodePackage, encodePackage, + odfIndexWrapperTag, readOdfMetadata, writeEmbeddedObject, } from "odf.js"; -import type { Box, DivisionDescriptor } from "document-schema.js"; +import type { + Box, + ContentControlDescriptor, + DivisionDescriptor, +} from "document-schema.js"; import { patchOdfMetadataOnPackage } from "../../metadata/core-patch"; import { resolveMetadataTimestamps } from "../../model/metadata"; import { encodeXmlText } from "../../xml/entities"; @@ -41,6 +46,8 @@ export interface OdtBody { appendBookmarkEnd(name: string): void; // A division region: every append between openDivisionRegion and closeRegion lands inside the division's own text:section element -- the block-flow spelling of an ODF division, which LibreOffice itself writes as text:section around the content it groups. The descriptor drives the section's own attributes (text:name, text:protected, and a trailing text:section-source for a linked chapter), mirroring odf.js's own typed writer; the one field not carried is columnCount, which needs the section-style interning that writer's style machinery performs and this editor surface does not have. Regions nest to arbitrary depth. openDivisionRegion(descriptor: DivisionDescriptor): void; + // An index-wrapper region (text:table-of-content or one of its six siblings around the extent's own text:index-body). Answers false when the descriptor's *-source residue names no recognisable wrapper -- there is then no fact saying which of the seven to write. + openIndexRegion(descriptor: ContentControlDescriptor): boolean; closeRegion(): void; } @@ -79,8 +86,8 @@ function findOfficeText(contentRoot: XmlElement): XmlElement { class OdtBodyImpl implements OdtBody { // The text:section elements of every division region currently open, innermost last, each with its still-unwritten text:section-source (a linked section's source element follows the section's children in the ODF schema, so it is spliced on at close). Appends target the innermost open section's own children and fall back to office:text when none is open. - private readonly openDivisions: { - section: XmlElement; + private readonly openRegions: { + container: XmlElement; linked: DivisionDescriptor["linked"]; }[] = []; @@ -91,7 +98,7 @@ class OdtBodyImpl implements OdtBody { private containerChildren(): XmlElement["children"] { return ( - this.openDivisions.at(-1)?.section.children ?? this.officeText.children + this.openRegions.at(-1)?.container.children ?? this.officeText.children ); } @@ -203,11 +210,46 @@ class OdtBodyImpl implements OdtBody { children: [], }; this.containerChildren().push(section); - this.openDivisions.push({ section, linked: descriptor.linked }); + this.openRegions.push({ container: section, linked: descriptor.linked }); + } + + openIndexRegion(descriptor: ContentControlDescriptor): boolean { + // An index wrapper region: the blocks between the markers land inside the wrapper's own text:index-body, mirroring odf.js's writeOdfIndexWrapper element for element (text:name when the descriptor carries a tag, a BARE *-source child the ODF schema requires every real wrapper to carry, and the index-body the appends target). The wrapper TAG itself -- which of the seven ODF index wrappers this is -- is recoverable only from the descriptor's *-source residue (odfIndexWrapperTag's own rule), so a descriptor without one has no fact naming the wrapper and answers false, which the caller treats as a refusal. + let tag: string; + try { + tag = odfIndexWrapperTag(descriptor); + } catch { + return false; + } + const attributes: { name: string; value: string }[] = []; + if (descriptor.tag !== undefined) { + attributes.push({ + name: "text:name", + value: encodeXmlText(descriptor.tag), + }); + } + const indexBody: XmlElement = { + type: "element", + tag: "text:index-body", + attributes: [], + children: [], + }; + const wrapper: XmlElement = { + type: "element", + tag, + attributes, + children: [ + { type: "element", tag: `${tag}-source`, attributes: [], children: [] }, + indexBody, + ], + }; + this.containerChildren().push(wrapper); + this.openRegions.push({ container: indexBody, linked: undefined }); + return true; } closeRegion(): void { - const entry = this.openDivisions.pop(); + const entry = this.openRegions.pop(); if (entry === undefined) { return; } @@ -223,7 +265,7 @@ class OdtBodyImpl implements OdtBody { value: encodeXmlText(entry.linked.sectionName), }); } - entry.section.children.push({ + entry.container.children.push({ type: "element", tag: "text:section-source", attributes: sourceAttributes, diff --git a/packages/odf.js/README.md b/packages/odf.js/README.md index 84788d8c2..41b79c7f4 100644 --- a/packages/odf.js/README.md +++ b/packages/odf.js/README.md @@ -68,7 +68,7 @@ Under active development. Built and shipped: - **OpenOffice.org 1.x writing** (`writeSxw`/`writeSxwContent`, `writeSxc`/`writeSxcContent`, `writeSxi`/`writeSxiContent`, and `writeSxd`/`writeSxdContent`, plus `transformToOoo1Package`, the read-side transform's own inverse) — `.sxw`, built on `writeOdt`/`writeOdtContent`; `.sxc`, built on `writeOds`/`writeOdsContent`; `.sxi`, built on `writeOdp`/`writeOdpContent`; `.sxd`, built on `writeOdg`/`writeOdgContent`, through one wrapper pair each rather than a second writer. Every genuine document format this package reads on the OpenOffice.org 1.x side it now writes as well, including the `.stw`/`.stc`/`.sti`/`.std` template variants of all four (a `template: true` write option) — see [Reading and writing an OpenOffice.org 1.x document](#reading-and-writing-an-openofficeorg-1x-document). - **The odt writer, at the same two levels** — `writeOdt` takes the `DocumentTree` `readOdt` returns and `writeOdtContent` the flat `ContentDocument` `readOdtContent` returns, and both produce a real `.odt` `Package` (`encodePackage` turns it into bytes). Paragraphs, headings, runs with character formatting and hyperlinks, whitespace, lists, tables, images, explicit page breaks, per-section page geometry, and `meta.xml` all round-trip. A field and a bookmark anchor (entirely within one paragraph) and a division/index wrapper (bracketing whole blocks) round-trip too ([ExaDev/documents.js#969](https://github.com/ExaDev/documents.js/issues/969)); a note/annotation/tracked-change anchor, a bookmark/tracked-change/comment range spanning several blocks, an `office:forms` control, and an embedded object are refused by name rather than silently dropped. See [Writing a document](#writing-a-document) and [Fidelity constructs](#fidelity-constructs). - **The ods writer, at the same two levels** — `writeOds`/`writeOdsContent`, the genuine inverse of `readOds`/`readOdsContent`. Every `office:value-type` a cell can carry (float/percentage/currency/boolean/date/time/string, plus a value-less cell), column widths, row heights, hidden rows/columns, merged ranges, cell background/borders/alignment/vertical-alignment, verbatim formulas, cell-anchored images, and print settings (page geometry, gridlines/headers, page order, scale/fit-to-page, print range, repeated header rows/columns, manual page breaks) all round-trip. Embedded objects, data-validation rules, and conditional-formatting rules (ExaDev/documents.js#1075 gave `readOdsContent` a real reader for the last of these; `writeOdsContent` still has no inverse) are refused by name — `readOdsContent` has no write-side counterpart for any of the three yet. See [Writing a document](#writing-a-document). -- **The odp writer, at the same two levels** — `writeOdp`/`writeOdpContent`, the genuine inverse of `readOdp`/`readOdpContent`. A slide's shapes (positioned text boxes with formatted runs and lists, a rotated shape's `draw:transform`, a shape carrying a table — including a nested list or a further nested table inside one of its cells — or an image as its sole content, per-shape text insets), per-slide page geometry, and speaker notes all round-trip. Shape writing itself (`typed/draw/write-shapes.ts`) is factored out as the shared mirror of the read side's own `typed/draw/shapes.ts`, and the `.odg` writer below reuses it unchanged. A shape's own text carries its run-level construct extents (fields, bookmarks, notes, comments, tracked changes) through the identical machinery an odt body paragraph uses, definitions table and all ([ExaDev/documents.js#969](https://github.com/ExaDev/documents.js/issues/969)); the shape content ODF genuinely has no spelling for (a heading, a page break, an embedded object, a table or image mixed with other shape content) is refused by name; a slide's own residue (transitions/animations/sound) is dropped, the same deliberate exception `writeOdt` makes for a paragraph's own residue — the package-level table restores, for the same reason it does there. See [Writing a document](#writing-a-document) and this package's own [LibreOffice verification](#libreoffice-verification-writeodp) section for what was checked against a real, independent ODF implementation, including the two gaps that verification found and closed. +- **The odp writer, at the same two levels** — `writeOdp`/`writeOdpContent`, the genuine inverse of `readOdp`/`readOdpContent`. A slide's shapes (positioned text boxes with formatted runs and lists, a rotated shape's `draw:transform`, a shape carrying a table — including a nested list or a further nested table inside one of its cells — or an image as its sole content, per-shape text insets), per-slide page geometry, and speaker notes all round-trip. Shape writing itself (`typed/draw/write-shapes.ts`) is factored out as the shared mirror of the read side's own `typed/draw/shapes.ts`, and the `.odg` writer below reuses it unchanged. A shape's own text carries its run-level construct extents (fields, bookmarks, notes, comments, tracked changes) through the identical machinery an odt body paragraph uses, definitions table and all ([ExaDev/documents.js#969](https://github.com/ExaDev/documents.js/issues/969)), and a shape whose sole content is an embedded object round-trips through the identical `writeEmbeddedObject`/`readDrawObjectReference` machinery an odt body embedding uses ([ExaDev/documents.js#1190](https://github.com/ExaDev/documents.js/issues/1190)); the shape content ODF genuinely has no spelling for (a heading, a page break, an embedded object mixed with other shape content, a table or image mixed with other shape content) is refused by name; a slide's own residue (transitions/animations/sound) is dropped, the same deliberate exception `writeOdt` makes for a paragraph's own residue — the package-level table restores, for the same reason it does there. See [Writing a document](#writing-a-document) and this package's own [LibreOffice verification](#libreoffice-verification-writeodp) section for what was checked against a real, independent ODF implementation, including the two gaps that verification found and closed. - **The odg writer, at the same two levels** — `writeOdg`/`writeOdgContent`, the genuine inverse of `readOdg`/`readOdgContent`, and the one writer here with content the others have no vocabulary for: a drawing page's **vector primitives**. Rectangles, ellipses, lines, and free-form paths write as real `draw:rect`/`draw:ellipse`/`draw:line`/`draw:path` elements, with fill and stroke interned as graphic-family automatic styles and a path's own subpaths serialised into genuine `svg:d` path data against an `svg:viewBox` sized to its own frame. A page's text-in-a-frame shapes go through the same `writeDrawShapes` the odp writer uses, and per-page geometry through the same `style:master-page`/`style:page-layout` pair, so a drawing costs no second copy of either. A dotted or double stroke, a non-positive stroke width, and a path with no subpaths or a zero-extent frame are refused by name — ODF has no spelling for the first two, and this package's own reader discards the last two outright. See [Writing a document](#writing-a-document) and [LibreOffice verification (`writeOdg`)](#libreoffice-verification-writeodg). Not yet built: a write path for the odt fidelity constructs `writeOdt` does not yet resolve (notes, annotations, tracked changes, a block-scope construct range spanning several blocks, and forms — see [Fidelity constructs](#fidelity-constructs)), the ods/odp/odg fidelity constructs, a `.ods` cell's own `number:*` data-style (`readOdsContent` does not read one back yet, so there is nothing to write against), live-view editors, and the `.odb` database-table-export subsystem. A general-purpose SQL query engine for rendering a Report against its data is **deliberately not attempted** — building even a bounded SQL engine means reimplementing HSQLDB's/Firebird's query semantics, a materially different undertaking from decoding their file formats, with unreviewed licensing questions. Gated on the requesting engineer's explicit sign-off. diff --git a/packages/odf.js/src/index.ts b/packages/odf.js/src/index.ts index 99e8ed981..65c2ff543 100644 --- a/packages/odf.js/src/index.ts +++ b/packages/odf.js/src/index.ts @@ -335,6 +335,12 @@ export { writeEmbeddedObjectPackage, } from "./typed/draw/embedded-write"; +// The index-wrapper element builders (typed/shared/constructs.ts): the write-side inverse of the odt reader's index-wrapper recovery, exported so documents.js's odt editor can open a real text:table-of-content-style region for an index contentControl construct pair (ExaDev/documents.js#1190). +export { + odfIndexWrapperTag, + writeOdfIndexWrapper, +} from "./typed/shared/constructs"; + export { readOdm } from "./typed/odm/read"; // The master-document WRITER, the inverse of readOdm: one top-level text:section per chapter, each carrying its external-file reference exactly as the reader's own real-LibreOffice verification transcribed it. diff --git a/packages/odf.js/src/typed/draw/shapes.ts b/packages/odf.js/src/typed/draw/shapes.ts index b764df1e5..cda69e4f6 100644 --- a/packages/odf.js/src/typed/draw/shapes.ts +++ b/packages/odf.js/src/typed/draw/shapes.ts @@ -23,6 +23,11 @@ import { attrValue, childrenWithTag, elementsWithTag } from "../../xml/query"; import { decodeXmlText } from "../../xml/entities"; import { base64ToBytes } from "../../util/base64"; import { sniffImageFormat } from "../../image/sniff"; +import { + readDrawObjectReference, + readEmbeddedObjectDocument, +} from "./embedded"; +import type { OdfResidueFormat } from "../shared/constructs"; import { findNamedStylePartElement, resolveStyleElementChain, @@ -190,7 +195,7 @@ export function readDrawImageBlock( return block; } -// A draw:frame's content is exactly one of table:table, draw:text-box, or draw:image (verified against real LibreOffice output) -- table:table is checked FIRST because a real saved presentation table frame also carries a sibling draw:image (an .svm fallback preview LibreOffice writes for consumers that can't render a real table), which must not be mistaken for the frame's own image content. +// A draw:frame's content is exactly one of table:table, draw:text-box, draw:image, or draw:object (an embedded sub-document, read only when the caller opts in through embeddedFormat -- verified against real LibreOffice output) -- an embedded draw:object is checked FIRST (a real embedding frame also carries a sibling draw:image, the ObjectReplacements/ preview, which must not be mistaken for the frame's own image content), then table:table (a real saved presentation table frame carries a sibling .svm fallback preview for the identical reason). // // ODP LIST MEMBERSHIP -- minted numId, not the numId-less { level } shape: document-schema.js 3.3.0 made ContentListMembership.numId optional precisely so a reader whose source carries NO list identity could emit the honest minimal { level } (ooxml.js's pptx reader, whose a:pPr/@lvl is a bare depth attribute on the paragraph with no list element behind it -- a fabricated numId there would be a lie in the data). A slide text box is not that case: draw:text-box's own content model is exactly (text:p | text:list)*, and its text:list elements are the IDENTICAL structural containers the odt reader walks in office:text -- a slide can carry two of them (two bullet bodies in one text box, or one list in each of two frames), and a consumer grouping list paragraphs apart (rendering separate