From a04805f07ee57310268558edc73d98a4e42e43cc 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. --- .../src/content-json-schema-defs.ts | 6 ++ packages/document-schema.js/src/content.ts | 18 ++++++ .../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 +- 6 files changed, 118 insertions(+), 3 deletions(-) diff --git a/packages/document-schema.js/src/content-json-schema-defs.ts b/packages/document-schema.js/src/content-json-schema-defs.ts index f1862a2b6..d038dd734 100644 --- a/packages/document-schema.js/src/content-json-schema-defs.ts +++ b/packages/document-schema.js/src/content-json-schema-defs.ts @@ -344,6 +344,7 @@ export const CONTENT_DEFS: Record = { type: "object", properties: { kind: { type: "string", const: "paragraph" }, + origin: { type: "string", enum: ["chart", "diagram", "image"] }, runs: { type: "array", items: { $ref: "#/$defs/ContentRun" } }, constructs: { type: "array", @@ -433,6 +434,7 @@ export const CONTENT_DEFS: Record = { type: "object", properties: { kind: { type: "string", const: "image" }, + origin: { type: "string", enum: ["chart", "diagram", "image"] }, format: { type: "string", enum: ["png", "jpeg", "svg", "gif"] }, base64: { type: "string" }, widthPt: { type: "number", exclusiveMinimum: 0 }, @@ -498,6 +500,7 @@ export const CONTENT_DEFS: Record = { type: "object", properties: { kind: { type: "string", const: "table" }, + origin: { type: "string", enum: ["chart", "diagram", "image"] }, rows: { type: "array", items: { $ref: "#/$defs/ContentTableRow" } }, // nonnegative, not positive -- see ContentTableSchema's own field comment (src/content.ts): both ooxml.js and odf.js's table readers deliberately default an unresolvable column's own width to 0 rather than omitting it, a real shape ExaDev/documents.js#1009's own real-corpus bijection gate confirmed live documents actually produce. columnWidthsPt: { @@ -696,6 +699,7 @@ export const CONTENT_DEFS: Record = { type: "object", properties: { kind: { type: "string", const: "paragraph" }, + origin: { type: "string", enum: ["chart", "diagram", "image"] }, runs: { type: "array", items: { $ref: "#/$defs/ContentRun" } }, constructs: { type: "array", @@ -733,6 +737,7 @@ export const CONTENT_DEFS: Record = { type: "object", properties: { kind: { type: "string", const: "paragraph" }, + origin: { type: "string", enum: ["chart", "diagram", "image"] }, runs: { type: "array", items: { $ref: "#/$defs/ContentRun" } }, constructs: { type: "array", @@ -1343,6 +1348,7 @@ export const CONTENT_DEFS: Record = { type: "object", properties: { kind: { type: "string", const: "image" }, + origin: { type: "string", enum: ["chart", "diagram", "image"] }, format: { type: "string", enum: ["png", "jpeg", "svg", "gif"] }, base64: { type: "string" }, widthPt: { type: "number", exclusiveMinimum: 0 }, diff --git a/packages/document-schema.js/src/content.ts b/packages/document-schema.js/src/content.ts index ae26d196c..176731c6d 100644 --- a/packages/document-schema.js/src/content.ts +++ b/packages/document-schema.js/src/content.ts @@ -105,9 +105,24 @@ export type ContentParagraphBorders = z.infer< typeof ContentParagraphBordersSchema >; +// What a node's content was in the source document, where that differs from what its own kind says. +// +// The content model deliberately flattens different source constructs onto the same node: a ContentTable +// is a native table, a chart's cached data, or a spreadsheet range, and a reader holding one cannot tell +// which. That matters to a consumer that treats them differently -- a chart's numbers are exact and +// quotable, a diagram's labels have lost the relationships between them, and (once a vision pass exists) +// an image's text is a model's reading rather than the document's words. +// +// Absence is the common case and means the node is exactly what its kind says: authored body content. +// 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. +export const ContentOriginSchema = z.enum(["chart", "diagram", "image"]); +export type ContentOrigin = z.infer; + export const ContentParagraphSchema = z.object({ kind: z.literal("paragraph"), runs: z.array(ContentRunSchema), + origin: ContentOriginSchema.optional(), // see ContentOriginSchema -- e.g. "diagram" for a paragraph carrying a SmartArt node's label constructs: z.array(RunConstructExtentSchema).optional(), // the run-scoped constructs this paragraph carries (RunConstructExtent above) -- absent when it carries none, which is the overwhelming common case. Scope split, stated once: a construct bracketing whole BLOCKS is the constructStart/constructEnd marker pair below, never this field; a construct covering a sub-sequence of this paragraph's runs is this field, never a marker pair. One occurrence, one scope, one encoding. styleId: z.string().optional(), // w:pStyle/@w:val, e.g. 'Heading1' -- round-trip-only: a producer's own style name, meaningful only to a consumer that already knows that producer's naming convention codeLanguage: z.string().optional(), // the source-format language identifier of a code-styled block -- a markdown fence's info word, the language a syntax-highlighting consumer keys on. Absent on ordinary paragraphs and on a code block whose source named no language. Deliberately a free string, not an enum: no format's language vocabulary is closed, and the field names what the source said, never what a renderer supports @@ -190,6 +205,7 @@ export type ContentFloatPosition = z.infer; export const ContentImageBlockSchema = z.object({ kind: z.literal("image"), format: z.enum(["png", "jpeg", "svg", "gif"]), // svg/gif added for epub's own manifest image kinds; a codec whose reader cannot yet decode one of the four degrades to alt text with a diagnostic exactly as it did before this field existed, rather than being forced to adopt them the moment they exist here + origin: ContentOriginSchema.optional(), // see ContentOriginSchema -- "image" once a vision pass has read this picture, distinguishing its recovered text from the document's own words base64: z.string(), widthPt: z.number().positive(), heightPt: z.number().positive(), @@ -257,6 +273,7 @@ export interface ContentTableRow { export interface ContentTable { kind: "table"; + origin?: ContentOrigin; // see ContentOriginSchema -- e.g. "chart" for a table built from a chart's cached series/category values rather than authored as a table. Declared here as well as on ContentTableSchema: this interface is hand-written (z.lazy collapses a recursive child to `unknown` in the pinned Zod version), so the two do not derive from one another and a field added to only one compiles away silently rows: ContentTableRow[]; columnWidthsPt: number[]; sourcePath?: string; // deterministic, document-order-derived path assigned by the format reader @@ -639,6 +656,7 @@ export const ContentTableRowSchema = z.object({ export const ContentTableSchema = z.object({ kind: z.literal("table"), + origin: ContentOriginSchema.optional(), // see ContentOriginSchema -- e.g. "chart" for a table built from a chart's cached series/category values rather than authored as a table rows: z.array(ContentTableRowSchema), // nonnegative, not positive: both ooxml.js's docx reader (a w:gridCol with no w:w attribute) and odf.js's table reader (a table:table-column resolving no style-column-width) deliberately default an unresolvable column's own width to 0 rather than omitting it or guessing -- a real, common shape in real-world documents, not a defect this constraint should reject. Confirmed against this package's own real-corpus bijection gate (ExaDev/documents.js#1009 -- ContentBlockSchema's z.lazy() rewrite was the first time this field was ever actually validated at runtime, since the opaque z.custom() guard it replaced never checked column-width positivity at all). columnWidthsPt: z.array(z.number().nonnegative()), 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 }; }); }