Skip to content
Open
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
6 changes: 6 additions & 0 deletions packages/document-schema.js/src/content-json-schema-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
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",
Expand Down Expand Up @@ -433,6 +434,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
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 },
Expand Down Expand Up @@ -498,6 +500,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
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: {
Expand Down Expand Up @@ -696,6 +699,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
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",
Expand Down Expand Up @@ -733,6 +737,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
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",
Expand Down Expand Up @@ -1343,6 +1348,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
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 },
Expand Down
18 changes: 18 additions & 0 deletions packages/document-schema.js/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ContentOriginSchema>;

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
Expand Down Expand Up @@ -190,6 +205,7 @@ export type ContentFloatPosition = z.infer<typeof ContentFloatPositionSchema>;
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(),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()),
Expand Down
58 changes: 57 additions & 1 deletion packages/ooxml.js/src/typed/pptx/chart.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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");
});
});
1 change: 1 addition & 0 deletions packages/ooxml.js/src/typed/pptx/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
36 changes: 35 additions & 1 deletion packages/ooxml.js/src/typed/pptx/diagram.test.ts
Original file line number Diff line number Diff line change
@@ -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: [] };
Expand Down Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion packages/ooxml.js/src/typed/pptx/diagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function diagramTextParagraphs(
runs.push({ text: "\n" });
}
}
return { kind: "paragraph", runs };
return { kind: "paragraph", origin: "diagram", runs };
});
}

Expand Down
Loading