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 }; }); }