From e6f10c3ca2a160f518be013cbec8ed3d422d1c3b Mon Sep 17 00:00:00 2001 From: fcbwilliams Date: Thu, 10 Sep 2026 18:12:25 +0200 Subject: [PATCH] feat(document-schema.js): record what a node's content was in the source The content model deliberately flattens different source constructs onto the same node, and a consumer holding one cannot tell which it has. A `ContentTable` is a native table, a chart's cached series/category data, or a spreadsheet range; a `ContentParagraph` is body prose or a SmartArt node's label. That distinction is not cosmetic. A chart's cached numbers are exact and quotable; a SmartArt diagram's labels have lost the relationships between them (a five-stage process arrives as five labels with no indication it is a sequence); and once a vision pass exists, an image's recovered text is a model's reading rather than the document's words. A consumer that cannot tell them apart treats all three as equally authoritative. `ContentOrigin` is `"chart" | "diagram" | "image"`, optional on `ContentParagraph`, `ContentTable` and `ContentImageBlock` -- the three variants that can carry content from a construct other than their own kind. Absence is the common case and means the node is exactly what its kind says: authored body content. Deliberately not added to `pageBreak`/`constructStart`/`constructEnd`, which have no content to have an origin for. Each value is here because a reader can actually distinguish it and a consumer can act on it; the vocabulary is open to extension rather than complete. `styleId` was not a candidate: it is a producer's own style name, meaningful only to a consumer that already knows that producer's convention, and it says nothing at all for a chart or a diagram. ooxml.js sets the two it can today: `readChartTable` marks its table `"chart"`, and `readDiagramText` marks every node paragraph `"diagram"`. `"image"` is reserved for the vision work in #1197 and set by nobody yet. Additive and optional throughout, so no existing document, reader, writer or consumer changes behaviour. Two notes for review, both places the change had to be made twice: `ContentTable`'s TypeScript interface is hand-written rather than inferred (z.lazy collapses a recursive child to `unknown` in the pinned Zod version), so the field is declared on both the interface and the schema. Adding it to only one compiles away silently -- the runtime validated it while `tsc` denied it existed, which is how I found this. Six hand-authored JSON Schema fragments needed it, not three: `HeadingParagraph`, `ListParagraph` and `ContentSheetImage` inherit the field by extending their base schemas. The live-`z.toJSONSchema()` comparison test caught every one. Refs #1197. --- .../ooxml.js/src/typed/pptx/chart.test.ts | 58 ++++++++++++++++++- packages/ooxml.js/src/typed/pptx/chart.ts | 1 + .../ooxml.js/src/typed/pptx/diagram.test.ts | 36 +++++++++++- packages/ooxml.js/src/typed/pptx/diagram.ts | 2 +- 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/packages/ooxml.js/src/typed/pptx/chart.test.ts b/packages/ooxml.js/src/typed/pptx/chart.test.ts index 8b3edda51..715c71e5b 100644 --- a/packages/ooxml.js/src/typed/pptx/chart.test.ts +++ b/packages/ooxml.js/src/typed/pptx/chart.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { XmlElement } from "../../model/node"; -import { readChartResidue } from "./chart"; +import { el, txt } from "../../xml/fragment"; +import { readChartResidue, readChartTable } from "./chart"; function chartRoot(): XmlElement { return { @@ -29,3 +30,58 @@ describe("readChartResidue", () => { expect(second.xml).toBe(first.xml); }); }); + +// One bar chart with a single series, its category labels and values in the caches PowerPoint writes +// beside the data reference. +function barChartRoot(): XmlElement { + const cachedPoint = (idx: string, value: string) => + el("c:pt", { idx }, [el("c:v", {}, [txt(value)])]); + return el("c:chartSpace", {}, [ + el("c:chart", {}, [ + el("c:plotArea", {}, [ + el("c:barChart", {}, [ + el("c:ser", {}, [ + el("c:tx", {}, [ + el("c:strRef", {}, [ + el("c:strCache", {}, [cachedPoint("0", "FY26")]), + ]), + ]), + el("c:cat", {}, [ + el("c:strRef", {}, [ + el("c:strCache", {}, [ + cachedPoint("0", "EMEA"), + cachedPoint("1", "APAC"), + ]), + ]), + ]), + el("c:val", {}, [ + el("c:numRef", {}, [ + el("c:numCache", {}, [ + cachedPoint("0", "42"), + cachedPoint("1", "51"), + ]), + ]), + ]), + ]), + ]), + ]), + ]), + ]); +} + +describe("readChartTable", () => { + it('marks the table it produces as origin "chart"', () => { + // A ContentTable is a native table, a chart's cached data, or a spreadsheet range, and a consumer + // holding one cannot otherwise tell which. It matters: a chart's cached numbers are exact and + // quotable, where a vision reading of the same chart would be approximate -- so the two have to be + // distinguishable by something other than a consumer's guess. + const table = readChartTable(barChartRoot(), { + xPt: 0, + yPt: 0, + widthPt: 400, + heightPt: 300, + }); + + expect(table?.origin).toBe("chart"); + }); +}); diff --git a/packages/ooxml.js/src/typed/pptx/chart.ts b/packages/ooxml.js/src/typed/pptx/chart.ts index 0c086bdad..63e314dca 100644 --- a/packages/ooxml.js/src/typed/pptx/chart.ts +++ b/packages/ooxml.js/src/typed/pptx/chart.ts @@ -130,6 +130,7 @@ export function readChartTable( const columnWidthPt = frame.widthPt / (series.length + 1); return { kind: "table", + origin: "chart", rows, columnWidthsPt: Array.from( { length: series.length + 1 }, diff --git a/packages/ooxml.js/src/typed/pptx/diagram.test.ts b/packages/ooxml.js/src/typed/pptx/diagram.test.ts index 915d6c990..2cc18eb2f 100644 --- a/packages/ooxml.js/src/typed/pptx/diagram.test.ts +++ b/packages/ooxml.js/src/typed/pptx/diagram.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { XmlElement } from "../../model/node"; -import { readDiagramResidue } from "./diagram"; +import { el, txt } from "../../xml/fragment"; +import { readDiagramResidue, readDiagramText } from "./diagram"; function part(tag: string): XmlElement { return { type: "element", tag, attributes: [], children: [] }; @@ -31,3 +32,36 @@ describe("readDiagramResidue", () => { expect(readDiagramResidue(undefined, undefined, undefined)).toBeUndefined(); }); }); + +// A two-node data model: a doc root, two content nodes, and the parOf connections making it a tree. +function dataModelRoot(): XmlElement { + const point = (id: string, text: string, type?: string) => + el("dgm:pt", type === undefined ? { modelId: id } : { modelId: id, type }, [ + el("dgm:t", {}, [ + el("a:p", {}, [el("a:r", {}, [el("a:t", {}, [txt(text)])])]), + ]), + ]); + const cxn = (srcId: string, destId: string, srcOrd: string) => + el("dgm:cxn", { srcId, destId, type: "parOf", srcOrd }); + return el("dgm:dataModel", {}, [ + el("dgm:ptLst", {}, [ + point("root", "", "doc"), + point("a", "Ad hoc"), + point("b", "Repeatable"), + ]), + el("dgm:cxnLst", {}, [cxn("root", "a", "0"), cxn("root", "b", "1")]), + ]); +} + +describe("readDiagramText", () => { + it('marks every node paragraph as origin "diagram"', () => { + // SmartArt node text reaches the model as ordinary paragraphs, so nothing otherwise distinguishes a + // process flow's step labels from body prose -- and they are not the same thing: the relationships + // between the nodes (the arrows, the hierarchy) are not recovered, which a consumer reading them as + // prose needs to know. + const paragraphs = readDiagramText(dataModelRoot()); + + expect(paragraphs.length).toBeGreaterThan(0); + expect(paragraphs.every((p) => p.origin === "diagram")).toBe(true); + }); +}); diff --git a/packages/ooxml.js/src/typed/pptx/diagram.ts b/packages/ooxml.js/src/typed/pptx/diagram.ts index df69e1391..8b550da99 100644 --- a/packages/ooxml.js/src/typed/pptx/diagram.ts +++ b/packages/ooxml.js/src/typed/pptx/diagram.ts @@ -29,7 +29,7 @@ function diagramTextParagraphs( runs.push({ text: "\n" }); } } - return { kind: "paragraph", runs }; + return { kind: "paragraph", origin: "diagram", runs }; }); }