From 49b3b6aa55e373c815191c0d19a720c99be1c120 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 18 Aug 2026 11:08:45 +0100 Subject: [PATCH 1/6] feat!: promote DocumentPackage to the package tree and version dumps by $schema URI The DocumentPackage envelope becomes the single hierarchical artefact (#20): the root carries kind, metadata, the document-level symbolTable, optional rendered pages, optional package-level styles/definitions tables, and children -- one group per top-level container, with the node vocabulary in src/package-node.ts following document-outline.js's phase-1 reference shape exactly. Section groups are mandatory, grouping never crosses container boundaries, and discrimination is structural on node+children rather than on the presence of a kind. The generic package-level definitions-table facility lands with styles as its first tenant (#21): style entries carry strict paragraph/run sub-objects of resolved canonical properties only -- the frames/sourcePath/styleId ban is enforced by schema shape (strictObject rejects those keys outright), never by convention -- tree groups carry style refs into the table, ContentDocument nodes carry none so the flat codec-exchange form stays fully materialised, and the pure overlay/resolve/apply helpers export for the documents.js package boundary. Minting stays documents.js's behaviour. Versioning moves entirely to the serialised-artefact boundary: both DocumentPackage's and ContentDocument's formatVersion literals retire, and the release-pinned $schema URI a dumper stamps is the version. documentFromJson is the enforcement point for untrusted input -- same major parses, an older major throws the named-change error (the retired formatVersion envelope and flat package shape), a newer major throws the upgrade pointer, and a layout-document URI throws the pdf-codec tombstone (#65's schema side). A bare DocumentPackageSchema parse structurally validates without version-discriminating; the dispatch contract is documented in src/schema-io.ts. Content hashes exclude $schema -- it is envelope metadata naming the dumper, not content. content-json-schema-defs.ts transcribes the recursive PackageNode set (descriptors, anchors, the seven group wrappers, sheet-image/vector leaves, the definitions tables), with live z.toJSONSchema() comparison coverage for every fragment that has a real z.object counterpart; the generator splices the one $defs block into both published schema files so each resolves its local pointers. --- package.json | 2 +- scripts/generate-json-schemas.mjs | 70 +-- src/codec.test.ts | 71 +--- src/content-json-schema-defs.test.ts | 55 ++- src/content-json-schema-defs.ts | 611 ++++++++++++++++++++++++++- src/content.test.ts | 30 +- src/content.ts | 18 +- src/definitions.test.ts | 159 +++++++ src/definitions.ts | 146 +++++++ src/index.ts | 3 +- src/package-node.test.ts | 309 ++++++++++++++ src/package-node.ts | 238 +++++++++++ src/package.test.ts | 233 +++++----- src/package.ts | 56 ++- src/schema-io.test.ts | 161 ++++--- src/schema-io.ts | 118 ++++-- test/smoke.test.mjs | 111 ++--- test/workers/document-schema.test.ts | 43 +- 18 files changed, 2012 insertions(+), 422 deletions(-) create mode 100644 src/definitions.test.ts create mode 100644 src/definitions.ts create mode 100644 src/package-node.test.ts create mode 100644 src/package-node.ts diff --git a/package.json b/package.json index 4adcb05..0fe158d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "document-schema.js", "version": "3.3.0", - "description": "The canonical, format-agnostic content and layout schemas shared by ooxml.js, odf.js, and documents.js -- pure Zod schemas, no behaviour.", + "description": "The canonical, format-agnostic content and document-package schemas shared by ooxml.js, odf.js, documents.js, pdf-codec, and markdown-codec -- pure Zod schemas, no behaviour.", "type": "module", "repository": { "type": "git", diff --git a/scripts/generate-json-schemas.mjs b/scripts/generate-json-schemas.mjs index 7da73e5..03ee629 100644 --- a/scripts/generate-json-schemas.mjs +++ b/scripts/generate-json-schemas.mjs @@ -5,11 +5,11 @@ // // This script is deliberately outside tsconfig.json's "include" and eslint.config.ts's linted set (see the "scripts" entry in both), matching the existing precedent for test/smoke.test.mjs: a standalone build step, not part of the shipped src/ program. // -// -- The z.custom() opacity problem -- ContentDocumentSchema's tree contains four schemas built from a hand-written type-guard predicate (z.custom()) rather than real Zod primitives -- ContentBlockSchema, ContentEmbeddedObjectSchema, MathMlNodeSchema, and MathExpressionSchema -- because the recursive block/table/embedded-object, MathML-element, and math-expression structures they represent can't be expressed via z.lazy() in the pinned Zod version (see src/content.ts's own comments on isContentBlock/isContentEmbeddedObject, src/mathml.ts's on isMathMlNode, and src/math.ts's on isMathExpression). z.toJSONSchema() cannot introspect a z.custom() node at all: with `unrepresentable: 'any'` it silently emits an empty `{}` for that node (confirmed by reading node_modules/zod/v4/core/json-schema-processors.js's customProcessor, which does nothing to its `json` argument once `unrepresentable !== 'throw'`); without that option it throws immediately, before override() ever runs (override only patches at finalize() time, strictly after the pass that would otherwise throw). So everything downstream of those four nodes needs hand-authored JSON Schema fragments, spliced in via the override() callback below -- the fragments themselves (CONTENT_DEFS, MAX_SAFE_INTEGER, EMBEDDED_OBJECT_KINDS, CONTENT_DOCUMENT_URI) now live in src/content-json-schema-defs.ts rather than inline here, so this script and that module's own regression test (content-json-schema-defs.test.ts) share exactly one copy -- see that src module's own top comment for why it had to move out of this script. Two real z.objects are replaced with a $ref to their hand-authored fragments too: ContentFormulaSchema (a real z.object, but its mathml/content fields drag in the opaque MathMlNodeSchema/MathExpressionSchema, so its auto-generated body would be hollowed out around them -- the ContentTableCellSchema situation) and SymbolTableSchema (fully generatable, but $ref-ing it keeps each ContentDocument arm's symbolTable field one named reference instead of five inlined copies of the whole unit-registry subtree). +// -- The z.custom() opacity problem -- ContentDocumentSchema's tree contains four schemas built from a hand-written type-guard predicate (z.custom()) rather than real Zod primitives -- ContentBlockSchema, ContentEmbeddedObjectSchema, MathMlNodeSchema, and MathExpressionSchema -- because the recursive block/table/embedded-object, MathML-element, and math-expression structures they represent can't be expressed via z.lazy() in the pinned Zod version (see src/content.ts's own comments on isContentBlock/isContentEmbeddedObject, src/mathml.ts's on isMathMlNode, and src/math.ts's on isMathExpression). z.toJSONSchema() cannot introspect a z.custom() node at all: with `unrepresentable: 'any'` it silently emits an empty `{}` for that node (confirmed by reading node_modules/zod/v4/core/json-schema-processors.js's customProcessor, which does nothing to its `json` argument once `unrepresentable !== 'throw'`); without that option it throws immediately, before override() ever runs (override only patches at finalize() time, strictly after the pass that would otherwise throw). So everything downstream of those four nodes needs hand-authored JSON Schema fragments, spliced in via the override() callback below -- the fragments themselves (CONTENT_DEFS, MAX_SAFE_INTEGER, EMBEDDED_OBJECT_KINDS, CONTENT_DOCUMENT_URI) now live in src/content-json-schema-defs.ts rather than inline here, so this script and that module's own regression test (content-json-schema-defs.test.ts) share exactly one copy -- see that src module's own top comment for why it had to move out of this script. The 4.0.0 tree-form DocumentPackage added a fifth opaque set: its five arms' children fields reference the package tree's per-kind group schemas (src/package-node.ts, z.custom over recursive guards), so the whole PackageNode vocabulary is transcribed in CONTENT_DEFS alongside the content fragments, and three more real z.objects are replaced with a $ref: ContentFormulaSchema and SymbolTableSchema (as before -- see their own branches) plus StyleEntrySchema/DefinitionEntrySchema (the same named-reference-instead-of-five-copies reason for the package arms' styles/definitions fields). // -// -- Cross-file references -- Rather than each of the three .schema.json files being a fully independent, self-contained document (duplicating ContentDocument's entire body inside document-package.schema.json), this uses Zod's registry-based multi-schema generation: a dedicated z.registry() (not z.globalRegistry, so a one-shot build step never pollutes shared process-wide state), registry.add(schema, {id}) for all three schemas, then one z.toJSONSchema(registry, {uri, override, unrepresentable: 'any'}) call. Zod automatically produces real $ref-based cross-references between the three output files for any registered schema encountered while generating another (confirmed empirically: see the experiment behind this script's own review) -- DocumentPackageSchema's own `content`/`layout` fields come out as `{ $ref: }` rather than inlining ContentDocument/LayoutDocument's entire bodies. +// -- Cross-file references -- Rather than each .schema.json file being a fully independent, self-contained document (duplicating ContentDocument's entire body inside document-package.schema.json), this uses Zod's registry-based multi-schema generation: a dedicated z.registry() (not z.globalRegistry, so a one-shot build step never pollutes shared process-wide state), registry.add(schema, {id}) for both schemas, then one z.toJSONSchema(registry, {uri, override, unrepresentable: 'any'}) call. Zod automatically produces real $ref-based cross-references for any registered schema encountered while generating another. The one cross-file pointer that remains is deliberate: $defs.ContentEmbeddedObject(Block)'s `document` field, which is the genuine cycle back to a whole ContentDocument and points at CONTENT_DOCUMENT_URI. Everything else stays file-local -- CONTENT_DEFS is spliced into BOTH output files' roots (the ContentDocumentSchema and DocumentPackageSchema branches below), because the tree fragments reference the content vocabulary through local `#/$defs/...` pointers and each file must resolve its own pointers without depending on the other file's layout; the two copies cannot drift because they are the same object emitted twice in one run. // -// -- $id/URI scheme -- schemaUriFor() (src/schema-io.ts, imported below from the freshly-built dist/index.js like every other schema this script uses) maps each registered id to https://cdn.jsdelivr.net/npm/document-schema.js@{version}/schemas/{fileName}, pinned to this package's own published npm version (baked in at build time via tsdown's `define`, not read from the npm registry) rather than a git commit. A commit-SHA-embedded $id was tried first and rejected: schemas/ is gitignored (generated, not committed, matching dist/'s own treatment below), so a file whose own content names the exact commit that generated it can never actually exist at that commit -- committing it would change the tree, which would change the hash it would need to embed. That's not a timing gap, it's a structural impossibility, confirmed by testing the resulting raw.githubusercontent.com URL directly (404, forever, for every past and future release). jsdelivr's npm CDN has no such problem: it serves whatever's inside an already-published version's own tarball, and the version is already known and stable by the time this script runs (semantic-release's npm plugin writes the bumped version into package.json before invoking npm's prepublishOnly lifecycle, which is what runs this generator via `pnpm run build`) -- no circularity, and confirmed live by directly curling this exact URL pattern against the previously-published 1.6.0 tarball (200, with `cache-control: immutable`). A local dev build reads whatever version currently happens to be in package.json (the last real release, not a "current" one) -- the resulting URL is only genuinely fetchable once that version is actually published, same caveat any version-pinned CDN reference has. schemaUriFor()/SCHEMA_FILE_NAMES are also exported at runtime (src/schema-io.ts) for documentPackageWithSchema()/documentFromJson()/etc. -- this script reuses that single copy rather than keeping its own parallel id->filename->URL map in sync by hand. +// -- $id/URI scheme -- schemaUriFor() (src/schema-io.ts, imported below from the freshly-built dist/index.js like every other schema this script uses) maps each registered id to https://cdn.jsdelivr.net/npm/document-schema.js@{version}/schemas/{fileName}, pinned to this package's own published npm version (baked in at build time via tsdown's `define`, not read from the npm registry) rather than a git commit. A commit-SHA-embedded $id was tried first and rejected: schemas/ is gitignored (generated, not committed, matching dist/'s own treatment below), so a file whose own content names the exact commit that generated it can never actually exist at that commit -- committing it would change the tree, which would change the hash it would need to embed. That's not a timing gap, it's a structural impossibility, confirmed by testing the resulting raw.githubusercontent.com URL directly (404, forever, for every past and future release). jsdelivr's npm CDN has no such problem: it serves whatever's inside an already-published version's own tarball, and the version is already known and stable by the time this script runs (semantic-release's npm plugin writes the bumped version into package.json before invoking npm's prepublishOnly lifecycle, which is what runs this generator via `pnpm run build`) -- no circularity, and confirmed live by directly curling this exact URL pattern against a previously-published 1.6.0 tarball (200, with `cache-control: immutable`). A local dev build reads whatever version currently happens to be in package.json (the last real release, not a "current" one) -- the resulting URL is only genuinely fetchable once that version is actually published, same caveat any version-pinned CDN reference has. schemaUriFor()/SCHEMA_FILE_NAMES are also exported at runtime (src/schema-io.ts) for documentPackageWithSchema()/documentFromJson()/etc. -- this script reuses that single copy rather than keeping its own parallel id->filename->URL map in sync by hand. The URI is also the artefact's VERSION (4.0.0's versioning contract, src/schema-io.ts): there is no formatVersion integer anywhere in a dumped value, and documentFromJson dispatches on this URI's version segment. // // No try/catch anywhere in this script -- any failure (a Zod throw, a filesystem error) crashes it loudly with a non-zero exit, matching this project's standing "never silently swallow a failure" convention. @@ -24,13 +24,16 @@ import { ContentEmbeddedObjectSchema, ContentFormulaSchema, CONTENT_DOCUMENT_URI, + DefinitionEntrySchema, DocumentPackageSchema, - EMBEDDED_OBJECT_KINDS, - LayoutDocumentSchema, + DrawPageGroupSchema, MathMlNodeSchema, - MAX_SAFE_INTEGER, SCHEMA_FILE_NAMES, schemaUriFor, + SectionGroupSchema, + SheetGroupSchema, + SlideGroupSchema, + StyleEntrySchema, SymbolTableSchema, } from '../dist/index.js'; @@ -44,17 +47,21 @@ const { version: packageVersion } = JSON.parse(readFileSync(join(repoRoot, 'pack const registry = z.registry(); registry.add(DocumentPackageSchema, { id: 'DocumentPackage' }); registry.add(ContentDocumentSchema, { id: 'ContentDocument' }); -registry.add(LayoutDocumentSchema, { id: 'LayoutDocument' }); -// Six branches, keyed on reference equality against the exported consts (each z.custom() call has distinct object identity, confirmed empirically). override() fires exactly once per unique Zod schema instance encountered anywhere across the whole registry-processing session, regardless of how many field sites reference it or which of the three output files happens to reach it first -- mutating ctx.jsonSchema in place is what makes one override call apply everywhere that exact schema object is used (e.g. ContentBlockSchema appears in ContentSectionSchema, ContentShapeSchema, and ContentTableCellSchema all at once). +// Ten branches, keyed on reference equality against the exported consts (each z.custom() call has distinct object identity, confirmed empirically). override() fires exactly once per unique Zod schema instance encountered anywhere across the whole registry-processing session, regardless of how many field sites reference it or which output file happens to reach it first -- mutating ctx.jsonSchema in place is what makes one override call apply everywhere that exact schema object is used. function override(ctx) { + if (ctx.zodSchema === DocumentPackageSchema) { + // The package root is a discriminated union of the five kind arms; its children fields reach the tree's opaque group schemas, replaced further down with local #/$defs pointers. Splicing CONTENT_DEFS here makes document-package.schema.json resolve its own pointers: the tree fragments, the styles/definitions fragments, and everything they reference live in that one $defs block (see this file's cross-file-references comment for why both output files carry it). + ctx.jsonSchema.$defs = CONTENT_DEFS; + return; + } if (ctx.zodSchema === ContentDocumentSchema) { // Zod's own discriminated-union conversion already produced a correct `oneOf` of the five kind variants on ctx.jsonSchema (each is a real z.object; the 'formula' variant reaches the custom nodes through ContentFormulaSchema, replaced wholesale by its own branch below) -- this only adds the hand-authored $defs block alongside it. ctx.jsonSchema.$defs = CONTENT_DEFS; return; } if (ctx.zodSchema === ContentFormulaSchema) { - // A real z.object, but two of its fields reach opaque custom nodes (mathml -> MathMlNodeSchema, content -> MathExpressionSchema), so the whole thing is transcribed as $defs.ContentFormula and every occurrence replaced -- same treatment as ContentBlockSchema below, except the auto-generated body must be cleared first: unlike a custom node (which starts as `{}`), a real object schema's jsonSchema already carries type/properties/required by the time finalize() runs. + // A real z.object, but two of its fields reach opaque custom nodes (mathml -> MathMlNodeSchema, content -> MathExpressionSchema), so the whole thing is transcribed as $defs.ContentFormula and every occurrence replaced -- same treatment as ContentBlockSchema below, except the auto-generated body must be cleared first: unlike a custom node (which starts as `{}`), a real object schema's jsonSchema already carries type/properties/required by the time finalize() runs. Reached twice per build: as the flat ContentDocument 'formula' variant's field and as the tree package's formula-root children item. for (const key of Object.keys(ctx.jsonSchema)) { delete ctx.jsonSchema[key]; } @@ -69,30 +76,45 @@ function override(ctx) { ctx.jsonSchema.$ref = '#/$defs/SymbolTable'; return; } + if (ctx.zodSchema === StyleEntrySchema || ctx.zodSchema === DefinitionEntrySchema) { + // The SymbolTable treatment for the package arms' tables: fully generatable entries, $ref-ed so each arm's styles/definitions field is one named reference instead of five inlined copies. The id spelled here is the branch's own -- the two schemas' fragments carry different names. + const defName = ctx.zodSchema === StyleEntrySchema ? 'StyleEntry' : 'DefinitionEntry'; + for (const key of Object.keys(ctx.jsonSchema)) { + delete ctx.jsonSchema[key]; + } + ctx.jsonSchema.$ref = `#/$defs/${defName}`; + return; + } if (ctx.zodSchema === MathMlNodeSchema) { - // Recursive -- an element's children may themselves be elements -- so every occurrence, including inside $defs.MathMlElement above, points at one shared definition rather than inlining, exactly as ContentBlockSchema does below. ctx.jsonSchema starts as `{}` here, so this assignment alone is sufficient. + // Recursive -- an element's children may themselves be elements -- so every occurrence, including inside $defs.MathMlElement, points at one shared definition rather than inlining, exactly as ContentBlockSchema does below. ctx.jsonSchema starts as `{}` here, so this assignment alone is sufficient. ctx.jsonSchema.$ref = '#/$defs/MathMlNode'; return; } if (ctx.zodSchema === ContentBlockSchema) { - // Recursive -- a table cell's own blocks may themselves be tables -- so every occurrence, including inside $defs.ContentTableCell above, points at one shared definition rather than inlining, which cannot express unbounded recursion. ctx.jsonSchema starts as `{}` here (customProcessor does nothing to it once unrepresentable !== 'throw'), so this assignment alone is sufficient -- no properties to clear first. + // Recursive -- a table cell's own blocks may themselves be tables -- so every occurrence, including inside $defs.ContentTableCell, points at one shared definition rather than inlining, which cannot express unbounded recursion. ctx.jsonSchema starts as `{}` here (customProcessor does nothing to it once unrepresentable !== 'throw'), so this assignment alone is sufficient -- no properties to clear first. ctx.jsonSchema.$ref = '#/$defs/ContentBlock'; return; } if (ctx.zodSchema === ContentEmbeddedObjectSchema) { - // Standalone schema for an embedded object on its own (src/content.ts), independent of the ContentBlock 'embeddedObject' wrapper above -- this is what ContentSheetSchema.embeddedObjects validates each entry against. Same object shape as $defs.ContentEmbeddedObjectBlock minus the `kind` discriminant, cell-anchor fields included. - ctx.jsonSchema.type = 'object'; - ctx.jsonSchema.properties = { - objectKind: { type: 'string', enum: EMBEDDED_OBJECT_KINDS }, - document: { $ref: CONTENT_DOCUMENT_URI }, - frame: { $ref: '#/$defs/Box' }, - anchorRow: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, - anchorColumn: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, - offsetXPt: { type: 'number' }, - offsetYPt: { type: 'number' }, - }; - ctx.jsonSchema.required = ['objectKind', 'document', 'frame']; - ctx.jsonSchema.additionalProperties = false; + // Standalone schema for an embedded object on its own (src/content.ts) -- the sheet-children leaf position of the package tree, and the flat ContentSheetSchema.embeddedObjects entry type. Transcribed as $defs.ContentEmbeddedObject (same member fields as ContentEmbeddedObjectBlock minus the block-level `kind` discriminant), and $ref-ed from every occurrence; a custom node, so its jsonSchema starts as `{}` and the assignment alone suffices, exactly as ContentBlockSchema above. + ctx.jsonSchema.$ref = '#/$defs/ContentEmbeddedObject'; + return; + } + if ( + ctx.zodSchema === SectionGroupSchema || + ctx.zodSchema === SlideGroupSchema || + ctx.zodSchema === SheetGroupSchema || + ctx.zodSchema === DrawPageGroupSchema + ) { + // The four per-kind root group schemas the package arms' children fields reference (src/package-node.ts). ShapeGroup/HeadingGroup/ListGroup never appear here: they are reachable only through the hand-authored fragments' own children pointers, which already spell their $defs names, and zod never walks inside a custom node to reach them. ctx.jsonSchema starts as `{}`, so the assignment alone suffices. + const defNames = new Map([ + [SectionGroupSchema, 'SectionGroup'], + [SlideGroupSchema, 'SlideGroup'], + [SheetGroupSchema, 'SheetGroup'], + [DrawPageGroupSchema, 'DrawPageGroup'], + ]); + const defName = defNames.get(ctx.zodSchema); + ctx.jsonSchema.$ref = `#/$defs/${defName}`; } } diff --git a/src/codec.test.ts b/src/codec.test.ts index 0c7cb99..a41e499 100644 --- a/src/codec.test.ts +++ b/src/codec.test.ts @@ -1,14 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { COLOR_BLACK } from './color'; -import type { ContentCodec, LayoutCodec } from './codec'; -import { CONTENT_FORMAT_VERSION, type ContentDocument } from './content'; -import { LAYOUT_FORMAT_VERSION, type LayoutDocument } from './layout'; -import { DEFAULT_LAYOUT_FONT } from './style'; +import type { ContentCodec } from './codec'; +import type { ContentDocument } from './content'; function wordprocessingDocument(): ContentDocument { return { kind: 'wordprocessing', - formatVersion: CONTENT_FORMAT_VERSION, metadata: { title: 'Codec round trip', author: 'document-schema.js' }, sections: [ { @@ -26,32 +22,6 @@ function wordprocessingDocument(): ContentDocument { }; } -function layoutDocument(): LayoutDocument { - return { - formatVersion: LAYOUT_FORMAT_VERSION, - metadata: { title: 'Codec round trip', author: 'document-schema.js' }, - pages: [ - { - widthPt: 612, - heightPt: 792, - items: [ - { - kind: 'text', - text: 'Hello, codec.', - xPt: 72, - yPt: 720, - font: DEFAULT_LAYOUT_FONT, - sizePt: 12, - color: COLOR_BLACK, - sourcePath: 'sections[0].blocks[0].runs[0]', - }, - ], - }, - ], - images: {}, - }; -} - describe('ContentCodec', () => { it('accepts a real implementation carrying both read and write', () => { const codec: ContentCodec = { @@ -94,40 +64,3 @@ describe('ContentCodec', () => { expect(codec.read(new Uint8Array([0]), {})).toEqual(wordprocessingDocument()); }); }); - -describe('LayoutCodec', () => { - it('accepts a real implementation carrying both read and write, since write is not optional', () => { - const codec: LayoutCodec = { - read: (bytes) => { - expect(bytes).toBeInstanceOf(Uint8Array); - return layoutDocument(); - }, - write: (layout) => { - expect(layout.pages).toHaveLength(1); - return new Uint8Array([4, 5, 6]); - }, - }; - - const layout = codec.read(new Uint8Array([0])); - expect(layout).toEqual(layoutDocument()); - expect(codec.write(layout)).toEqual(new Uint8Array([4, 5, 6])); - }); - - it('threads a format-specific TOptions type through both read and write', () => { - interface PdfWriteOptions { - onFontSubstitution?: (family: string) => void; - } - - const codec: LayoutCodec = { - read: () => layoutDocument(), - write: (_layout, options) => { - options?.onFontSubstitution?.('Carlito'); - return new Uint8Array([7]); - }, - }; - - let substituted: string | undefined; - codec.write(layoutDocument(), { onFontSubstitution: (family) => (substituted = family) }); - expect(substituted).toBe('Carlito'); - }); -}); diff --git a/src/content-json-schema-defs.test.ts b/src/content-json-schema-defs.test.ts index 460f868..3ce5c07 100644 --- a/src/content-json-schema-defs.test.ts +++ b/src/content-json-schema-defs.test.ts @@ -8,11 +8,26 @@ import { ContentListMembershipSchema, ContentPageBreakSchema, ContentParagraphSchema, + ContentPathPointSchema, + ContentPathSegmentSchema, ContentRunSchema, + ContentSheetCellCommentSchema, + ContentSheetCellSchema, + ContentSheetColumnSchema, + ContentSheetImageSchema, + ContentSheetPrintRangeSchema, + ContentSheetPrintSettingsSchema, + ContentSheetRepeatRangeSchema, + ContentSheetRowSchema, + ContentStrokeSchema, ContentStrokeStyleSchema, + ContentSubpathSchema, + ContentVectorSchema, + ContentCellValueSchema, } from './content'; import { CONTENT_DEFS } from './content-json-schema-defs'; -import { BoxSchema, LayoutFrameSchema } from './geometry'; +import { DefinitionEntrySchema, StyleEntrySchema, StyleParagraphPropertiesSchema, StyleRunPropertiesSchema } from './definitions'; +import { BoxSchema, LayoutFrameSchema, MarginsSchema, PageSizeSchema } from './geometry'; import { DimensionVectorSchema, ExactRationalSchema, @@ -28,9 +43,18 @@ import { MathUnparsedSchema, SymbolTableSchema, } from './math'; +import { + DrawPageDescriptorSchema, + HeadingParagraphSchema, + ListParagraphSchema, + SectionDescriptorSchema, + ShapeDescriptorSchema, + SheetDescriptorSchema, + SlideDescriptorSchema, +} from './package-node'; import { AlignmentSchema } from './style'; -// This is the regression test scripts/generate-json-schemas.mjs's own top comment calls for: the only structural defence that generator has against silently drifting away from src/content.ts/src/color.ts/src/geometry.ts/src/style.ts/src/math.ts, since CONTENT_DEFS (content-json-schema-defs.ts) is transcribed by hand rather than generated. Not every entry in CONTENT_DEFS can be checked this way -- ContentBlock/ContentTable/ContentTableRow/ContentTableCell/ContentEmbeddedObjectBlock/MathMlNode/MathMlElement/MathMlAttribute all sit downstream of one of the genuinely un-representable z.custom() nodes (ContentBlockSchema, ContentEmbeddedObjectSchema, MathMlNodeSchema), and ContentFormula/MathExpression/MathApp/MathSum/MathProd/MathMatrix sit downstream of the fourth (MathExpressionSchema, reached through ContentFormulaSchema.content for the first and through the grammar's own recursion for the rest) -- see that module's own top comment -- so a bare z.toJSONSchema() call over their real schema counterpart either throws or degrades to `{}` for the recursive/custom part, which is exactly the problem CONTENT_DEFS exists to work around in the first place. What CAN be checked -- because a real, non-recursive, non-custom exported Zod schema exists for it -- is every leaf and near-leaf fragment: Color, Box, LayoutFrame, Alignment, ContentStrokeStyle, ContentBorder, ContentCellBorders, ContentListMembership, ContentRun, ContentParagraph, ContentImageBlock, ContentPageBreak, ExactRational, DimensionVector, MathPresentation, MathProvenance, MathUncertainty, MathNum, MathQty, MathSym, MathUnparsed, MathSymbolEntry, MathUnit, MathNormalisationContext, SymbolTable. None of these reaches ContentBlockSchema, ContentEmbeddedObjectSchema, MathMlNodeSchema, or MathExpressionSchema from anywhere in its own field tree, so each can be generated live and compared directly. +// This is the regression test scripts/generate-json-schemas.mjs's own top comment calls for: the only structural defence that generator has against silently drifting away from src/content.ts/src/color.ts/src/geometry.ts/src/style.ts/src/math.ts/src/package-node.ts/src/definitions.ts, since CONTENT_DEFS (content-json-schema-defs.ts) is transcribed by hand rather than generated. Not every entry in CONTENT_DEFS can be checked this way -- ContentBlock/ContentTable/ContentTableRow/ContentTableCell/ContentEmbeddedObject(Block)/MathMlNode/MathMlElement/MathMlAttribute all sit downstream of one of the genuinely un-representable z.custom() nodes (ContentBlockSchema, ContentEmbeddedObjectSchema, MathMlNodeSchema), the seven package-tree group wrappers sit downstream of the tree's own per-kind group schemas (src/package-node.ts, z.custom over recursive guards, reached only through the hand fragments' own children pointers), and ContentFormula/MathExpression/MathApp/MathSum/MathProd/MathMatrix sit downstream of the fourth opaque node (MathExpressionSchema, reached through ContentFormulaSchema.content for the first and through the grammar's own recursion for the rest) -- see that module's own top comment -- so a bare z.toJSONSchema() call over their real schema counterpart either throws or degrades to `{}` for the recursive/custom part, which is exactly the problem CONTENT_DEFS exists to work around in the first place. What CAN be checked -- because a real, non-recursive, non-custom exported Zod schema exists for it -- is every leaf and near-leaf fragment: Color, Box, LayoutFrame, Alignment, ContentStrokeStyle, ContentBorder, ContentCellBorders, ContentListMembership, ContentRun, ContentParagraph, ContentImageBlock, ContentPageBreak, PageSize, Margins, SectionDescriptor, SlideDescriptor, SheetDescriptor, DrawPageDescriptor, ShapeDescriptor, HeadingParagraph, ListParagraph, ContentSheetCell, ContentCellValue, ContentSheetCellComment, ContentSheetColumn, ContentSheetRow, ContentSheetPrintSettings, ContentSheetPrintRange, ContentSheetRepeatRange, ContentSheetImage, ContentStroke, ContentPathPoint, ContentPathSegment, ContentSubpath, ContentVector, StyleParagraphProperties, StyleRunProperties, StyleEntry, DefinitionEntry, ExactRational, DimensionVector, MathPresentation, MathProvenance, MathUncertainty, MathNum, MathQty, MathSym, MathUnparsed, MathSymbolEntry, MathUnit, MathNormalisationContext, SymbolTable. None of these reaches ContentBlockSchema, ContentEmbeddedObjectSchema, MathMlNodeSchema, MathExpressionSchema, or a tree group schema from anywhere in its own field tree, so each can be generated live and compared directly. // // Comparison strategy: a bare `z.toJSONSchema(SomeSchema)` call, run in isolation, would INLINE every nested schema it encounters (ColorSchema inside ContentRunSchema, AlignmentSchema inside ContentParagraphSchema, etc.) rather than emit the `{ $ref: '#/$defs/X' }` pointers CONTENT_DEFS itself uses -- because those nested schemas aren't registered anywhere. To reproduce the exact cross-reference shape CONTENT_DEFS hand-authors, this test registers the identical set of real schemas under the identical id strings CONTENT_DEFS uses as its own $defs keys, with a `uri` callback matching the `#/$defs/` convention CONTENT_DEFS was written against -- confirmed empirically (see this file's own construction) to make Zod's registry-based multi-schema generation emit exactly that $ref shape for every registered schema referenced from within another. Each per-schema result still carries its own top-level `$schema`/`$id` (since z.toJSONSchema(registry, ...) treats every registered schema as its own standalone root), which CONTENT_DEFS's own nested fragments never have -- those two keys are stripped before comparison, since they're an artefact of testing each fragment as a registry root rather than a real structural difference. @@ -47,6 +71,33 @@ const REGISTERED_SCHEMAS = { ContentParagraph: ContentParagraphSchema, ContentImageBlock: ContentImageBlockSchema, ContentPageBreak: ContentPageBreakSchema, + PageSize: PageSizeSchema, + Margins: MarginsSchema, + SectionDescriptor: SectionDescriptorSchema, + SlideDescriptor: SlideDescriptorSchema, + SheetDescriptor: SheetDescriptorSchema, + DrawPageDescriptor: DrawPageDescriptorSchema, + ShapeDescriptor: ShapeDescriptorSchema, + HeadingParagraph: HeadingParagraphSchema, + ListParagraph: ListParagraphSchema, + ContentSheetCell: ContentSheetCellSchema, + ContentCellValue: ContentCellValueSchema, + ContentSheetCellComment: ContentSheetCellCommentSchema, + ContentSheetColumn: ContentSheetColumnSchema, + ContentSheetRow: ContentSheetRowSchema, + ContentSheetPrintSettings: ContentSheetPrintSettingsSchema, + ContentSheetPrintRange: ContentSheetPrintRangeSchema, + ContentSheetRepeatRange: ContentSheetRepeatRangeSchema, + ContentSheetImage: ContentSheetImageSchema, + ContentStroke: ContentStrokeSchema, + ContentPathPoint: ContentPathPointSchema, + ContentPathSegment: ContentPathSegmentSchema, + ContentSubpath: ContentSubpathSchema, + ContentVector: ContentVectorSchema, + StyleParagraphProperties: StyleParagraphPropertiesSchema, + StyleRunProperties: StyleRunPropertiesSchema, + StyleEntry: StyleEntrySchema, + DefinitionEntry: DefinitionEntrySchema, ExactRational: ExactRationalSchema, DimensionVector: DimensionVectorSchema, MathPresentation: MathPresentationSchema, diff --git a/src/content-json-schema-defs.ts b/src/content-json-schema-defs.ts index a2e123d..8d99ab1 100644 --- a/src/content-json-schema-defs.ts +++ b/src/content-json-schema-defs.ts @@ -5,11 +5,11 @@ import { schemaUriFor } from './schema-io'; // The hand-authored JSON Schema $defs fragments spliced into content-document.schema.json's `override()` callback (scripts/generate-json-schemas.mjs), lifted out into their own src module rather than staying inline in that script. The reason is single-sourcing, not tidiness: this exact object needs to be reachable from two places that cannot share an import graph -- // // 1. scripts/generate-json-schemas.mjs itself, which only ever runs against the freshly-built ../dist/ (it imports every other schema it needs the same way), so it imports CONTENT_DEFS from '../dist/content-json-schema-defs.js', the file tsdown emits for this module (entry: 'src/**/*.ts', one dist file per src file -- see tsdown.config.ts). -// 2. content-json-schema-defs.test.ts (src/, run directly by vitest's "unit" project against source, never against dist), which imports this exact same CONTENT_DEFS value straight from here and asserts it stays byte-for-byte in step with a live z.toJSONSchema() call over each fragment's real exported Zod schema counterpart (ContentParagraphSchema, ContentRunSchema, ContentListMembershipSchema, ContentImageBlockSchema, ContentPageBreakSchema, ColorSchema, BoxSchema, AlignmentSchema, ContentStrokeStyleSchema, ContentBorderSchema, ContentCellBordersSchema, plus the non-recursive math leaves from src/math.ts: ExactRationalSchema, DimensionVectorSchema, MathPresentationSchema, MathProvenanceSchema, MathUncertaintySchema, MathNumSchema, MathQtySchema, MathSymSchema, MathUnparsedSchema, MathSymbolEntrySchema, MathUnitSchema, MathNormalisationContextSchema, SymbolTableSchema) -- see that test file's own top comment for why this is the only structural defence this generator has against silently drifting away from the schemas it's meant to describe. +// 2. content-json-schema-defs.test.ts (src/, run directly by vitest's "unit" project against source, never against dist), which imports this exact same CONTENT_DEFS value straight from here and asserts it stays byte-for-byte in step with a live z.toJSONSchema() call over each fragment's real exported Zod schema counterpart (ContentParagraphSchema, ContentRunSchema, ContentListMembershipSchema, ContentImageBlockSchema, ContentPageBreakSchema, ColorSchema, BoxSchema, LayoutFrameSchema, PageSizeSchema, MarginsSchema, AlignmentSchema, ContentStrokeStyleSchema, ContentBorderSchema, ContentCellBordersSchema, the package tree's non-recursive descriptors and anchors from src/package-node.ts: SectionDescriptorSchema, SlideDescriptorSchema, SheetDescriptorSchema, DrawPageDescriptorSchema, ShapeDescriptorSchema, HeadingParagraphSchema, ListParagraphSchema, the sheet grid and vector leaves from src/content.ts: ContentSheetCellSchema, ContentCellValueSchema, ContentSheetCellCommentSchema, ContentSheetColumnSchema, ContentSheetRowSchema, ContentSheetPrintSettingsSchema, ContentSheetPrintRangeSchema, ContentSheetRepeatRangeSchema, ContentSheetImageSchema, ContentStrokeSchema, ContentPathPointSchema, ContentPathSegmentSchema, ContentSubpathSchema, ContentVectorSchema, and the definitions facility from src/definitions.ts: StyleParagraphPropertiesSchema, StyleRunPropertiesSchema, StyleEntrySchema, DefinitionEntrySchema, plus the non-recursive math leaves from src/math.ts: ExactRationalSchema, DimensionVectorSchema, MathPresentationSchema, MathProvenanceSchema, MathUncertaintySchema, MathNumSchema, MathQtySchema, MathSymSchema, MathUnparsedSchema, MathSymbolEntrySchema, MathUnitSchema, MathNormalisationContextSchema, SymbolTableSchema) -- see that test file's own top comment for why this is the only structural defence this generator has against silently drifting away from the schemas it's meant to describe. // // If CONTENT_DEFS stayed inline in the .mjs script, only path 1 above would work: the script imports Zod schemas exclusively from '../dist/index.js' (a build artefact that may not exist, and per eslint.config.ts/tsconfig.json is deliberately excluded from both linting and typechecking, matching test/smoke.test.mjs's own precedent) -- a test that has to import through that path would only ever run after a build, which `pnpm test` (the "unit" vitest project, run standalone in CI's own "test" job, with no build step beforehand) never guarantees. Living here instead, this is an ordinary, fully typechecked and linted src module like any other -- CONTENT_DEFS just happens to be consumed by a script as well as by the package's own test suite. // -// The fragments below still cover exactly what scripts/generate-json-schemas.mjs's own top-of-file comment already explains: ContentBlockSchema, ContentEmbeddedObjectSchema, MathMlNodeSchema, and MathExpressionSchema are z.custom() predicates z.toJSONSchema() cannot introspect at all (recursion the pinned Zod version's z.lazy() can't express -- see src/content.ts's isContentBlock/isContentEmbeddedObject, src/mathml.ts's isMathMlNode, and src/math.ts's isMathExpression), so every schema reachable only through one of those four is transcribed by hand here, field-for-field, from the real Zod object definitions. Two further schemas are transcribed despite being real z.objects themselves: ContentFormulaSchema (its mathml/content fields reach the opaque MathMlNodeSchema/MathExpressionSchema nodes, exactly like ContentTableCellSchema's blocks) and SymbolTableSchema (transcribed so each ContentDocument arm's symbolTable field is one named $ref rather than five inlined copies of the whole unit-registry subtree) -- the generator's override() replaces both with a $ref to their fragments here. Anything transcribed here that DOES have a real, non-custom, exported Zod schema counterpart is exactly what content-json-schema-defs.test.ts holds to a live z.toJSONSchema() comparison; re-verify the rest (ContentTableCell/ContentTableRow/ContentTable, ContentEmbeddedObjectBlock, MathMlElement/MathMlNode, MathApp/MathSum/MathProd/MathMatrix/MathExpression, ContentFormula) against src/content.ts/src/mathml.ts/src/math.ts by hand whenever those files' field shapes change, exactly as before. +// The fragments below still cover exactly what scripts/generate-json-schemas.mjs's own top-of-file comment already explains: ContentBlockSchema, ContentEmbeddedObjectSchema, MathMlNodeSchema, and MathExpressionSchema are z.custom() predicates z.toJSONSchema() cannot introspect at all (recursion the pinned Zod version's z.lazy() can't express -- see src/content.ts's isContentBlock/isContentEmbeddedObject, src/mathml.ts's isMathMlNode, and src/math.ts's isMathExpression), so every schema reachable only through one of those four is transcribed by hand here, field-for-field, from the real Zod object definitions. The package tree added its own opaque set in the 4.0.0 major: DocumentPackageSchema's children reach the tree's per-kind group schemas (src/package-node.ts, all z.custom over recursive guards), so the whole PackageNode vocabulary -- container descriptors, anchor paragraphs, the seven group wrappers, and the sheet-image/vector leaves -- is transcribed here too, and the generator splices CONTENT_DEFS into document-package.schema.json as well as content-document.schema.json so both files resolve their local #/$defs pointers without depending on each other's file layout (the one deliberate cross-file ref stays $defs.ContentEmbeddedObject(Block)'s document pointer, CONTENT_DOCUMENT_URI). Three further schemas are transcribed despite being real z.objects themselves: ContentFormulaSchema (its mathml/content fields reach the opaque MathMlNodeSchema/MathExpressionSchema nodes, exactly like ContentTableCellSchema's blocks), SymbolTableSchema (transcribed so each ContentDocument arm's symbolTable field is one named $ref rather than five inlined copies of the whole unit-registry subtree), and now StyleEntrySchema/DefinitionEntrySchema (same five-copies reason for the package arms' styles/definitions fields) -- the generator's override() replaces each with a $ref to its fragment here. Anything transcribed here that DOES have a real, non-custom, exported Zod schema counterpart is exactly what content-json-schema-defs.test.ts holds to a live z.toJSONSchema() comparison; re-verify the rest (ContentTableCell/ContentTableRow/ContentTable, ContentEmbeddedObject(Block), the seven group wrappers, MathMlElement/MathMlNode, MathApp/MathSum/MathProd/MathMatrix/MathExpression, ContentFormula) against src/content.ts/src/package-node.ts/src/mathml.ts/src/math.ts by hand whenever those files' field shapes change, exactly as before. type JsonSchema = z.core.JSONSchema.JSONSchema; @@ -241,6 +241,613 @@ export const CONTENT_DEFS: Record = { { $ref: '#/$defs/ContentEmbeddedObjectBlock' }, ], }, + // -- The package tree (src/package-node.ts), reached through DocumentPackageSchema's children -- + // + // Everything in this block is here because the tree's group schemas are z.custom() guards z.toJSONSchema() cannot walk, so the descriptors, anchors, leaves, and wrappers underneath them exist only as these fragments. The descriptors, anchors, and leaves have real exported Zod counterparts built from the content schemas by omit+extend, and content-json-schema-defs.test.ts holds each to a live comparison; only the seven group wrappers (recursive through their children arrays) and ContentEmbeddedObject (the z.custom-backed interface with no z.object at all) are hand-verified alone. + PageSize: { + type: 'object', + properties: { + widthPt: { type: 'number', exclusiveMinimum: 0 }, + heightPt: { type: 'number', exclusiveMinimum: 0 }, + }, + required: ['widthPt', 'heightPt'], + additionalProperties: false, + }, + Margins: { + type: 'object', + properties: { + topPt: { type: 'number', minimum: 0 }, + rightPt: { type: 'number', minimum: 0 }, + bottomPt: { type: 'number', minimum: 0 }, + leftPt: { type: 'number', minimum: 0 }, + }, + required: ['topPt', 'rightPt', 'bottomPt', 'leftPt'], + additionalProperties: false, + }, + SectionDescriptor: { + type: 'object', + properties: { + pageSize: { $ref: '#/$defs/PageSize' }, + margins: { $ref: '#/$defs/Margins' }, + kind: { type: 'string', const: 'section' }, + }, + required: ['pageSize', 'margins', 'kind'], + additionalProperties: false, + }, + SlideDescriptor: { + type: 'object', + properties: { + size: { $ref: '#/$defs/PageSize' }, + notes: { type: 'string' }, + kind: { type: 'string', const: 'slide' }, + }, + required: ['size', 'notes', 'kind'], + additionalProperties: false, + }, + SheetDescriptor: { + type: 'object', + properties: { + name: { type: 'string' }, + cells: { type: 'array', items: { $ref: '#/$defs/ContentSheetCell' } }, + columns: { type: 'array', items: { $ref: '#/$defs/ContentSheetColumn' } }, + rows: { type: 'array', items: { $ref: '#/$defs/ContentSheetRow' } }, + printSettings: { $ref: '#/$defs/ContentSheetPrintSettings' }, + kind: { type: 'string', const: 'sheet' }, + }, + required: ['name', 'cells', 'columns', 'rows', 'printSettings', 'kind'], + additionalProperties: false, + }, + DrawPageDescriptor: { + type: 'object', + properties: { + size: { $ref: '#/$defs/PageSize' }, + kind: { type: 'string', const: 'drawPage' }, + }, + required: ['size', 'kind'], + additionalProperties: false, + }, + // A shape group's node payload -- the one descriptor with no kind tag, since ContentShape carries none; identified structurally by its frame and insets. strictObject in the source (src/package-node.ts) is what rejects a raw flat ContentShape's blocks key here, matching additionalProperties: false plus blocks' absence. + ShapeDescriptor: { + type: 'object', + properties: { + name: { type: 'string' }, + frame: { $ref: '#/$defs/Box' }, + rotationDeg: { type: 'number' }, + insetLeftPt: { type: 'number', minimum: 0 }, + insetTopPt: { type: 'number', minimum: 0 }, + insetRightPt: { type: 'number', minimum: 0 }, + insetBottomPt: { type: 'number', minimum: 0 }, + fontScale: { type: 'number', exclusiveMinimum: 0 }, + lineSpacingReduction: { type: 'number', minimum: 0 }, + paintOrder: { type: 'number' }, + sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, + }, + required: ['frame', 'insetLeftPt', 'insetTopPt', 'insetRightPt', 'insetBottomPt'], + additionalProperties: false, + }, + // The heading-group anchor: ContentParagraphSchema's every field with headingLevel required (ContentParagraphSchema.extend in src/package-node.ts). + HeadingParagraph: { + type: 'object', + properties: { + kind: { type: 'string', const: 'paragraph' }, + runs: { type: 'array', items: { $ref: '#/$defs/ContentRun' } }, + styleId: { type: 'string' }, + headingLevel: { type: 'integer', exclusiveMinimum: 0, maximum: MAX_SAFE_INTEGER }, + alignment: { $ref: '#/$defs/Alignment' }, + list: { $ref: '#/$defs/ContentListMembership' }, + spacingBeforePt: { type: 'number' }, + spacingAfterPt: { type: 'number' }, + lineSpacing: { type: 'number', exclusiveMinimum: 0 }, + indentLeftPt: { type: 'number' }, + indentFirstLinePt: { type: 'number' }, + sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, + }, + required: ['kind', 'runs', 'headingLevel'], + additionalProperties: false, + }, + // The list-group anchor: ContentParagraphSchema's every field with list required. + ListParagraph: { + type: 'object', + properties: { + kind: { type: 'string', const: 'paragraph' }, + runs: { type: 'array', items: { $ref: '#/$defs/ContentRun' } }, + styleId: { type: 'string' }, + headingLevel: { type: 'integer', exclusiveMinimum: 0, maximum: MAX_SAFE_INTEGER }, + alignment: { $ref: '#/$defs/Alignment' }, + list: { $ref: '#/$defs/ContentListMembership' }, + spacingBeforePt: { type: 'number' }, + spacingAfterPt: { type: 'number' }, + lineSpacing: { type: 'number', exclusiveMinimum: 0 }, + indentLeftPt: { type: 'number' }, + indentFirstLinePt: { type: 'number' }, + sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, + }, + required: ['kind', 'runs', 'list'], + additionalProperties: false, + }, + ContentSheetCell: { + type: 'object', + properties: { + row: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + column: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + value: { $ref: '#/$defs/ContentCellValue' }, + formula: { type: 'string' }, + displayText: { type: 'string' }, + runs: { type: 'array', items: { $ref: '#/$defs/ContentRun' } }, + colSpan: { type: 'integer', exclusiveMinimum: 0, maximum: MAX_SAFE_INTEGER }, + rowSpan: { type: 'integer', exclusiveMinimum: 0, maximum: MAX_SAFE_INTEGER }, + background: { $ref: '#/$defs/Color' }, + borders: { $ref: '#/$defs/ContentCellBorders' }, + alignment: { $ref: '#/$defs/Alignment' }, + verticalAlignment: { type: 'string', enum: ['top', 'middle', 'bottom'] }, + comment: { $ref: '#/$defs/ContentSheetCellComment' }, + sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, + }, + required: ['row', 'column', 'value', 'displayText'], + additionalProperties: false, + }, + // A cell's own computed/typed value, one variant per ODF office:value-type plus dateTime -- the ten-member discriminated union in declared order (src/content.ts's ContentCellValueSchema). + ContentCellValue: { + oneOf: [ + { + type: 'object', + properties: { + kind: { type: 'string', const: 'number' }, + value: { type: 'number' }, + exactValue: { type: 'string', pattern: '^-?(0|[1-9]\\d*)(\\.\\d+)?$' }, + }, + required: ['kind', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { type: 'string', const: 'percentage' }, + value: { type: 'number' }, + exactValue: { type: 'string', pattern: '^-?(0|[1-9]\\d*)(\\.\\d+)?$' }, + }, + required: ['kind', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { type: 'string', const: 'currency' }, + value: { type: 'number' }, + currency: { type: 'string' }, + exactValue: { type: 'string', pattern: '^-?(0|[1-9]\\d*)(\\.\\d+)?$' }, + }, + required: ['kind', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'boolean' }, value: { type: 'boolean' } }, + required: ['kind', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'date' }, value: { type: 'string' } }, + required: ['kind', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'time' }, value: { type: 'string' } }, + required: ['kind', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'dateTime' }, value: { type: 'string' } }, + required: ['kind', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'string' }, value: { type: 'string' } }, + required: ['kind', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'error' }, value: { type: 'string' } }, + required: ['kind', 'value'], + additionalProperties: false, + }, + { + type: 'object', + properties: { kind: { type: 'string', const: 'empty' } }, + required: ['kind'], + additionalProperties: false, + }, + ], + }, + ContentSheetCellComment: { + type: 'object', + properties: { + text: { type: 'string' }, + author: { type: 'string' }, + createdAt: { type: 'string' }, + replies: { + type: 'array', + items: { + type: 'object', + properties: { text: { type: 'string' }, author: { type: 'string' } }, + required: ['text'], + additionalProperties: false, + }, + }, + }, + required: ['text'], + additionalProperties: false, + }, + ContentSheetColumn: { + type: 'object', + properties: { + index: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + widthPt: { type: 'number', exclusiveMinimum: 0 }, + hidden: { type: 'boolean' }, + }, + required: ['index'], + additionalProperties: false, + }, + ContentSheetRow: { + type: 'object', + properties: { + index: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + heightPt: { type: 'number', exclusiveMinimum: 0 }, + hidden: { type: 'boolean' }, + }, + required: ['index'], + additionalProperties: false, + }, + ContentSheetPrintSettings: { + type: 'object', + properties: { + pageSize: { $ref: '#/$defs/PageSize' }, + margins: { $ref: '#/$defs/Margins' }, + printRange: { $ref: '#/$defs/ContentSheetPrintRange' }, + scalePercent: { type: 'number', exclusiveMinimum: 0 }, + fitToPages: { + type: 'object', + properties: { + width: { type: 'integer', exclusiveMinimum: 0, maximum: MAX_SAFE_INTEGER }, + height: { type: 'integer', exclusiveMinimum: 0, maximum: MAX_SAFE_INTEGER }, + }, + required: ['width', 'height'], + additionalProperties: false, + }, + repeatRows: { $ref: '#/$defs/ContentSheetRepeatRange' }, + repeatColumns: { $ref: '#/$defs/ContentSheetRepeatRange' }, + gridlines: { type: 'boolean' }, + headers: { type: 'boolean' }, + pageOrder: { type: 'string', enum: ['downThenOver', 'overThenDown'] }, + manualBreaks: { + type: 'object', + properties: { + rows: { type: 'array', items: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER } }, + columns: { type: 'array', items: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER } }, + }, + required: ['rows', 'columns'], + additionalProperties: false, + }, + }, + required: ['pageSize', 'margins', 'gridlines', 'headers', 'pageOrder'], + additionalProperties: false, + }, + ContentSheetPrintRange: { + type: 'object', + properties: { + startRow: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + startColumn: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + endRow: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + endColumn: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + }, + required: ['startRow', 'startColumn', 'endRow', 'endColumn'], + additionalProperties: false, + }, + ContentSheetRepeatRange: { + type: 'object', + properties: { + start: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + end: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + }, + required: ['start', 'end'], + additionalProperties: false, + }, + // A sheet-anchored image leaf: ContentImageBlockSchema's own fields plus the four required cell-anchor placement fields (ContentImageBlockSchema.extend, src/content.ts). + ContentSheetImage: { + type: 'object', + properties: { + kind: { type: 'string', const: 'image' }, + format: { type: 'string', enum: ['png', 'jpeg'] }, + base64: { type: 'string' }, + widthPt: { type: 'number', exclusiveMinimum: 0 }, + heightPt: { type: 'number', exclusiveMinimum: 0 }, + altText: { type: 'string' }, + sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, + anchorRow: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + anchorColumn: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + offsetXPt: { type: 'number' }, + offsetYPt: { type: 'number' }, + }, + required: ['kind', 'format', 'base64', 'widthPt', 'heightPt', 'anchorRow', 'anchorColumn', 'offsetXPt', 'offsetYPt'], + additionalProperties: false, + }, + ContentStroke: { + type: 'object', + properties: { + color: { $ref: '#/$defs/Color' }, + widthPt: { type: 'number', exclusiveMinimum: 0 }, + style: { $ref: '#/$defs/ContentStrokeStyle' }, + }, + required: ['color', 'widthPt'], + additionalProperties: false, + }, + ContentPathPoint: { + type: 'object', + properties: { + xPt: { type: 'number' }, + yPt: { type: 'number' }, + }, + required: ['xPt', 'yPt'], + additionalProperties: false, + }, + ContentPathSegment: { + oneOf: [ + { + type: 'object', + properties: { kind: { type: 'string', const: 'line' }, to: { $ref: '#/$defs/ContentPathPoint' } }, + required: ['kind', 'to'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { type: 'string', const: 'cubic' }, + control1: { $ref: '#/$defs/ContentPathPoint' }, + control2: { $ref: '#/$defs/ContentPathPoint' }, + to: { $ref: '#/$defs/ContentPathPoint' }, + }, + required: ['kind', 'control1', 'control2', 'to'], + additionalProperties: false, + }, + ], + }, + ContentSubpath: { + type: 'object', + properties: { + start: { $ref: '#/$defs/ContentPathPoint' }, + segments: { type: 'array', items: { $ref: '#/$defs/ContentPathSegment' } }, + closed: { type: 'boolean' }, + }, + required: ['start', 'segments', 'closed'], + additionalProperties: false, + }, + // The textless vector primitives, in their declared variant order (rect / ellipse / line / path, src/content.ts's ContentVectorSchema). + ContentVector: { + oneOf: [ + { + type: 'object', + properties: { + kind: { type: 'string', const: 'rect' }, + frame: { $ref: '#/$defs/Box' }, + rotationDeg: { type: 'number' }, + fill: { $ref: '#/$defs/Color' }, + stroke: { $ref: '#/$defs/ContentStroke' }, + paintOrder: { type: 'number' }, + sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, + }, + required: ['kind', 'frame'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { type: 'string', const: 'ellipse' }, + frame: { $ref: '#/$defs/Box' }, + rotationDeg: { type: 'number' }, + fill: { $ref: '#/$defs/Color' }, + stroke: { $ref: '#/$defs/ContentStroke' }, + paintOrder: { type: 'number' }, + sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, + }, + required: ['kind', 'frame'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { type: 'string', const: 'line' }, + from: { $ref: '#/$defs/ContentPathPoint' }, + to: { $ref: '#/$defs/ContentPathPoint' }, + stroke: { $ref: '#/$defs/ContentStroke' }, + paintOrder: { type: 'number' }, + sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, + }, + required: ['kind', 'from', 'to', 'stroke'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + kind: { type: 'string', const: 'path' }, + frame: { $ref: '#/$defs/Box' }, + rotationDeg: { type: 'number' }, + subpaths: { type: 'array', items: { $ref: '#/$defs/ContentSubpath' } }, + fill: { $ref: '#/$defs/Color' }, + fillRule: { type: 'string', enum: ['nonzero', 'evenodd'] }, + stroke: { $ref: '#/$defs/ContentStroke' }, + paintOrder: { type: 'number' }, + sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, + }, + required: ['kind', 'frame', 'subpaths'], + additionalProperties: false, + }, + ], + }, + // An embedded object on its own (the sheet-children leaf position) -- the same member fields as ContentEmbeddedObjectBlock above minus the block-level kind discriminant, transcribed from the ContentEmbeddedObject interface (src/content.ts), which has no z.object() counterpart at all. + ContentEmbeddedObject: { + type: 'object', + properties: { + objectKind: { type: 'string', enum: EMBEDDED_OBJECT_KINDS }, + document: { $ref: CONTENT_DOCUMENT_URI }, + frame: { $ref: '#/$defs/Box' }, + anchorRow: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + anchorColumn: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + offsetXPt: { type: 'number' }, + offsetYPt: { type: 'number' }, + }, + required: ['objectKind', 'document', 'frame'], + additionalProperties: false, + }, + // The seven group wrappers, hand-verified alone (recursive through their children arrays): `{ node, style?, children }` where children's permitted members are exactly that group kind's own child types (src/package-node.ts's per-kind guards). A wordprocessing section's flow. + SectionGroup: { + type: 'object', + properties: { + node: { $ref: '#/$defs/SectionDescriptor' }, + style: { type: 'string' }, + children: { + type: 'array', + items: { oneOf: [{ $ref: '#/$defs/HeadingGroup' }, { $ref: '#/$defs/ListGroup' }, { $ref: '#/$defs/ContentBlock' }] }, + }, + }, + required: ['node', 'children'], + additionalProperties: false, + }, + HeadingGroup: { + type: 'object', + properties: { + node: { $ref: '#/$defs/HeadingParagraph' }, + style: { type: 'string' }, + children: { + type: 'array', + items: { oneOf: [{ $ref: '#/$defs/HeadingGroup' }, { $ref: '#/$defs/ListGroup' }, { $ref: '#/$defs/ContentBlock' }] }, + }, + }, + required: ['node', 'children'], + additionalProperties: false, + }, + ListGroup: { + type: 'object', + properties: { + node: { $ref: '#/$defs/ListParagraph' }, + style: { type: 'string' }, + children: { + type: 'array', + items: { oneOf: [{ $ref: '#/$defs/ListGroup' }, { $ref: '#/$defs/ContentBlock' }] }, + }, + }, + required: ['node', 'children'], + additionalProperties: false, + }, + // A slide holds shape groups only, in shape order -- grouping never crosses a shape boundary (a slide's paragraphs across its shapes is the outline's lossy TOC projection, not a decomposition). + SlideGroup: { + type: 'object', + properties: { + node: { $ref: '#/$defs/SlideDescriptor' }, + style: { type: 'string' }, + children: { type: 'array', items: { $ref: '#/$defs/ShapeGroup' } }, + }, + required: ['node', 'children'], + additionalProperties: false, + }, + ShapeGroup: { + type: 'object', + properties: { + node: { $ref: '#/$defs/ShapeDescriptor' }, + style: { type: 'string' }, + children: { + type: 'array', + items: { oneOf: [{ $ref: '#/$defs/ListGroup' }, { $ref: '#/$defs/ContentBlock' }] }, + }, + }, + required: ['node', 'children'], + additionalProperties: false, + }, + // A sheet's children: its anchored images then its whole embedded documents, in that fixed order; the grid rides the sheet descriptor. + SheetGroup: { + type: 'object', + properties: { + node: { $ref: '#/$defs/SheetDescriptor' }, + style: { type: 'string' }, + children: { + type: 'array', + items: { oneOf: [{ $ref: '#/$defs/ContentSheetImage' }, { $ref: '#/$defs/ContentEmbeddedObject' }] }, + }, + }, + required: ['node', 'children'], + additionalProperties: false, + }, + // A drawing page's children: shape groups then vector leaves, in that fixed order. + DrawPageGroup: { + type: 'object', + properties: { + node: { $ref: '#/$defs/DrawPageDescriptor' }, + style: { type: 'string' }, + children: { + type: 'array', + items: { oneOf: [{ $ref: '#/$defs/ShapeGroup' }, { $ref: '#/$defs/ContentVector' }] }, + }, + }, + required: ['node', 'children'], + additionalProperties: false, + }, + // -- The definitions facility (src/definitions.ts), reached through DocumentPackageSchema's styles/definitions fields -- + StyleParagraphProperties: { + type: 'object', + properties: { + alignment: { $ref: '#/$defs/Alignment' }, + list: { $ref: '#/$defs/ContentListMembership' }, + spacingBeforePt: { type: 'number' }, + spacingAfterPt: { type: 'number' }, + lineSpacing: { type: 'number', exclusiveMinimum: 0 }, + indentLeftPt: { type: 'number' }, + indentFirstLinePt: { type: 'number' }, + }, + additionalProperties: false, + }, + StyleRunProperties: { + type: 'object', + properties: { + bold: { type: 'boolean' }, + italic: { type: 'boolean' }, + underline: { type: 'boolean' }, + strike: { type: 'boolean' }, + fontFamily: { type: 'string' }, + sizePt: { type: 'number', exclusiveMinimum: 0 }, + color: { $ref: '#/$defs/Color' }, + }, + additionalProperties: false, + }, + StyleEntry: { + type: 'object', + properties: { + paragraph: { $ref: '#/$defs/StyleParagraphProperties' }, + run: { $ref: '#/$defs/StyleRunProperties' }, + }, + additionalProperties: false, + }, + // A tenant-generic definitions-table entry: a required `kind` discriminator plus an open body whose keys belong to the tenant's vocabulary, never this package's -- the empty additionalProperties schema is JSON Schema's "anything", the emitted form of z.looseObject (src/definitions.ts). + DefinitionEntry: { + type: 'object', + properties: { + kind: { type: 'string' }, + }, + required: ['kind'], + additionalProperties: {}, + }, // The MathML node tree carried by the ContentDocument 'formula' variant's own ContentFormulaSchema.mathml (src/content.ts), reached through MathMlNodeSchema -- the third z.custom() node, transcribed field-for-field from src/mathml.ts's own real Zod definitions (MathMlAttributeSchema/MathMlTextSchema/MathMlCdataSchema/MathMlCommentSchema/MathMlDeclarationSchema/MathMlPiSchema) plus the MathMlElement interface, which has no z.object() counterpart usable here for the same reason ContentTableCell doesn't: MathMlElementSchema.children is z.array(MathMlNodeSchema), so converting it drags in the opaque custom node again. MathMlAttribute: { type: 'object', diff --git a/src/content.test.ts b/src/content.test.ts index 623cb1d..d2a7d6d 100644 --- a/src/content.test.ts +++ b/src/content.test.ts @@ -3,7 +3,6 @@ import { COLOR_BLACK } from './color'; import { type ContentBlock, ContentBlockSchema, - CONTENT_FORMAT_VERSION, type ContentDocument, ContentDocumentSchema, type ContentEmbeddedObject, @@ -136,7 +135,6 @@ describe('isContentBlock', () => { function wordprocessingDocument(): ContentDocument { return { kind: 'wordprocessing', - formatVersion: CONTENT_FORMAT_VERSION, metadata: { title: 'Deep nesting test', author: 'documents.js', @@ -156,7 +154,6 @@ function wordprocessingDocument(): ContentDocument { function presentationDocument(): ContentDocument { return { kind: 'presentation', - formatVersion: CONTENT_FORMAT_VERSION, metadata: { title: 'Deck' }, slides: [ { @@ -192,7 +189,6 @@ function presentationDocument(): ContentDocument { function spreadsheetDocument(): ContentDocument { return { kind: 'spreadsheet', - formatVersion: CONTENT_FORMAT_VERSION, metadata: { title: 'Quarterly figures' }, sheets: [ { @@ -276,7 +272,6 @@ function spreadsheetDocument(): ContentDocument { function drawingDocument(): ContentDocument { return { kind: 'drawing', - formatVersion: CONTENT_FORMAT_VERSION, metadata: { title: 'Org chart' }, pages: [ { @@ -341,7 +336,6 @@ function drawingDocument(): ContentDocument { function formulaDocument(): ContentDocument { return { kind: 'formula', - formatVersion: CONTENT_FORMAT_VERSION, metadata: { title: 'Pythagoras' }, formula: { mathml: [ @@ -386,7 +380,6 @@ describe('ContentDocument formula variant', () => { it('rejects a malformed MathML node buried inside the tree, not just at the outermost element', () => { const malformed: unknown = { kind: 'formula', - formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, formula: { mathml: [ @@ -406,7 +399,6 @@ describe('ContentDocument formula variant', () => { it('parses with starMath omitted, since MathML alone is the authoritative content', () => { const parsed = ContentDocumentSchema.parse({ kind: 'formula', - formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, formula: { mathml: [{ type: 'element', tag: 'math', attributes: [], children: [] }] }, }); @@ -421,7 +413,6 @@ describe('ContentDocument formula variant', () => { function layeredFormulaDocument(): ContentDocument { return { kind: 'formula', - formatVersion: CONTENT_FORMAT_VERSION, metadata: { title: 'Pythagoras, both layers' }, formula: { mathml: [], @@ -535,7 +526,6 @@ describe('an embedded formula object carrying a real formula document', () => { expect( ContentDocumentSchema.safeParse({ kind: 'wordprocessing', - formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, sections: [ { @@ -870,17 +860,6 @@ describe('ContentDocumentSchema round trips', () => { it('rejects an unknown discriminant', () => { expect(ContentDocumentSchema.safeParse({ kind: 'bogus' }).success).toBe(false); }); - - it('rejects a mismatched formatVersion', () => { - expect( - ContentDocumentSchema.safeParse({ - kind: 'wordprocessing', - formatVersion: 999, - metadata: {}, - sections: [], - }).success, - ).toBe(false); - }); }); // Deliberately deep nesting for ContentEmbeddedObjectSchema's own recursive guard, mirroring the discipline already applied to ContentTable's three-level recursion test above: a formula embedded inside a drawing embedded inside a spreadsheet, three levels deep, exercising both anchoring mechanisms (ContentSheetSchema.embeddedObjects at level 1->2, and the ContentBlock 'embeddedObject' variant at level 2->3) in the same structure. The innermost document is a genuine 'formula'-kind ContentDocument (reusing the fixture above), so this also drives the recursion down through the custom MathMlNode guard, not only through the block model. @@ -893,7 +872,6 @@ const formulaEmbeddedBlock: ContentBlock = { const drawingWithFormula: ContentDocument = { kind: 'drawing', - formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, pages: [ { @@ -921,7 +899,6 @@ const drawingEmbeddedObject: ContentEmbeddedObject = { const spreadsheetWithDrawing: ContentDocument = { kind: 'spreadsheet', - formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, sheets: [ { @@ -1032,7 +1009,6 @@ describe('ContentEmbeddedObjectSchema deep recursion', () => { it('rejects a malformed embedded object buried three levels deep, not just at the outermost shell', () => { const deeplyMalformed: unknown = { kind: 'spreadsheet', - formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, sheets: [ { @@ -1054,8 +1030,7 @@ describe('ContentEmbeddedObjectSchema deep recursion', () => { frame: { xPt: 100, yPt: 100, widthPt: 200, heightPt: 150 }, document: { kind: 'drawing', - formatVersion: CONTENT_FORMAT_VERSION, - metadata: {}, + metadata: {}, pages: [ { size: { widthPt: 400, heightPt: 300 }, @@ -1074,8 +1049,7 @@ describe('ContentEmbeddedObjectSchema deep recursion', () => { frame: { xPt: 10, yPt: 10, widthPt: 80, heightPt: 20 }, document: { kind: 'formula', - formatVersion: CONTENT_FORMAT_VERSION, - metadata: {}, + metadata: {}, formula: { mathml: [ { diff --git a/src/content.ts b/src/content.ts index 14f059c..337babc 100644 --- a/src/content.ts +++ b/src/content.ts @@ -8,7 +8,7 @@ import { MathMlNodeSchema } from './mathml'; import { LayoutMetadataSchema } from './metadata'; import { AlignmentSchema } from './style'; -// The shared block model underlying a wordprocessing document's sections and a presentation document's slides. Ported from ooxml.js's src/typed/shared/content.ts (itself ported from documents.js's src/model/content.ts) -- the canonical home now; ooxml.js and documents.js both import this instead of maintaining their own copy. The ContentDocument envelope below (formatVersion + kind + wordprocessing/presentation/spreadsheet/drawing/formula variants) is this package's own addition on top of that shared vocabulary, matching documents.js's existing model/content.ts shape, since a caller needs a single top-level value to carry through a conversion pipeline. +// The shared block model underlying a wordprocessing document's sections and a presentation document's slides. Ported from ooxml.js's src/typed/shared/content.ts (itself ported from documents.js's src/model/content.ts) -- the canonical home now; ooxml.js and documents.js both import this instead of maintaining their own copy. The ContentDocument envelope below (kind + wordprocessing/presentation/spreadsheet/drawing/formula variants) is this package's own addition on top of that shared vocabulary, matching documents.js's existing model/content.ts shape, since a caller needs a single top-level value to carry through a conversion pipeline. // sourcePath is assigned by each format's reader at read time; this package only defines the field, it doesn't generate values. Known limitation: sourcePath values are stable within one read+layout pass over a single document, not across edits -- inserting content earlier in a document shifts every later path. It exists for tagged/accessible-PDF-style traceability and debugging, not edit-tracking, and not (any more, see `frames` immediately below) as the mechanism a node's own rendered position is found through. @@ -519,46 +519,44 @@ export const ContentFormulaSchema = z.object({ }); export type ContentFormula = z.infer; -// Bumped whenever ContentDocumentSchema's shape changes incompatibly. 2 added the 'formula' variant below, renamed ContentSheetPrintSettings.scale to scalePercent, made ContentSheetColumn.widthPt/ContentSheetRow.heightPt optional-positive rather than required-nonnegative, and added the 'dateTime' ContentCellValue kind. 3 added the canonical, format-agnostic `headingLevel` field to ContentParagraphSchema (alongside the existing round-trip-only `styleId`), and fused DocumentPackage's own layout half directly onto the content tree: every content-kind leaf that previously carried only a `sourcePath` correlation string (ContentRun, ContentParagraph, ContentImageBlock, ContentPageBreak, ContentTable, ContentTableCell, ContentEmbeddedObjectBlock, ContentShape, every ContentVector variant, ContentSheetCell) now additionally carries an optional `frames: LayoutFrame[]` field of its own rendered page position(s) -- see FusedNode above and DOCUMENT_PACKAGE_FORMAT_VERSION in package.ts, bumped in step. -export const CONTENT_FORMAT_VERSION = 3; +// The five ContentDocument kinds, one shared declaration for every consumer that needs the union as a value or a type -- the package tree's root carries the same five (src/package.ts), and two hand copies of the list would drift the first time a kind was added. +export const CONTENT_DOCUMENT_KINDS = ['wordprocessing', 'presentation', 'spreadsheet', 'drawing', 'formula'] as const; +export type ContentDocumentKind = (typeof CONTENT_DOCUMENT_KINDS)[number]; -// Fields every one of the five ContentDocument arms below carries in addition to its own kind, formatVersion, and metadata -- currently the document-level math symbol table (SymbolTableSchema, src/math.ts): the curation layer mapping each written symbol glyph to its quantity kind, preferred unit, and definition, alongside the unit registry a formula's expressions resolve their symbol and unit references against. Spliced into each arm via spread rather than factored through a base schema the arms extend, because z.discriminatedUnion() needs each member as a plain z.object carrying its own literal `kind` field in place. Optional on every arm: a document with no lowered math content (most of them) simply omits it, and the table is presentation-inert by construction -- it curates what symbols mean, never how any formula renders -- so its presence or absence changes no rendering. It lives on the envelope, not inside LayoutMetadataSchema, because that schema is shared with LayoutDocument (src/metadata.ts) and a math curation layer there would leak onto every layout document, which carries no formulas of its own. -const contentDocumentSharedFields = { +// ContentDocument carries no formatVersion of its own: it is the in-process codec-exchange type the codecs hand each other and never a serialised artefact in its own right, so it has no version to declare. Versioning lives entirely at the serialised-artefact boundary -- a dumped document or package states its version through the release-pinned $schema URI its dumper stamped (src/schema-io.ts), which is also what an ingesting documentFromJson dispatches on. Releases 1.x-3.x carried a per-arm formatVersion literal here; 4.0.0 retired it (ExaDev/document-schema.js#20's errata). + +// Fields every one of the five ContentDocument arms below carries in addition to its own kind and metadata -- currently the document-level math symbol table (SymbolTableSchema, src/math.ts): the curation layer mapping each written symbol glyph to its quantity kind, preferred unit, and definition, alongside the unit registry a formula's expressions resolve their symbol and unit references against. Spliced into each arm via spread rather than factored through a base schema the arms extend, because z.discriminatedUnion() needs each member as a plain z.object carrying its own literal `kind` field in place. Optional on every arm: a document with no lowered math content (most of them) simply omits it, and the table is presentation-inert by construction -- it curates what symbols mean, never how any formula renders -- so its presence or absence changes no rendering. Exported because DocumentPackageSchema's own arms (src/package.ts) spread the identical field set -- one declaration, so a shared field added here reaches the package root without a second edit. +export const contentDocumentSharedFields = { symbolTable: SymbolTableSchema.optional(), }; export const ContentDocumentSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('wordprocessing'), - formatVersion: z.literal(CONTENT_FORMAT_VERSION), metadata: LayoutMetadataSchema, ...contentDocumentSharedFields, sections: z.array(ContentSectionSchema), }), z.object({ kind: z.literal('presentation'), - formatVersion: z.literal(CONTENT_FORMAT_VERSION), metadata: LayoutMetadataSchema, ...contentDocumentSharedFields, slides: z.array(ContentSlideSchema), }), z.object({ kind: z.literal('spreadsheet'), - formatVersion: z.literal(CONTENT_FORMAT_VERSION), metadata: LayoutMetadataSchema, ...contentDocumentSharedFields, sheets: z.array(ContentSheetSchema), }), z.object({ kind: z.literal('drawing'), - formatVersion: z.literal(CONTENT_FORMAT_VERSION), metadata: LayoutMetadataSchema, ...contentDocumentSharedFields, pages: z.array(ContentDrawPageSchema), }), z.object({ kind: z.literal('formula'), - formatVersion: z.literal(CONTENT_FORMAT_VERSION), metadata: LayoutMetadataSchema, ...contentDocumentSharedFields, formula: ContentFormulaSchema, diff --git a/src/definitions.test.ts b/src/definitions.test.ts new file mode 100644 index 0000000..81d5aaa --- /dev/null +++ b/src/definitions.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import type { ContentParagraph, ContentRun } from './content'; +import { + applyParagraphStyleProperties, + applyRunStyleProperties, + DefinitionEntrySchema, + DefinitionsTableSchema, + overlayStyleEntries, + resolveStyleChain, + StyleEntrySchema, + StylesTableSchema, + type StyleEntry, + type StylesTable, +} from './definitions'; + +const BODY: StyleEntry = { + paragraph: { alignment: 'justify', spacingAfterPt: 6 }, + run: { sizePt: 11 }, +}; +const EMPHASIS: StyleEntry = { + paragraph: { alignment: 'left' }, + run: { italic: true, sizePt: 14 }, +}; + +describe('StyleEntrySchema enforces the entry shape', () => { + it('accepts resolved canonical paragraph and run properties, either or both halves', () => { + expect(StyleEntrySchema.safeParse(BODY).success).toBe(true); + expect(StyleEntrySchema.safeParse({ paragraph: { lineSpacing: 1.5 } }).success).toBe(true); + expect(StyleEntrySchema.safeParse({ run: { fontFamily: 'Carlito', bold: true } }).success).toBe(true); + expect(StyleEntrySchema.safeParse({}).success).toBe(true); + }); + + it('rejects the banned per-node facts wherever they appear -- frames, sourcePath, and styleId fail outright, they are not silently stripped', () => { + for (const banned of ['frames', 'sourcePath', 'styleId']) { + expect(StyleEntrySchema.safeParse({ [banned]: 'x' }).success).toBe(false); + expect(StyleEntrySchema.safeParse({ paragraph: { [banned]: 'x' } }).success).toBe(false); + expect(StyleEntrySchema.safeParse({ run: { [banned]: 'x' } }).success).toBe(false); + } + expect(StyleEntrySchema.safeParse({ paragraph: { frames: [{ pageIndex: 0, xPt: 1, yPt: 1, widthPt: 1, heightPt: 1 }] } }).success).toBe(false); + }); + + it('rejects misnested properties -- run fields do not belong at entry level or under paragraph, and sizePt is the run field name, not fontPt', () => { + expect(StyleEntrySchema.safeParse({ bold: true }).success).toBe(false); + expect(StyleEntrySchema.safeParse({ paragraph: { bold: true } }).success).toBe(false); + expect(StyleEntrySchema.safeParse({ run: { fontPt: 11 } }).success).toBe(false); + expect(StyleEntrySchema.safeParse({ paragraph: { alignment: 'diagonal' } }).success).toBe(false); + }); + + it('rejects a basedOn-style graph edge inside the table', () => { + expect(StyleEntrySchema.safeParse({ basedOn: 'other' }).success).toBe(false); + }); +}); + +describe('StylesTableSchema', () => { + it('accepts a table of named entries and rejects a non-entry value under a key', () => { + expect(StylesTableSchema.safeParse({ s1: BODY, s2: EMPHASIS }).success).toBe(true); + expect(StylesTableSchema.safeParse({ s1: { paragraph: { frames: [] } } }).success).toBe(false); + }); +}); + +describe('the definitions facility stays tenant-generic', () => { + it('accepts and PRESERVES any tenant body -- unknown keys ride through a parse rather than being stripped', () => { + const parsed = DefinitionEntrySchema.parse({ kind: 'link', url: 'https://example.com', title: 'Example' }); + expect(parsed).toEqual({ kind: 'link', url: 'https://example.com', title: 'Example' }); + const footnote = DefinitionEntrySchema.parse({ kind: 'footnote', marker: '1', blocks: [] }); + expect(footnote).toEqual({ kind: 'footnote', marker: '1', blocks: [] }); + }); + + it('requires the tenant discriminator and rejects a non-string kind', () => { + expect(DefinitionEntrySchema.safeParse({ url: 'https://example.com' }).success).toBe(false); + expect(DefinitionEntrySchema.safeParse({ kind: 7 }).success).toBe(false); + }); + + it('carries no styles vocabulary of its own -- a StyleEntry is not a DefinitionEntry and vice versa', () => { + expect(DefinitionEntrySchema.safeParse(BODY).success).toBe(false); + expect(StyleEntrySchema.safeParse({ kind: 'link', url: 'https://example.com' }).success).toBe(false); + }); + + it('holds both tenants side by side in one table', () => { + const table = { + l1: { kind: 'link', url: 'https://example.com' }, + f1: { kind: 'footnote', marker: '1' }, + }; + expect(DefinitionsTableSchema.safeParse(table).success).toBe(true); + }); +}); + +describe('overlayStyleEntries', () => { + it('innermost wins per property; a property only outer carries falls through', () => { + const merged = overlayStyleEntries(BODY, EMPHASIS); + expect(merged.paragraph).toEqual({ alignment: 'left', spacingAfterPt: 6 }); + expect(merged.run).toEqual({ sizePt: 14, italic: true }); + }); + + it('emits no key for a half neither side carries', () => { + const merged = overlayStyleEntries({}, { run: { bold: true } }); + expect(merged).toEqual({ run: { bold: true } }); + expect('paragraph' in merged).toBe(false); + }); + + it('explicitly-present-undefined inner values do not overwrite outer -- absence is not a value', () => { + const merged = overlayStyleEntries(BODY, { paragraph: { alignment: undefined } }); + expect(merged.paragraph).toEqual({ alignment: 'justify', spacingAfterPt: 6 }); + }); +}); + +describe('resolveStyleChain', () => { + const styles: StylesTable = { base: BODY, emphasis: EMPHASIS }; + + it('folds refs outermost-first with innermost winning', () => { + expect(resolveStyleChain(styles, ['base', 'emphasis'])).toEqual(overlayStyleEntries(BODY, EMPHASIS)); + expect(resolveStyleChain(styles, ['emphasis', 'base'])).toEqual(overlayStyleEntries(EMPHASIS, BODY)); + }); + + it('resolves one ref to itself and zero refs to an empty entry', () => { + expect(resolveStyleChain(styles, ['base'])).toEqual(BODY); + expect(resolveStyleChain(styles, [])).toEqual({}); + }); + + it('throws on a ref naming no entry -- an unresolvable ref is loud, never a silent skip', () => { + expect(() => resolveStyleChain(styles, ['base', 'missing'])).toThrow(/missing/); + }); +}); + +describe('applyParagraphStyleProperties and applyRunStyleProperties', () => { + it('fills only the gaps: the node\'s own direct properties win, style values supply the rest', () => { + const paragraph: ContentParagraph = { + kind: 'paragraph', + runs: [], + alignment: 'center', + }; + const effective = applyParagraphStyleProperties(BODY.paragraph, paragraph); + expect(effective.alignment).toBe('center'); + expect(effective.spacingAfterPt).toBe(6); + expect(effective).not.toBe(paragraph); + expect(paragraph.spacingAfterPt).toBeUndefined(); + }); + + it('returns the input object itself when there is nothing to apply', () => { + const paragraph: ContentParagraph = { kind: 'paragraph', runs: [] }; + expect(applyParagraphStyleProperties(undefined, paragraph)).toBe(paragraph); + const run: ContentRun = { text: 'x' }; + expect(applyRunStyleProperties(undefined, run)).toBe(run); + }); + + it('applies run defaults under the run\'s own properties -- the chain\'s one extra level down', () => { + const run: ContentRun = { text: 'x', sizePt: 9 }; + const effective = applyRunStyleProperties(EMPHASIS.run, run); + expect(effective.sizePt).toBe(9); + expect(effective.italic).toBe(true); + }); + + it('leaves non-style fields (text, hyperlink, sourcePath, frames) untouched', () => { + const run: ContentRun = { text: 'x', hyperlink: 'https://example.com' }; + const effective = applyRunStyleProperties({ bold: true }, run); + expect(effective.hyperlink).toBe('https://example.com'); + expect(effective.text).toBe('x'); + }); +}); diff --git a/src/definitions.ts b/src/definitions.ts new file mode 100644 index 0000000..326a56f --- /dev/null +++ b/src/definitions.ts @@ -0,0 +1,146 @@ +import { z } from 'zod'; +import { ColorSchema } from './color'; +import { ContentListMembershipSchema, type ContentParagraph, type ContentRun } from './content'; +import { AlignmentSchema } from './style'; + +// The package-level definitions-table facility (ExaDev/document-schema.js#21): named tables at the DocumentPackage root whose entries tree nodes reference by string id, so repeated data is stated once and referenced many times. Styles are the first tenant (the StylesTableSchema below); link and footnote definitions are future tenants of the same mechanism (ExaDev/markdown-codec#63, ExaDev/document-schema.js#22) -- which is why the generic DefinitionsTableSchema exists alongside the styles-specific one rather than the facility being shaped around styles. This module defines the schemas and the pure resolution helpers only; minting entries (the frequency pass that factors repeated property tuples into table refs) is documents.js's boundary behaviour, not this package's. + +// The paragraph half of a style entry: exactly the canonical ContentParagraph direct properties that a style may carry, and nothing else. Deliberately strict rather than plain: strictObject REJECTS a smuggled extra key instead of silently stripping it, which is what makes the ban list a schema-shape guarantee rather than a documented convention -- frames, sourcePath, and styleId are per-node facts (a position is a fact about a node, not a style; sourcePath and styleId identify the node and its producer-side style), so an entry carrying any of them fails validation outright instead of parsing to a value that quietly dropped them (ExaDev/document-schema.js#21's errata). +export const StyleParagraphPropertiesSchema = z.strictObject({ + alignment: AlignmentSchema.optional(), + list: ContentListMembershipSchema.optional(), + spacingBeforePt: z.number().optional(), + spacingAfterPt: z.number().optional(), + lineSpacing: z.number().positive().optional(), // multiple of single line height, matching ContentParagraphSchema's own field + indentLeftPt: z.number().optional(), + indentFirstLinePt: z.number().optional(), +}); +export type StyleParagraphProperties = z.infer; + +// The run half of a style entry: the canonical ContentRun direct formatting properties. sizePt is the real field name (ContentRunSchema's own) -- the issue text's "fontPt" was a typo, corrected in its errata comment. Same strictness and the same ban-list reasoning as StyleParagraphPropertiesSchema above. +export const StyleRunPropertiesSchema = z.strictObject({ + bold: z.boolean().optional(), + italic: z.boolean().optional(), + underline: z.boolean().optional(), + strike: z.boolean().optional(), + fontFamily: z.string().optional(), + sizePt: z.number().positive().optional(), + color: ColorSchema.optional(), +}); +export type StyleRunProperties = z.infer; + +// One styles-table entry: resolved canonical properties only, split by the level they apply at. Never a basedOn graph inside the table (the entry is a dictionary value, not a program -- resolution is one overlay chain computed by the consumer, see resolveStyleChain below), never frames/sourcePath/styleId (the two strict sub-objects above are the entire legal field set, so the ban list holds no matter how the entry is constructed). +export const StyleEntrySchema = z.strictObject({ + paragraph: StyleParagraphPropertiesSchema.optional(), + run: StyleRunPropertiesSchema.optional(), +}); +export type StyleEntry = z.infer; + +// The styles tenant of the definitions facility: string id -> resolved entry. Ids are minted by the producer's factoring pass (s1, s2, ... in deterministic order -- minting determinism is documents.js's law iii), and a tree node's `style` ref names a key in exactly this record. +export const StylesTableSchema = z.record(z.string(), StyleEntrySchema); +export type StylesTable = z.infer; + +// The tenant-generic half of the facility: any table of definitions whose entries are not styles. Each entry carries a `kind` string naming its tenant (a future link definition is { kind: 'link', url, ... }, a footnote definition { kind: 'footnote', ... }) and an open body belonging to that tenant's own vocabulary -- this package defines the mechanism and the discriminator, never the per-tenant fields, so a new tenant lands additively without this schema changing. Deliberately loose rather than strict: the whole point is that the body's keys are not this package's to enumerate, so unknown keys are preserved through a parse rather than stripped. +export const DefinitionEntrySchema = z.looseObject({ + kind: z.string(), +}); +export type DefinitionEntry = z.infer; + +export const DefinitionsTableSchema = z.record(z.string(), DefinitionEntrySchema); +export type DefinitionsTable = z.infer; + +// Field-wise overlay of two style entries: for every property of both halves, inner's value (when present) wins over outer's; a property absent from inner falls through to outer. Explicitly-present-undefined inner values do not overwrite outer -- a key set to undefined is JSON-identical to an absent key (it never serialises), and treating it as a value would make the overlay's result depend on how the producer spelled absence. +export function overlayStyleEntries(outer: StyleEntry, inner: StyleEntry): StyleEntry { + const paragraph = overlayParagraphProperties(outer.paragraph, inner.paragraph); + const run = overlayRunProperties(outer.run, inner.run); + return { + ...(paragraph !== undefined ? { paragraph } : {}), + ...(run !== undefined ? { run } : {}), + }; +} + +function overlayParagraphProperties( + outer: StyleParagraphProperties | undefined, + inner: StyleParagraphProperties | undefined, +): StyleParagraphProperties | undefined { + if (outer === undefined) return inner; + if (inner === undefined) return outer; + const merged: StyleParagraphProperties = { ...outer }; + if (inner.alignment !== undefined) merged.alignment = inner.alignment; + if (inner.list !== undefined) merged.list = inner.list; + if (inner.spacingBeforePt !== undefined) merged.spacingBeforePt = inner.spacingBeforePt; + if (inner.spacingAfterPt !== undefined) merged.spacingAfterPt = inner.spacingAfterPt; + if (inner.lineSpacing !== undefined) merged.lineSpacing = inner.lineSpacing; + if (inner.indentLeftPt !== undefined) merged.indentLeftPt = inner.indentLeftPt; + if (inner.indentFirstLinePt !== undefined) merged.indentFirstLinePt = inner.indentFirstLinePt; + return merged; +} + +function overlayRunProperties( + outer: StyleRunProperties | undefined, + inner: StyleRunProperties | undefined, +): StyleRunProperties | undefined { + if (outer === undefined) return inner; + if (inner === undefined) return outer; + const merged: StyleRunProperties = { ...outer }; + if (inner.bold !== undefined) merged.bold = inner.bold; + if (inner.italic !== undefined) merged.italic = inner.italic; + if (inner.underline !== undefined) merged.underline = inner.underline; + if (inner.strike !== undefined) merged.strike = inner.strike; + if (inner.fontFamily !== undefined) merged.fontFamily = inner.fontFamily; + if (inner.sizePt !== undefined) merged.sizePt = inner.sizePt; + if (inner.color !== undefined) merged.color = inner.color; + return merged; +} + +// Folds one node's full overlay chain into a single effective entry: refs ordered outermost first (the nearest ancestor group's style, then each further-out one, ending with the node's own group ref -- however many levels the tree actually uses). An unknown ref throws rather than resolving to nothing, because a package whose tree references an id the styles table does not carry is malformed and a silent skip would quietly drop that level of the chain -- consistency between refs and the table is the producer's responsibility (the same deliberate non-enforcement DocumentPackageSchema applies to pages-versus-frames), but once resolution runs, it runs loudly. +export function resolveStyleChain(styles: StylesTable, refs: readonly string[]): StyleEntry { + let resolved: StyleEntry = {}; + for (const ref of refs) { + const entry = styles[ref]; + if (entry === undefined) { + throw new Error(`resolveStyleChain: style ref "${ref}" names no entry in the styles table`); + } + resolved = overlayStyleEntries(resolved, entry); + } + return resolved; +} + +// Applies a resolved entry's paragraph half to one paragraph: the paragraph's own direct properties win (innermost), style-supplied values fill only the gaps. Pure -- the input paragraph is never mutated, and when the entry carries no paragraph half the input object is returned as-is (matching the family's ownership discipline of embedding rather than cloning unchanged nodes). +export function applyParagraphStyleProperties( + properties: StyleParagraphProperties | undefined, + paragraph: ContentParagraph, +): ContentParagraph { + if (properties === undefined) return paragraph; + const effective: ContentParagraph = { ...paragraph }; + if (effective.alignment === undefined && properties.alignment !== undefined) effective.alignment = properties.alignment; + if (effective.list === undefined && properties.list !== undefined) effective.list = properties.list; + if (effective.spacingBeforePt === undefined && properties.spacingBeforePt !== undefined) { + effective.spacingBeforePt = properties.spacingBeforePt; + } + if (effective.spacingAfterPt === undefined && properties.spacingAfterPt !== undefined) { + effective.spacingAfterPt = properties.spacingAfterPt; + } + if (effective.lineSpacing === undefined && properties.lineSpacing !== undefined) effective.lineSpacing = properties.lineSpacing; + if (effective.indentLeftPt === undefined && properties.indentLeftPt !== undefined) { + effective.indentLeftPt = properties.indentLeftPt; + } + if (effective.indentFirstLinePt === undefined && properties.indentFirstLinePt !== undefined) { + effective.indentFirstLinePt = properties.indentFirstLinePt; + } + return effective; +} + +// Applies a resolved entry's run half to one run, as run-level defaults: the run's own properties win, style-supplied values fill only the gaps. This is the overlay chain's one extra level down (a resolved entry's run half is the default for every run of the paragraph it resolved for, and each run's own formatting sits innermost on top of it). +export function applyRunStyleProperties(properties: StyleRunProperties | undefined, run: ContentRun): ContentRun { + if (properties === undefined) return run; + const effective: ContentRun = { ...run }; + if (effective.bold === undefined && properties.bold !== undefined) effective.bold = properties.bold; + if (effective.italic === undefined && properties.italic !== undefined) effective.italic = properties.italic; + if (effective.underline === undefined && properties.underline !== undefined) effective.underline = properties.underline; + if (effective.strike === undefined && properties.strike !== undefined) effective.strike = properties.strike; + if (effective.fontFamily === undefined && properties.fontFamily !== undefined) effective.fontFamily = properties.fontFamily; + if (effective.sizePt === undefined && properties.sizePt !== undefined) effective.sizePt = properties.sizePt; + if (effective.color === undefined && properties.color !== undefined) effective.color = properties.color; + return effective; +} diff --git a/src/index.ts b/src/index.ts index 6d837e0..908a452 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,8 @@ export * from './metadata'; export * from './mathml'; export * from './math'; export * from './content'; -export * from './layout'; +export * from './definitions'; +export * from './package-node'; export * from './package'; export * from './codec'; export * from './schema-io'; diff --git a/src/package-node.test.ts b/src/package-node.test.ts new file mode 100644 index 0000000..51290ef --- /dev/null +++ b/src/package-node.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from 'vitest'; +import type { ContentDocument, ContentEmbeddedObject, ContentFormula, ContentRun } from './content'; +import { + DrawPageGroupSchema, + HeadingGroupSchema, + isPackageGroup, + isPackageLeaf, + isPackageNode, + ListGroupSchema, + PackageGroupSchema, + PackageLeafSchema, + PackageNodeSchema, + SectionGroupSchema, + ShapeGroupSchema, + SheetGroupSchema, + SlideGroupSchema, + type DrawPageGroupNode, + type HeadingGroupNode, + type ListGroupNode, + type PackageNode, + type SectionGroupNode, + type ShapeGroupNode, + type SheetGroupNode, + type SlideGroupNode, +} from './package-node'; + +const PAGE = { widthPt: 612, heightPt: 792 }; +const MARGINS = { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }; + +function run(text: string, extra: Partial = {}): ContentRun { + return { text, ...extra }; +} + +function formula(): ContentFormula { + return { mathml: [{ type: 'element', tag: 'math', attributes: [], children: [] }] }; +} + +function embeddedFormulaDocument(): ContentDocument { + return { kind: 'formula', metadata: {}, formula: formula() }; +} + +function embeddedObject(): ContentEmbeddedObject { + return { + objectKind: 'formula', + document: embeddedFormulaDocument(), + frame: { xPt: 100, yPt: 100, widthPt: 60, heightPt: 20 }, + anchorRow: 1, + anchorColumn: 2, + offsetXPt: 4, + offsetYPt: 5, + }; +} + +// A wordprocessing section group exercising every section-child position at once: a heading group with a nested deeper heading, a list group two levels deep, and bare block leaves (a paragraph, a table, an image, a page break) -- plus a style ref on the outer heading, the one place a ref may legally sit. +function sectionGroup(): SectionGroupNode { + const heading: HeadingGroupNode = { + node: { kind: 'paragraph', headingLevel: 1, runs: [run('Heading')] }, + children: [ + { kind: 'paragraph', runs: [run('Plain leaf')] }, + { + kind: 'table', + rows: [{ cells: [{ blocks: [{ kind: 'paragraph', runs: [run('Cell')] }] }] }], + columnWidthsPt: [100], + }, + { kind: 'image', format: 'png', base64: 'aGk=', widthPt: 50, heightPt: 50 }, + { kind: 'pageBreak' }, + { + node: { kind: 'paragraph', headingLevel: 2, runs: [run('Nested heading')] }, + children: [], + }, + ], + }; + const list: ListGroupNode = { + node: { kind: 'paragraph', list: { level: 0 }, runs: [run('Item')] }, + children: [ + { + node: { kind: 'paragraph', list: { level: 1 }, runs: [run('Nested item')] }, + children: [], + }, + ], + }; + return { + node: { kind: 'section', pageSize: PAGE, margins: MARGINS }, + children: [heading, list, { kind: 'paragraph', runs: [run('After the lists')] }], + }; +} + +function slideGroup(): SlideGroupNode { + const shape: ShapeGroupNode = { + node: { + frame: { xPt: 10, yPt: 10, widthPt: 200, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [ + { + node: { kind: 'paragraph', list: { level: 0 }, runs: [run('Bullet')] }, + children: [], + }, + { kind: 'paragraph', runs: [run('Shape body')] }, + ], + }; + return { + node: { kind: 'slide', size: { widthPt: 960, heightPt: 540 }, notes: '' }, + children: [shape], + }; +} + +function sheetGroup(): SheetGroupNode { + return { + node: { + kind: 'sheet', + name: 'Sheet1', + cells: [{ row: 0, column: 0, value: { kind: 'string', value: 'A1' }, displayText: 'A1' }], + columns: [{ index: 0, widthPt: 64 }], + rows: [], + printSettings: { + pageSize: PAGE, + margins: MARGINS, + gridlines: false, + headers: false, + pageOrder: 'downThenOver', + }, + }, + children: [ + { + kind: 'image', + format: 'png', + base64: 'aGk=', + widthPt: 50, + heightPt: 50, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, + }, + embeddedObject(), + ], + }; +} + +function drawPageGroup(): DrawPageGroupNode { + const shape: ShapeGroupNode = { + node: { + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + }, + children: [], + }; + return { + node: { kind: 'drawPage', size: PAGE }, + children: [ + shape, + { + kind: 'line', + from: { xPt: 0, yPt: 0 }, + to: { xPt: 10, yPt: 10 }, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + }, + ], + }; +} + +describe('the package tree accepts a real tree of every kind', () => { + it('validates a wordprocessing section group with heading, list, and leaf children (SectionGroupSchema)', () => { + expect(SectionGroupSchema.safeParse(sectionGroup()).success).toBe(true); + }); + + it('validates a presentation slide group of shape groups (SlideGroupSchema)', () => { + expect(SlideGroupSchema.safeParse(slideGroup()).success).toBe(true); + }); + + it('validates a spreadsheet sheet group whose grid rides the node and whose children are images and embedded documents (SheetGroupSchema)', () => { + expect(SheetGroupSchema.safeParse(sheetGroup()).success).toBe(true); + }); + + it('validates a drawing page group of shape groups and vector leaves (DrawPageGroupSchema)', () => { + expect(DrawPageGroupSchema.safeParse(drawPageGroup()).success).toBe(true); + }); + + it('validates the individual group schemas against the same trees (HeadingGroup/ListGroup/ShapeGroup)', () => { + const section = sectionGroup(); + const heading = section.children[0]; + if (!isPackageGroup(heading) || !('headingLevel' in heading.node)) throw new Error('fixture shape'); + expect(HeadingGroupSchema.safeParse(heading).success).toBe(true); + const list = section.children[1]; + if (!isPackageGroup(list)) throw new Error('fixture shape'); + expect(ListGroupSchema.safeParse(list).success).toBe(true); + const slide = slideGroup(); + expect(ShapeGroupSchema.safeParse(slide.children[0]).success).toBe(true); + }); + + it('accepts a style ref on every group wrapper position, and a present-but-empty table-free group', () => { + const styled: SectionGroupNode = { + node: { kind: 'section', pageSize: PAGE, margins: MARGINS }, + style: 's1', + children: [ + { node: { kind: 'paragraph', headingLevel: 1, runs: [run('H')] }, style: 's2', children: [] }, + ], + }; + expect(SectionGroupSchema.safeParse(styled).success).toBe(true); + }); + + it('keeps an embedded document intact as one leaf, recursively through its own ContentDocument', () => { + expect(PackageLeafSchema.safeParse(embeddedObject()).success).toBe(true); + expect(isPackageLeaf(embeddedObject())).toBe(true); + expect(isPackageNode(embeddedObject())).toBe(true); + }); + + it('round-trips every kind of tree through JSON and revalidates identically', () => { + for (const group of [sectionGroup(), slideGroup(), sheetGroup(), drawPageGroup()]) { + const roundTripped: unknown = JSON.parse(JSON.stringify(group)); + expect(PackageGroupSchema.safeParse(roundTripped).success).toBe(true); + expect(PackageNodeSchema.safeParse(roundTripped).success).toBe(true); + } + }); +}); + +describe('the package tree rejects near-misses', () => { + it('rejects a group wrapper with no children array', () => { + const broken = { node: { kind: 'section', pageSize: PAGE, margins: MARGINS } }; + expect(SectionGroupSchema.safeParse(broken).success).toBe(false); + }); + + it('rejects a raw flat container posed as a descriptor -- a section node missing its kind tag', () => { + const broken = { node: { pageSize: PAGE, margins: MARGINS }, children: [] }; + expect(SectionGroupSchema.safeParse(broken).success).toBe(false); + }); + + it('rejects a shape descriptor still carrying its blocks -- the omitted array is banned, not merely absent', () => { + const broken = { + node: { + frame: { xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [], + }, + children: [], + }; + expect(ShapeGroupSchema.safeParse(broken).success).toBe(false); + }); + + it('rejects a slide group with a paragraph leaf child -- a slide holds shape groups only', () => { + const slide = slideGroup(); + const broken = { ...slide, children: [...slide.children, { kind: 'paragraph', runs: [run('stray')] }] }; + expect(SlideGroupSchema.safeParse(broken).success).toBe(false); + }); + + it('rejects a sheet group with a block-flow child -- a sheet holds images and embedded documents only', () => { + const sheet = sheetGroup(); + const broken = { ...sheet, children: [...sheet.children, { kind: 'paragraph', runs: [run('stray')] }] }; + expect(SheetGroupSchema.safeParse(broken).success).toBe(false); + }); + + it('rejects a heading group under a list group -- heading never appears below a list', () => { + const broken = { + node: { kind: 'paragraph', list: { level: 0 }, runs: [run('Item')] }, + children: [{ node: { kind: 'paragraph', headingLevel: 1, runs: [run('H')] }, children: [] }], + }; + expect(ListGroupSchema.safeParse(broken).success).toBe(false); + }); + + it('rejects a heading group whose anchor carries no headingLevel, and a list group whose anchor carries no list', () => { + const notHeading = { node: { kind: 'paragraph', runs: [run('plain')] }, children: [] }; + expect(HeadingGroupSchema.safeParse(notHeading).success).toBe(false); + const notList = { node: { kind: 'paragraph', runs: [run('plain')] }, children: [] }; + expect(ListGroupSchema.safeParse(notList).success).toBe(false); + }); + + it('rejects a non-string style ref', () => { + const broken = { + node: { kind: 'section', pageSize: PAGE, margins: MARGINS }, + style: 7, + children: [], + }; + expect(SectionGroupSchema.safeParse(broken).success).toBe(false); + }); + + it('rejects a malformed leaf payload at a child position (an image whose width is not a number)', () => { + const broken = { + node: { kind: 'section', pageSize: PAGE, margins: MARGINS }, + children: [{ kind: 'image', format: 'png', base64: 'aGk=', widthPt: 'wide', heightPt: 50 }], + }; + expect(SectionGroupSchema.safeParse(broken).success).toBe(false); + }); + + it('never confuses the two classes: a group does not validate as a leaf, a leaf does not validate as a group', () => { + const section = sectionGroup(); + expect(isPackageLeaf(section)).toBe(false); + const paragraph = { kind: 'paragraph', runs: [run('leaf')] } as const; + expect(isPackageGroup(paragraph)).toBe(false); + expect(isPackageLeaf(paragraph)).toBe(true); + }); + + it('a heading paragraph is also a legal bare leaf (a valid ContentBlock), while its group wrapper is not a leaf', () => { + const headingParagraph = { kind: 'paragraph', headingLevel: 1, runs: [run('H')] } as const; + expect(isPackageLeaf(headingParagraph)).toBe(true); + const wrapper: PackageNode = { node: { kind: 'paragraph', headingLevel: 1, runs: [run('H')] }, children: [] }; + expect(isPackageGroup(wrapper)).toBe(true); + expect(isPackageLeaf(wrapper)).toBe(false); + }); +}); diff --git a/src/package-node.ts b/src/package-node.ts new file mode 100644 index 0000000..10fa29e --- /dev/null +++ b/src/package-node.ts @@ -0,0 +1,238 @@ +import { z } from 'zod'; +import { + ContentBlockSchema, + ContentDrawPageSchema, + ContentEmbeddedObjectSchema, + ContentFormulaSchema, + ContentListMembershipSchema, + ContentParagraphSchema, + ContentSectionSchema, + ContentShapeSchema, + ContentSheetImageSchema, + ContentSheetSchema, + ContentSlideSchema, + ContentVectorSchema, + type ContentBlock, + type ContentEmbeddedObject, + type ContentFormula, + type ContentSheetImage, + type ContentVector, +} from './content'; + +// The package tree's node vocabulary (ExaDev/document-schema.js#20's promoted DocumentPackage, as proven by document-outline.js's phase-1 reference implementation -- this module is that shape's schema-home port). Groups are `{ node, children }` where node embeds either an anchor paragraph (heading and list groups carry the full ContentParagraph, runs and formatting and frames included, never a projected text label) or a container descriptor (section / slide / sheet / drawPage, each tagged with a `kind` the flat container type does not carry). Bare leaves carry their own `kind` and never `children` -- discrimination is structural on node+children, because the earlier "anything with kind is a leaf" rule collided with `{ kind: 'slide' }` groups. Grouping never crosses container boundaries: a shape is its own group with its inner blocks grouped inside it, a sheet's grid rides on the sheet node, and an embedded document (the recursive ContentEmbeddedObject arm) stays intact as one leaf. A group may additionally carry `style` -- a string ref into the package's styles table (ExaDev/document-schema.js#21); refs exist only here, never on ContentDocument nodes, so the flat codec-exchange form is always fully materialised. + +// The descriptors are built from the content schemas themselves by omit+extend rather than re-declared field by field, so a field added to a container schema in a future release rides its descriptor automatically -- the zod-first spelling of the reference implementation's `Omit & { kind: 'section' }` types. Each is strict: the omitted array (the one whose members became the group's children) is rejected, not merely absent, so a raw flat container smuggled in as a descriptor fails validation instead of parsing to a descriptor that silently dropped its content. +export const SectionDescriptorSchema = ContentSectionSchema.omit({ blocks: true }) + .extend({ kind: z.literal('section') }) + .strict(); +export type SectionDescriptor = z.infer; + +export const SlideDescriptorSchema = ContentSlideSchema.omit({ shapes: true }) + .extend({ kind: z.literal('slide') }) + .strict(); +export type SlideDescriptor = z.infer; + +export const SheetDescriptorSchema = ContentSheetSchema.omit({ images: true, embeddedObjects: true }) + .extend({ kind: z.literal('sheet') }) + .strict(); +export type SheetDescriptor = z.infer; + +export const DrawPageDescriptorSchema = ContentDrawPageSchema.omit({ shapes: true, vectors: true }) + .extend({ kind: z.literal('drawPage') }) + .strict(); +export type DrawPageDescriptor = z.infer; + +// A shape group's node payload: every ContentShape field except its blocks, which the group's children carry grouped by list level. Unlike the four container descriptors this carries no `kind` tag, because ContentShape has none to give -- a shape group is identified structurally by its frame and insets. +export const ShapeDescriptorSchema = ContentShapeSchema.omit({ blocks: true }).strict(); +export type ShapeDescriptor = z.infer; + +// The anchor of a heading group: the heading paragraph itself, embedded whole. The tree never reduces a heading to its text -- that is the outline package's OutlineNode projection, and a decomposition that kept only labels would be lossy by construction. headingLevel moves from optional to required here because a paragraph with neither headingLevel nor list membership is block-flow content, not a group anchor, and belongs at a leaf position -- a tree that wrapped one in { node, children } does not validate. +export const HeadingParagraphSchema = ContentParagraphSchema.extend({ + headingLevel: z.number().int().positive(), +}); +export type HeadingParagraph = z.infer; + +// The anchor of a list-item group: the list paragraph itself, embedded whole, for the same reason. list is required here for the same reason headingLevel is above. +export const ListParagraphSchema = ContentParagraphSchema.extend({ + list: ContentListMembershipSchema, +}); +export type ListParagraph = z.infer; + +// The leaf payloads of a package tree, across all five document kinds: wordprocessing/presentation/drawing block flow yields ContentBlock leaves, spreadsheets additionally yield sheet-anchored images (ContentSheetImage) and whole embedded documents (ContentEmbeddedObject, which is not itself a ContentBlock -- it has no `kind` discriminator), drawings yield textless vector primitives (ContentVector), and a formula document yields its single ContentFormula. One union, so one guard set serves every kind. +export type PackageLeaf = ContentBlock | ContentSheetImage | ContentEmbeddedObject | ContentVector | ContentFormula; + +// What a section's (or a heading group's) block flow holds: heading groups, list groups, and bare block leaves. Headings nest under headings and lists nest inside the open heading scope or under deeper list items; a plain paragraph is a leaf. +export type SectionChild = HeadingGroupNode | ListGroupNode | ContentBlock; + +// What a shape's block flow holds: list groups and bare leaves only. Shapes carry no heading hierarchy of their own -- list.level is the only depth signal a slide or drawing shape's paragraphs actually carry -- so a paragraph with headingLevel but no list membership sits flat as a leaf here. +export type ShapeChild = ListGroupNode | ContentBlock; + +// A list group's children: deeper list groups and block leaves. A heading never appears below a list group, because opening a heading resets the list nesting before it opens its own group. +export type ListChild = ListGroupNode | ContentBlock; + +// What a sheet's children are: its anchored images and its whole embedded documents, in that order (the two live in sibling arrays with no cross-array ordering field, and flatten's type partition reverses this fixed order). Cells are addressable data, never children -- they ride the sheet descriptor. +export type SheetChild = ContentSheetImage | ContentEmbeddedObject; + +// What a drawing page's children are: shape groups then vector leaves, in that fixed order (again sibling arrays with no cross-array ordering; flatten's partition reverses it exactly). +export type DrawPageChild = ShapeGroupNode | ContentVector; + +export interface SectionGroupNode { + readonly node: SectionDescriptor; + style?: string; + children: SectionChild[]; +} + +export interface SlideGroupNode { + readonly node: SlideDescriptor; + style?: string; + children: ShapeGroupNode[]; +} + +export interface SheetGroupNode { + readonly node: SheetDescriptor; + style?: string; + children: SheetChild[]; +} + +export interface DrawPageGroupNode { + readonly node: DrawPageDescriptor; + style?: string; + children: DrawPageChild[]; +} + +export interface ShapeGroupNode { + readonly node: ShapeDescriptor; + style?: string; + children: ShapeChild[]; +} + +export interface HeadingGroupNode { + readonly node: HeadingParagraph; + style?: string; + children: SectionChild[]; +} + +export interface ListGroupNode { + readonly node: ListParagraph; + style?: string; + children: ListChild[]; +} + +export type PackageGroup = + | SectionGroupNode + | SlideGroupNode + | SheetGroupNode + | DrawPageGroupNode + | ShapeGroupNode + | HeadingGroupNode + | ListGroupNode; + +export type PackageNode = PackageGroup | PackageLeaf; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +// The shared wrapper shape every group guard checks: a record whose `node` is itself a record, whose `children` is an array of values each satisfying that group kind's own child predicate, and whose optional `style` ref is a string when present. Per-kind child predicates (not one generic isPackageNode) are what make these guards the untrusted-input boundary: a tree that hangs a paragraph leaf directly off a slide group, or a section group off a sheet, is structurally illegal and rejects here, where the reference implementation's own guard checks children generically (it walks trees it constructed itself; this schema's job is to validate trees it did not). +function isGroupWrapper(value: Record, isChild: (child: unknown) => boolean): boolean { + if (!isRecord(value.node)) return false; + if (value.style !== undefined && typeof value.style !== 'string') return false; + return Array.isArray(value.children) && value.children.every(isChild); +} + +function isSectionChild(value: unknown): value is SectionChild { + return isHeadingGroupNode(value) || isListGroupNode(value) || ContentBlockSchema.safeParse(value).success; +} + +function isShapeChild(value: unknown): value is ShapeChild { + return isListGroupNode(value) || ContentBlockSchema.safeParse(value).success; +} + +function isListChild(value: unknown): value is ListChild { + return isListGroupNode(value) || ContentBlockSchema.safeParse(value).success; +} + +function isSheetChild(value: unknown): value is SheetChild { + return ContentSheetImageSchema.safeParse(value).success || ContentEmbeddedObjectSchema.safeParse(value).success; +} + +function isDrawPageChild(value: unknown): value is DrawPageChild { + return isShapeGroupNode(value) || ContentVectorSchema.safeParse(value).success; +} + +export function isSectionGroupNode(value: unknown): value is SectionGroupNode { + return ( + isRecord(value) && + isGroupWrapper(value, isSectionChild) && + SectionDescriptorSchema.safeParse(value.node).success + ); +} + +export function isSlideGroupNode(value: unknown): value is SlideGroupNode { + return isRecord(value) && isGroupWrapper(value, isShapeGroupNode) && SlideDescriptorSchema.safeParse(value.node).success; +} + +export function isSheetGroupNode(value: unknown): value is SheetGroupNode { + return isRecord(value) && isGroupWrapper(value, isSheetChild) && SheetDescriptorSchema.safeParse(value.node).success; +} + +export function isDrawPageGroupNode(value: unknown): value is DrawPageGroupNode { + return ( + isRecord(value) && + isGroupWrapper(value, isDrawPageChild) && + DrawPageDescriptorSchema.safeParse(value.node).success + ); +} + +export function isShapeGroupNode(value: unknown): value is ShapeGroupNode { + return isRecord(value) && isGroupWrapper(value, isShapeChild) && ShapeDescriptorSchema.safeParse(value.node).success; +} + +export function isHeadingGroupNode(value: unknown): value is HeadingGroupNode { + return isRecord(value) && isGroupWrapper(value, isSectionChild) && HeadingParagraphSchema.safeParse(value.node).success; +} + +export function isListGroupNode(value: unknown): value is ListGroupNode { + return isRecord(value) && isGroupWrapper(value, isListChild) && ListParagraphSchema.safeParse(value.node).success; +} + +// Leaf validation delegates to the content model's own exported schemas rather than hand-rolling a second, parallel structural guard per payload -- the shapes are src/content.ts's to own, and a hand copy here would drift the first time a schema field changes. The union's first-match-wins order is safe because no leaf type is a structural subset of a later member that would change the verdict. +const packageLeafUnion = z.union([ + ContentBlockSchema, + ContentSheetImageSchema, + ContentEmbeddedObjectSchema, + ContentVectorSchema, + ContentFormulaSchema, +]); + +export function isPackageLeaf(value: unknown): value is PackageLeaf { + return packageLeafUnion.safeParse(value).success; +} + +export function isPackageGroup(value: unknown): value is PackageGroup { + return ( + isSectionGroupNode(value) || + isSlideGroupNode(value) || + isSheetGroupNode(value) || + isDrawPageGroupNode(value) || + isShapeGroupNode(value) || + isHeadingGroupNode(value) || + isListGroupNode(value) + ); +} + +export function isPackageNode(value: unknown): value is PackageNode { + return isPackageGroup(value) || isPackageLeaf(value); +} + +// The zod faces of the guards above -- usable wherever a schema value is needed (array element, object property, safeParse of external input). Deliberately z.custom, not z.lazy: z.lazy() collapses the static type of a recursive schema to `unknown` under the pinned zod 4, so the recursion lives in the plain function guards instead (ContentBlockSchema in src/content.ts is the family precedent, OutlineNodeSchema in document-outline.js the direct one). +export const SectionGroupSchema = z.custom(isSectionGroupNode); +export const SlideGroupSchema = z.custom(isSlideGroupNode); +export const SheetGroupSchema = z.custom(isSheetGroupNode); +export const DrawPageGroupSchema = z.custom(isDrawPageGroupNode); +export const ShapeGroupSchema = z.custom(isShapeGroupNode); +export const HeadingGroupSchema = z.custom(isHeadingGroupNode); +export const ListGroupSchema = z.custom(isListGroupNode); +export const PackageGroupSchema = z.custom(isPackageGroup); +export const PackageLeafSchema = z.custom(isPackageLeaf); +export const PackageNodeSchema = z.custom(isPackageNode); diff --git a/src/package.test.ts b/src/package.test.ts index a345b07..ae81fc4 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -1,27 +1,33 @@ import { describe, expect, it } from 'vitest'; -import { CONTENT_FORMAT_VERSION, type ContentDocument } from './content'; -import { DOCUMENT_PACKAGE_FORMAT_VERSION, type DocumentPackage, DocumentPackageSchema } from './package'; +import { type DocumentPackage, DocumentPackageSchema } from './package'; -function wordprocessingDocument(): ContentDocument { +const PAGE = { widthPt: 612, heightPt: 792 }; +const MARGINS = { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }; + +// A wordprocessing package in the tree form: the root carries kind/metadata/pages, and one section group per section with the section's own blocks grouped inside it -- a heading group wrapping a leaf paragraph, plus the section's own trailing leaf. +function wordprocessingPackage(): DocumentPackage { return { kind: 'wordprocessing', - formatVersion: CONTENT_FORMAT_VERSION, - metadata: { title: 'Package round trip', author: 'document-content-model' }, - sections: [ + metadata: { title: 'Package round trip', author: 'document-schema.js' }, + pages: [PAGE], + children: [ { - pageSize: { widthPt: 612, heightPt: 792 }, - margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, - blocks: [ + node: { kind: 'section', pageSize: PAGE, margins: MARGINS }, + children: [ { - kind: 'paragraph', - runs: [ - { - text: 'Hello, package.', - // A run rendered onto a single page -- the frame's own pageIndex matches DocumentPackage.pages' own array index below. - frames: [{ pageIndex: 0, xPt: 72, yPt: 720, widthPt: 96, heightPt: 12 }], - }, - ], - frames: [{ pageIndex: 0, xPt: 72, yPt: 720, widthPt: 96, heightPt: 12 }], + node: { + kind: 'paragraph', + headingLevel: 1, + runs: [ + { + text: 'Hello, package.', + // A run rendered onto a single page -- the frame's own pageIndex matches the root pages array's own index. + frames: [{ pageIndex: 0, xPt: 72, yPt: 720, widthPt: 96, heightPt: 12 }], + }, + ], + frames: [{ pageIndex: 0, xPt: 72, yPt: 720, widthPt: 96, heightPt: 12 }], + }, + children: [{ kind: 'paragraph', runs: [{ text: 'Body under the heading.' }] }], }, ], }, @@ -29,24 +35,38 @@ function wordprocessingDocument(): ContentDocument { }; } -// A paragraph whose own rendered content is split across two pages -- the fusion design's whole reason for `frames` being an array rather than a single optional frame: one semantic node, two rendered positions, no duplication of the node itself. -function wordprocessingDocumentSpanningTwoPages(): ContentDocument { +// A spreadsheet package whose sheet group carries its grid on the node and an anchored image child -- the other end of the per-kind children typing. +function spreadsheetPackage(): DocumentPackage { return { - kind: 'wordprocessing', - formatVersion: CONTENT_FORMAT_VERSION, - metadata: { title: 'Package round trip (paginated)' }, - sections: [ + kind: 'spreadsheet', + metadata: {}, + children: [ { - pageSize: { widthPt: 612, heightPt: 792 }, - margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, - blocks: [ + node: { + kind: 'sheet', + name: 'Sheet1', + cells: [{ row: 0, column: 0, value: { kind: 'number', value: 1 }, displayText: '1' }], + columns: [], + rows: [], + printSettings: { + pageSize: PAGE, + margins: MARGINS, + gridlines: true, + headers: true, + pageOrder: 'downThenOver', + }, + }, + children: [ { - kind: 'paragraph', - runs: [{ text: 'A paragraph that wraps across a page boundary.' }], - frames: [ - { pageIndex: 0, xPt: 72, yPt: 60, widthPt: 468, heightPt: 24 }, - { pageIndex: 1, xPt: 72, yPt: 720, widthPt: 200, heightPt: 12 }, - ], + kind: 'image', + format: 'png', + base64: 'aGk=', + widthPt: 50, + heightPt: 50, + anchorRow: 0, + anchorColumn: 0, + offsetXPt: 0, + offsetYPt: 0, }, ], }, @@ -54,98 +74,97 @@ function wordprocessingDocumentSpanningTwoPages(): ContentDocument { }; } -describe('DocumentPackageSchema round trips', () => { - it('deep-equals the original package after a JSON round trip when pages/frames are present', () => { - const original: DocumentPackage = { - formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, - content: wordprocessingDocument(), - pages: [{ widthPt: 612, heightPt: 792 }], - }; - const parsed = DocumentPackageSchema.parse(original); - const roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); - expect(DocumentPackageSchema.parse(roundTripped)).toEqual(original); - }); +// A formula package: the one kind whose single child is a leaf, not a group. +function formulaPackage(): DocumentPackage { + return { + kind: 'formula', + metadata: {}, + children: [{ mathml: [{ type: 'element', tag: 'math', attributes: [], children: [] }] }], + }; +} - it('deep-equals the original package after a JSON round trip when pages/frames are absent (content-only)', () => { - const original: DocumentPackage = { - formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, - content: wordprocessingDocument(), - }; +describe('DocumentPackageSchema round trips (tree form)', () => { + it('deep-equals the original package after a JSON round trip when pages/frames are present', () => { + const original = wordprocessingPackage(); const parsed = DocumentPackageSchema.parse(original); const roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); expect(DocumentPackageSchema.parse(roundTripped)).toEqual(original); }); - it('serializes with pages omitted entirely, not as null or an empty array', () => { - const original: DocumentPackage = { - formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, - content: wordprocessingDocument(), - }; + it('deep-equals a content-only package (no pages, no styles, no definitions) after a JSON round trip', () => { + const original = wordprocessingPackage(); + delete original.pages; const parsed = DocumentPackageSchema.parse(original); expect(parsed.pages).toBeUndefined(); - const serialized: unknown = JSON.parse(JSON.stringify(parsed)); expect(serialized).not.toHaveProperty('pages'); + expect(DocumentPackageSchema.parse(serialized)).toEqual(original); }); - it('rejects a mismatched formatVersion', () => { - expect(DocumentPackageSchema.safeParse({ formatVersion: 1, content: wordprocessingDocument() }).success).toBe( - false, - ); + it('round trips a spreadsheet package and a formula package', () => { + for (const original of [spreadsheetPackage(), formulaPackage()]) { + const parsed = DocumentPackageSchema.parse(original); + const roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); + expect(DocumentPackageSchema.parse(roundTripped)).toEqual(original); + } }); - it('accepts a single content node carrying more than one frame -- appearing on multiple pages without duplicating content', () => { - const original: DocumentPackage = { - formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, - content: wordprocessingDocumentSpanningTwoPages(), - pages: [ - { widthPt: 612, heightPt: 792 }, - { widthPt: 612, heightPt: 792 }, - ], + it('tolerates and strips an incoming $schema key, and accepts styles and definitions tables at the root', () => { + const original = wordprocessingPackage(); + const withTables = { + $schema: 'https://cdn.jsdelivr.net/npm/document-schema.js@4.0.0/schemas/document-package.schema.json', + ...original, + styles: { s1: { paragraph: { alignment: 'justify' }, run: { sizePt: 11 } } }, + definitions: { l1: { kind: 'link', url: 'https://example.com' } }, }; - const parsed = DocumentPackageSchema.parse(original); - if (parsed.content.kind !== 'wordprocessing') { - throw new Error('expected a wordprocessing document'); - } - const paragraph = parsed.content.sections[0]?.blocks[0]; - if (paragraph?.kind !== 'paragraph') { - throw new Error('expected a paragraph'); - } - expect(paragraph.frames).toHaveLength(2); - expect(paragraph.frames?.[0]?.pageIndex).toBe(0); - expect(paragraph.frames?.[1]?.pageIndex).toBe(1); + const parsed = DocumentPackageSchema.parse(withTables); + expect(parsed.styles).toEqual({ s1: { paragraph: { alignment: 'justify' }, run: { sizePt: 11 } } }); + expect(parsed.definitions).toEqual({ l1: { kind: 'link', url: 'https://example.com' } }); + expect('$schema' in parsed).toBe(false); + }); - const roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); - expect(DocumentPackageSchema.parse(roundTripped)).toEqual(original); + it('rejects the retired 3.x flat shape -- a value with no children and no tree kind at the root', () => { + const oldShape = { + formatVersion: 2, + content: { kind: 'wordprocessing', metadata: {}, sections: [{ pageSize: PAGE, margins: MARGINS, blocks: [] }] }, + pages: [PAGE], + }; + expect(DocumentPackageSchema.safeParse(oldShape).success).toBe(false); }); - // ContentShapeSchema (unlike ContentParagraph, which is only ever reached inside a ContentBlockSchema z.custom() guard -- see content.ts's own top comment on that guard's deliberately minimal depth) is a real, directly-nested Zod schema on ContentSlideSchema.shapes, so a malformed field on it genuinely fails a full DocumentPackageSchema parse rather than only a standalone ContentShapeSchema.parse. - it('rejects a frame with a negative or non-integer pageIndex', () => { - const withBadFrame = { - formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, - content: { - kind: 'presentation', - formatVersion: CONTENT_FORMAT_VERSION, - metadata: {}, - slides: [ - { - size: { widthPt: 960, heightPt: 540 }, - shapes: [ - { - frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, - insetLeftPt: 0, - insetTopPt: 0, - insetRightPt: 0, - insetBottomPt: 0, - blocks: [], - frames: [{ pageIndex: -1, xPt: 0, yPt: 0, widthPt: 10, heightPt: 10 }], - }, - ], - notes: '', - }, - ], - }, + it('rejects a root child of the wrong group kind for the package kind', () => { + const mixed = { + kind: 'presentation', + metadata: {}, + children: [{ node: { kind: 'section', pageSize: PAGE, margins: MARGINS }, children: [] }], }; - expect(DocumentPackageSchema.safeParse(withBadFrame).success).toBe(false); + expect(DocumentPackageSchema.safeParse(mixed).success).toBe(false); + }); + + it('rejects a malformed leaf deep in the tree (a style entry carrying the banned frames key, and a non-group slide child)', () => { + const withBannedStyle = { + ...wordprocessingPackage(), + styles: { s1: { frames: [] } }, + }; + expect(DocumentPackageSchema.safeParse(withBannedStyle).success).toBe(false); + + const slideWithStrayParagraph = { + kind: 'presentation', + metadata: {}, + children: [ + { + node: { kind: 'slide', size: { widthPt: 960, heightPt: 540 }, notes: '' }, + children: [{ kind: 'paragraph', runs: [{ text: 'stray' }] }], + }, + ], + }; + expect(DocumentPackageSchema.safeParse(slideWithStrayParagraph).success).toBe(false); + }); + + it('keeps the document-level symbolTable on the package root, spliced from the same declaration the content arms use', () => { + const original = wordprocessingPackage(); + const withSymbols = { ...original, symbolTable: { symbols: [], units: [] } }; + const parsed = DocumentPackageSchema.parse(withSymbols); + expect(parsed.symbolTable).toEqual({ symbols: [], units: [] }); }); }); diff --git a/src/package.ts b/src/package.ts index 2deb737..45cd7ed 100644 --- a/src/package.ts +++ b/src/package.ts @@ -1,20 +1,50 @@ import { z } from 'zod'; -import { ContentDocumentSchema } from './content'; +import { contentDocumentSharedFields, ContentFormulaSchema } from './content'; +import { DefinitionsTableSchema, StylesTableSchema } from './definitions'; import { PageSizeSchema } from './geometry'; +import { LayoutMetadataSchema } from './metadata'; +import { DrawPageGroupSchema, SectionGroupSchema, SheetGroupSchema, SlideGroupSchema } from './package-node'; -// DocumentPackage is a fused, single-tree envelope around ContentDocument: content is required, and once something has laid the document out, that same layout is not carried as a second, independent tree -- it is fused directly onto the content tree, node by node, via each node's own optional `frames` field (src/content.ts's FusedNode pattern; see LayoutFrameSchema in src/geometry.ts). A paragraph, run, image, table, shape, vector, or spreadsheet cell that has been through a layout pass carries its own rendered page position(s) right there on the node -- no correlation step, no separate array of positioned items to walk back to their origin by matching a sourcePath string. -// -// What is left at the package level, once position moves onto the nodes themselves, is `pages`: the geometry of each rendered page a `frames` entry's own `pageIndex` refers into. `pages` is optional for the same reason DocumentPackage's old `layout` field was optional -- layout (now: page geometry plus populated `frames` fields throughout content) is a *derived* artifact, the output of running a layout algorithm against content, so a content-only package (an edit-only workflow that never touches rendering) must be constructible without eagerly running layout. A DocumentPackage whose `pages` is present but whose content nodes carry no `frames` at all (or vice versa) is not detected or rejected by this schema; keeping the two in step is entirely the producer's responsibility, exactly as keeping content and layout in step was under the old two-tree design. -// -// This is a genuinely breaking shape change from the previous `{ content, layout: LayoutDocument }` envelope (LayoutDocument -- pages of positioned, sourcePath-correlated LayoutItems -- no longer appears here at all), which is why DOCUMENT_PACKAGE_FORMAT_VERSION is bumped below. LayoutDocumentSchema itself is untouched and still exported from this package: it remains the right shape for a format with no content tree of its own to fuse onto, most notably pdf-codec's own readPdf/writePdf, which read and write a PDF's pages of positioned items directly with no ContentDocument in the loop at all. +// DocumentPackage is the single hierarchical artefact: structure, layout, and content fused in one tree (ExaDev/document-schema.js#20). The root carries what no tree node can -- the document kind (moved up from the retired flat `content` field; the empty documents are legal, so the kind cannot be inferred from the children and the envelope keeps it explicit), the required metadata, the optional document-level symbolTable (the same shared fields every ContentDocument arm spreads -- one declaration, spliced in from src/content.ts), and the envelope's three optional tables and arrays: `pages` (each rendered page's own size, indexed to match every content node's own `frames[].pageIndex` -- present once a layout pass has run, absent for a content-only package), `styles` and `definitions` (the package-level definitions-table facility, src/definitions.ts). Everything structural hangs off `children`: one group per top-level container (a section, slide, sheet, or draw page), each holding its own content tree -- see src/package-node.ts for the node vocabulary and its structural discrimination rule. -// Bumped whenever DocumentPackageSchema's own shape changes incompatibly -- independent of CONTENT_FORMAT_VERSION and LAYOUT_FORMAT_VERSION, since the envelope can change shape without either pivot changing, and vice versa. 2 replaced the separate optional `layout: LayoutDocument` field with the fused-tree design above: `pages` (page geometry only) plus each content node's own optional `frames` field (src/content.ts, CONTENT_FORMAT_VERSION bumped in step). -export const DOCUMENT_PACKAGE_FORMAT_VERSION = 2; +// The package tree and the flat ContentDocument are one format in two encodings, related by three laws (stated on the issues and proven property-wise by document-outline.js's decompose/flatten over real corpus documents, with documents.js re-running the same assertions over its own corpus at the package boundary): (i) strict structural equality holds both directions for a table-free package -- decompose(flatten(pkg)) and flatten(decompose(pkg)) reproduce it exactly; (ii) effective-property equality holds universally -- once styles are resolved (resolve-then-compare, src/definitions.ts), a factored and an unfactored serialisation of one document compare equal; (iii) minting is idempotent -- factoring a second time mints the identical table. The codecs keep producing flat ContentDocuments (their natural reading shape); decomposition runs once at the package boundary in documents.js and flatten runs once where a builder consumes a package. -export const DocumentPackageSchema = z.object({ - formatVersion: z.literal(DOCUMENT_PACKAGE_FORMAT_VERSION), - content: ContentDocumentSchema, - // Each rendered page's own size, indexed to match every content node's own `frames[].pageIndex` (src/content.ts, src/geometry.ts's LayoutFrameSchema). Absent until something has laid `content` out, mirroring the old `layout` field's own absence for a content-only package. +// This is a genuinely breaking shape change from the previous `{ formatVersion, content, pages }` envelope, which is why it rides a major (4.0.0). The old envelope's `formatVersion` field is gone with no replacement field: a serialised package states its version through the release-pinned $schema URI its dumper stamped (documentPackageWithSchema, src/schema-io.ts), and an ingesting documentFromJson dispatches on that URI -- the URI is the version, not a hand-kept integer. ContentDocument (the flat codec-exchange form) survives unchanged in role minus its own retired formatVersion literal, and nothing about the content model itself changed: every block, run, cell, and frame field a 3.x package carried still validates in its old flat shape -- only the envelope around it moved. + +// The five arms duplicate their kind literals rather than factoring through a base schema, because z.discriminatedUnion() needs each member as a plain z.object carrying its own literal `kind` field in place (the same reason ContentDocumentSchema's own arms spread contentDocumentSharedFields); the children type is the one thing that differs per arm, and the union says exactly which root group each kind takes -- a wordprocessing package of section groups, a presentation of slide groups, a spreadsheet of sheet groups, a drawing of drawPage groups, and a formula package whose single child is the ContentFormula leaf itself (a formula has no container structure to group). +const packageEnvelopeFields = { + metadata: LayoutMetadataSchema, + ...contentDocumentSharedFields, pages: z.array(PageSizeSchema).optional(), -}); + styles: StylesTableSchema.optional(), + definitions: DefinitionsTableSchema.optional(), +}; + +export const DocumentPackageSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('wordprocessing'), + ...packageEnvelopeFields, + children: z.array(SectionGroupSchema), + }), + z.object({ + kind: z.literal('presentation'), + ...packageEnvelopeFields, + children: z.array(SlideGroupSchema), + }), + z.object({ + kind: z.literal('spreadsheet'), + ...packageEnvelopeFields, + children: z.array(SheetGroupSchema), + }), + z.object({ + kind: z.literal('drawing'), + ...packageEnvelopeFields, + children: z.array(DrawPageGroupSchema), + }), + z.object({ + kind: z.literal('formula'), + ...packageEnvelopeFields, + children: z.array(ContentFormulaSchema), + }), +]); export type DocumentPackage = z.infer; diff --git a/src/schema-io.test.ts b/src/schema-io.test.ts index 6196343..01de3f6 100644 --- a/src/schema-io.test.ts +++ b/src/schema-io.test.ts @@ -1,19 +1,21 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -import { COLOR_BLACK } from './color'; -import { CONTENT_FORMAT_VERSION, type ContentDocument } from './content'; -import { LAYOUT_FORMAT_VERSION, type LayoutDocument } from './layout'; -import { DOCUMENT_PACKAGE_FORMAT_VERSION, type DocumentPackage } from './package'; +import type { ContentDocument } from './content'; +import { + type DocumentPackage, + DocumentPackageSchema, +} from './package'; +import type { SectionGroupNode } from './package-node'; import { contentDocumentWithSchema, documentFromJson, documentPackageWithSchema, documentSchemaKindOf, - layoutDocumentWithSchema, + LayoutSchemaDemotedError, + SchemaVersionMismatchError, schemaUriFor, UnrecognizedDocumentSchemaError, } from './schema-io'; -import { DEFAULT_LAYOUT_FONT } from './style'; function isPackageJsonWithVersion(value: unknown): value is { version: string } { if (typeof value !== 'object' || value === null) return false; @@ -26,11 +28,19 @@ if (!isPackageJsonWithVersion(parsedPackageJson)) { throw new Error('package.json is missing a string "version" field'); } const packageVersion: string = parsedPackageJson.version; +// The installed release's major, read the same way src/schema-io.ts's dispatch reads it -- every URI this test builds keys on this, so the suite stays correct whatever the dev package.json happens to say. +const installedMajor = Number(/^(\d+)/.exec(packageVersion)?.[1]); +const installedMajorMinusOne = installedMajor - 1; +const installedMajorPlusOne = installedMajor + 1; + +function uriForVersion(majorOrVersion: number | string, stem: 'document-package' | 'content-document' | 'layout-document'): string { + const version = typeof majorOrVersion === 'number' ? `${majorOrVersion}.0.0` : majorOrVersion; + return `https://cdn.jsdelivr.net/npm/document-schema.js@${version}/schemas/${stem}.schema.json`; +} function wordprocessingDocument(): ContentDocument { return { kind: 'wordprocessing', - formatVersion: CONTENT_FORMAT_VERSION, metadata: { title: 'schema-io fixture', author: 'document-schema.js' }, sections: [ { @@ -42,50 +52,18 @@ function wordprocessingDocument(): ContentDocument { }; } -function layoutDocument(): LayoutDocument { - return { - formatVersion: LAYOUT_FORMAT_VERSION, - metadata: { title: 'schema-io fixture', author: 'document-schema.js' }, - pages: [ - { - widthPt: 612, - heightPt: 792, - items: [ - { - kind: 'text', - text: 'Hello, schema-io.', - xPt: 72, - yPt: 720, - font: DEFAULT_LAYOUT_FONT, - sizePt: 12, - color: COLOR_BLACK, - }, - ], - }, - ], - images: {}, - }; -} - function documentPackage(): DocumentPackage { - return { - formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, - content: wordprocessingDocument(), - pages: [{ widthPt: 612, heightPt: 792 }], + const section: SectionGroupNode = { + node: { kind: 'section', pageSize: { widthPt: 612, heightPt: 792 }, margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 } }, + children: [{ kind: 'paragraph', runs: [{ text: 'Hello, schema-io.' }] }], }; + return { kind: 'wordprocessing', metadata: { title: 'schema-io fixture' }, children: [section] }; } describe('schemaUriFor', () => { - it('builds a jsdelivr URL pinned to the current published version, one per kind', () => { - expect(schemaUriFor('DocumentPackage')).toBe( - `https://cdn.jsdelivr.net/npm/document-schema.js@${packageVersion}/schemas/document-package.schema.json`, - ); - expect(schemaUriFor('ContentDocument')).toBe( - `https://cdn.jsdelivr.net/npm/document-schema.js@${packageVersion}/schemas/content-document.schema.json`, - ); - expect(schemaUriFor('LayoutDocument')).toBe( - `https://cdn.jsdelivr.net/npm/document-schema.js@${packageVersion}/schemas/layout-document.schema.json`, - ); + it('builds a release-pinned jsdelivr URL, one per kind', () => { + expect(schemaUriFor('DocumentPackage')).toBe(uriForVersion(packageVersion, 'document-package')); + expect(schemaUriFor('ContentDocument')).toBe(uriForVersion(packageVersion, 'content-document')); }); }); @@ -105,29 +83,20 @@ describe('*WithSchema', () => { expect(tagged.$schema).toBe(schemaUriFor('ContentDocument')); expect(tagged).toEqual({ $schema: schemaUriFor('ContentDocument'), ...doc }); }); - - it('layoutDocumentWithSchema stamps $schema as the first key and preserves every field', () => { - const layout = layoutDocument(); - const tagged = layoutDocumentWithSchema(layout); - expect(Object.keys(tagged)[0]).toBe('$schema'); - expect(tagged.$schema).toBe(schemaUriFor('LayoutDocument')); - expect(tagged).toEqual({ $schema: schemaUriFor('LayoutDocument'), ...layout }); - }); }); describe('documentSchemaKindOf', () => { - it('recognizes all three kinds', () => { + it('recognizes both live kinds', () => { expect(documentSchemaKindOf(documentPackageWithSchema(documentPackage()))).toBe('DocumentPackage'); expect(documentSchemaKindOf(contentDocumentWithSchema(wordprocessingDocument()))).toBe('ContentDocument'); - expect(documentSchemaKindOf(layoutDocumentWithSchema(layoutDocument()))).toBe('LayoutDocument'); }); - it('is version-agnostic: a $schema from a different installed version still resolves', () => { - expect( - documentSchemaKindOf({ - $schema: 'https://cdn.jsdelivr.net/npm/document-schema.js@0.0.1/schemas/document-package.schema.json', - }), - ).toBe('DocumentPackage'); + it('is version-agnostic: a $schema from a different release still names its kind', () => { + expect(documentSchemaKindOf({ $schema: uriForVersion(installedMajorPlusOne, 'document-package') })).toBe('DocumentPackage'); + }); + + it('returns undefined for a layout-document URI -- that kind moved to pdf-codec', () => { + expect(documentSchemaKindOf({ $schema: schemaUriFor('DocumentPackage').replace('document-package', 'layout-document') })).toBeUndefined(); }); it('returns undefined for a missing, non-string, or unrelated $schema', () => { @@ -144,16 +113,67 @@ describe('documentSchemaKindOf', () => { }); }); -describe('documentFromJson', () => { - it('round-trips each kind end-to-end', () => { +describe('documentFromJson dispatches on the $schema URI', () => { + it('round-trips each live kind stamped with the installed release URI', () => { const pkg = documentPackage(); expect(documentFromJson(documentPackageWithSchema(pkg))).toEqual({ kind: 'DocumentPackage', value: pkg }); const content = wordprocessingDocument(); expect(documentFromJson(contentDocumentWithSchema(content))).toEqual({ kind: 'ContentDocument', value: content }); + }); - const layout = layoutDocument(); - expect(documentFromJson(layoutDocumentWithSchema(layout))).toEqual({ kind: 'LayoutDocument', value: layout }); + it('accepts a URI from another release of the SAME major -- patch and minor releases validate a major\'s dumps', () => { + const pkg = documentPackage(); + const tagged = { ...documentPackageWithSchema(pkg), $schema: uriForVersion(`${installedMajor}.9.9`, 'document-package') }; + expect(documentFromJson(tagged)).toEqual({ kind: 'DocumentPackage', value: pkg }); + }); + + it('refuses an older major\'s URI and names the change -- the formatVersion era and the flat package shape', () => { + const oldDump = { + $schema: uriForVersion(installedMajorMinusOne, 'document-package'), + formatVersion: 2, + content: { kind: 'wordprocessing', metadata: {}, sections: [] }, + }; + try { + documentFromJson(oldDump); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(SchemaVersionMismatchError); + if (!(error instanceof SchemaVersionMismatchError)) throw error; + expect(error.dumpVersion).toBe(`${installedMajorMinusOne}.0.0`); + expect(error.installedVersion).toBe(packageVersion); + expect(error.message).toContain('formatVersion'); + expect(error.message).toContain('tree-form DocumentPackage'); + expect(error.message).toContain('ExaDev/document-schema.js#20'); + } + }); + + it('refuses a newer major\'s URI with the upgrade pointer', () => { + const futureDump = { $schema: uriForVersion(installedMajorPlusOne, 'document-package') }; + try { + documentFromJson(futureDump); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(SchemaVersionMismatchError); + if (!(error instanceof SchemaVersionMismatchError)) throw error; + expect(error.message).toContain('Upgrade document-schema.js'); + } + }); + + it('tombstones a layout-document URI from any release with the pointer to pdf-codec', () => { + for (const version of [1, 2, 3, installedMajor, installedMajorPlusOne]) { + const layoutDump = { $schema: uriForVersion(version, 'layout-document'), pages: [] }; + try { + documentFromJson(layoutDump); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(LayoutSchemaDemotedError); + if (!(error instanceof LayoutSchemaDemotedError)) throw error; + expect(error.schema).toBe(uriForVersion(version, 'layout-document')); + expect(error.message).toContain('pdf-codec'); + expect(error.message).toContain('ExaDev/pdf-codec#65'); + } + } }); it('throws UnrecognizedDocumentSchemaError, carrying the offending value, for unrecognized input', () => { @@ -179,4 +199,13 @@ describe('documentFromJson', () => { UnrecognizedDocumentSchemaError, ); }); + + it('a bare DocumentPackageSchema.parse does not version-discriminate: it structurally validates whatever it is handed', () => { + // The documented contract (src/schema-io.ts): a direct parse validates structure only. This value carries a foreign version's $schema, which documentFromJson would refuse -- the direct parse accepts, because the installed schema's shape tolerates and strips the unknown $schema key and the tree underneath is valid. + const foreignTagged = { + ...documentPackage(), + $schema: uriForVersion(installedMajorPlusOne, 'document-package'), + } as unknown; + expect(DocumentPackageSchema.safeParse(foreignTagged).success).toBe(true); + }); }); diff --git a/src/schema-io.ts b/src/schema-io.ts index 2553ced..70762d6 100644 --- a/src/schema-io.ts +++ b/src/schema-io.ts @@ -1,14 +1,12 @@ import { type ContentDocument, ContentDocumentSchema } from './content'; -import { type LayoutDocument, LayoutDocumentSchema } from './layout'; import { type DocumentPackage, DocumentPackageSchema } from './package'; -// The three kinds published as .schema.json files (see scripts/generate-json-schemas.mjs, which imports SCHEMA_FILE_NAMES/schemaUriFor from this module rather than declaring its own copy). Kept in one place so the id string, the filename, and the URL are never edited independently. -export type DocumentSchemaKind = 'DocumentPackage' | 'ContentDocument' | 'LayoutDocument'; +// The two kinds published as .schema.json files (see scripts/generate-json-schemas.mjs, which imports SCHEMA_FILE_NAMES/schemaUriFor from this module rather than declaring its own copy). Kept in one place so the id string, the filename, and the URL are never edited independently. +export type DocumentSchemaKind = 'DocumentPackage' | 'ContentDocument'; export const SCHEMA_FILE_NAMES: Record = { DocumentPackage: 'document-package.schema.json', ContentDocument: 'content-document.schema.json', - LayoutDocument: 'layout-document.schema.json', }; // __PACKAGE_VERSION__ is a literal string constant, not a runtime read -- see src/global.d.ts, tsdown.config.ts, and vitest.config.ts. @@ -16,9 +14,25 @@ export function schemaUriFor(kind: DocumentSchemaKind): string { return `https://cdn.jsdelivr.net/npm/document-schema.js@${__PACKAGE_VERSION__}/schemas/${SCHEMA_FILE_NAMES[kind]}`; } -// Version-agnostic on purpose: a value tagged by an older or newer installed version of this package is still recognisable as "a DocumentPackage" (etc.) from its $schema alone -- only the capturing group (the file stem) is used, the @ segment is deliberately not constrained beyond "no slash". +// THE VERSIONING CONTRACT (ExaDev/document-schema.js#20's errata): the $schema URI a dumper stamps is the artefact's version, and it is release-pinned -- the @version segment names the exact npm release whose schema validates the value. It replaces the formatVersion integers releases 1.x-3.x carried (DocumentPackage's own and ContentDocument's per-arm literals), which were a second, hand-kept source of truth alongside URIs that already named the release. There is no version field anywhere in a dumped value any more. +// +// That makes documentFromJson the enforcement point for untrusted input: it reads the URI's version segment and refuses anything this installed release cannot faithfully validate (see the version gate in documentFromJson below -- a different MAJOR never parses, because a major is exactly a schema generation this release may not describe). A bare DocumentPackageSchema.parse() does not version-discriminate at all -- it structurally validates whatever it is handed against the installed schema, full stop -- so a caller ingesting a dump from anywhere it did not itself produce must go through documentFromJson, not a direct parse. Callers that already trust the value's provenance may keep parsing directly, exactly as before. + +// The layout-document stem stays in this pattern on purpose: this package no longer defines that schema (the whole LayoutDocument family moved to pdf-codec in this major, ExaDev/pdf-codec#65), but values stamped with its URI are still recognised -- by documentFromJson's tombstone branch, which names where the schema went instead of failing as if the value were unrelated. const SCHEMA_URI_PATTERN = - /^https:\/\/cdn\.jsdelivr\.net\/npm\/document-schema\.js@[^/]+\/schemas\/(document-package|content-document|layout-document)\.schema\.json$/; + /^https:\/\/cdn\.jsdelivr\.net\/npm\/document-schema\.js@([^/]+)\/schemas\/(document-package|content-document|layout-document)\.schema\.json$/; + +interface SchemaUriParts { version: string; stem: string } + +function parseSchemaUri(uri: string): SchemaUriParts | undefined { + const match = SCHEMA_URI_PATTERN.exec(uri); + if (match === null) return undefined; + const version = match[1]; + const stem = match[2]; + // Both capturing groups are mandatory in the pattern above, so a successful match always populates them; these checks exist to satisfy noUncheckedIndexedAccess, not because either can genuinely fire. + if (version === undefined || stem === undefined) return undefined; + return { version, stem }; +} function kindForFileStem(stem: string): DocumentSchemaKind | undefined { switch (stem) { @@ -26,34 +40,37 @@ function kindForFileStem(stem: string): DocumentSchemaKind | undefined { return 'DocumentPackage'; case 'content-document': return 'ContentDocument'; - case 'layout-document': - return 'LayoutDocument'; default: return undefined; } } +// The major of a release-pinned version string, or undefined when it does not start with one (which no real URI does -- a mismatch, never a parse). +function majorVersionOf(version: string): number | undefined { + const match = /^(\d+)/.exec(version); + if (match?.[1] === undefined) return undefined; + return Number(match[1]); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -// The one place a raw, unvalidated $schema value is read back out of an unknown input -- used by documentFromJson below, and exported on its own for a caller that only wants to know "what kind of document is this, if any" without also parsing the rest of the value. +// The one place a raw, unvalidated $schema value is read back out of an unknown input -- used by documentFromJson below, and exported on its own for a caller that only wants to know "what kind of document is this, if any" without also parsing the rest of the value. Version-agnostic on purpose: it answers "which kind does this URI name", not "may this installed release parse it" -- a value tagged by an older or newer release is still recognisable as "a DocumentPackage" from its file stem alone, and only documentFromJson applies the version gate. A layout-document URI returns undefined here because this package no longer defines that kind; documentFromJson recognises it separately and answers it with the demotion tombstone. export function documentSchemaKindOf(value: unknown): DocumentSchemaKind | undefined { if (!isRecord(value)) return undefined; if (!('$schema' in value) || typeof value.$schema !== 'string') return undefined; - const match = SCHEMA_URI_PATTERN.exec(value.$schema); - if (match === null) return undefined; - const stem = match[1]; - // noUncheckedIndexedAccess types every array/RegExpExecArray element as possibly undefined; this particular capture group is mandatory in the pattern above, so a successful match always populates it -- this check exists to satisfy that, not because it can genuinely fire. - if (stem === undefined) return undefined; - return kindForFileStem(stem); + const parts = parseSchemaUri(value.$schema); + if (parts === undefined) return undefined; + return kindForFileStem(parts.stem); } export type DocumentPackageJson = DocumentPackage & { readonly $schema: string }; export type ContentDocumentJson = ContentDocument & { readonly $schema: string }; -export type LayoutDocumentJson = LayoutDocument & { readonly $schema: string }; // No re-validation here: the parameter type already guarantees a real DocumentPackage at the call site, so re-parsing it would be defensive code for a case that can't happen. $schema is spread first so it's also the first enumerable/JSON key. +// +// $schema is envelope metadata, not content: a content hash or structural comparison computed over a serialised dump must exclude it (document-outline.js's leafContentHash recipe -- canonicalise, stringify, SHA-256 -- is the family's stated canonicaliser, and the $schema key is stripped before that canonicalisation runs, never hashed alongside the content it merely labels). Two dumps of one document by two different installed releases hash equal once $schema is excluded; hashing it in would make the digest name the dumper, not the document. export function documentPackageWithSchema(value: DocumentPackage): DocumentPackageJson { return { $schema: schemaUriFor('DocumentPackage'), ...value }; } @@ -62,39 +79,84 @@ export function contentDocumentWithSchema(value: ContentDocument): ContentDocume return { $schema: schemaUriFor('ContentDocument'), ...value }; } -export function layoutDocumentWithSchema(value: LayoutDocument): LayoutDocumentJson { - return { $schema: schemaUriFor('LayoutDocument'), ...value }; -} - export class UnrecognizedDocumentSchemaError extends Error { readonly schema: unknown; constructor(schema: unknown) { super( - `documentFromJson: value has no recognized "$schema" property (expected one of the three document-schema.js .schema.json URIs; found: ${JSON.stringify(schema)}).`, + `documentFromJson: value has no recognized "$schema" property (expected one of the document-schema.js .schema.json URIs; found: ${JSON.stringify(schema)}).`, ); this.name = 'UnrecognizedDocumentSchemaError'; this.schema = schema; } } +// The demotion tombstone: a value stamped with the layout-document schema's URI was written by document-schema.js 3.x or earlier, and the schema it names now lives in pdf-codec. The pointer is the entire answer -- this release cannot validate the value, and pretending not to recognise the URI would hide the one fact the reader needs. +export class LayoutSchemaDemotedError extends Error { + readonly schema: string; + + constructor(schema: string) { + super( + `documentFromJson: this value is a layout-document dump (${schema}), and LayoutDocument moved to pdf-codec in document-schema.js 4.0.0 (ExaDev/pdf-codec#65). Read it with pdf-codec's own layout model, or with a document-schema.js 3.x release.`, + ); + this.name = 'LayoutSchemaDemotedError'; + this.schema = schema; + } +} + +// The version gate's refusal: the dump's URI names a release this installed package cannot validate. An older major gets the migration pointer (the formatVersion era and the flat DocumentPackage shape are what it is), a newer major the upgrade pointer. +export class SchemaVersionMismatchError extends Error { + readonly schema: string; + readonly dumpVersion: string; + readonly installedVersion: string; + + constructor(schema: string, dumpVersion: string, installedVersion: string) { + const dumpMajor = majorVersionOf(dumpVersion); + const installedMajor = majorVersionOf(installedVersion); + const isOlder = dumpMajor !== undefined && installedMajor !== undefined && dumpMajor < installedMajor; + super( + `documentFromJson: this dump's $schema pins document-schema.js@${dumpVersion}, but the installed release is @${installedVersion}, and a dump only parses under the major that wrote it.` + + (isOlder + ? ` Dumps from before 4.0.0 carry the retired formatVersion field and (for packages) the flat { formatVersion, content, pages } shape, replaced in 4.0.0 by the tree-form DocumentPackage (ExaDev/document-schema.js#20). Re-dump the value with a 4.x release, or parse it with the release that produced it.` + : ` Upgrade document-schema.js to read it.`), + ); + this.name = 'SchemaVersionMismatchError'; + this.schema = schema; + this.dumpVersion = dumpVersion; + this.installedVersion = installedVersion; + } +} + export type DocumentJsonResult = | { kind: 'DocumentPackage'; value: DocumentPackage } - | { kind: 'ContentDocument'; value: ContentDocument } - | { kind: 'LayoutDocument'; value: LayoutDocument }; + | { kind: 'ContentDocument'; value: ContentDocument }; -// The genuinely new ingest capability: a caller that already knows the kind can keep calling DocumentPackageSchema.parse(value) (etc.) directly, unchanged -- these schemas are all plain (non-strict) z.object()s, so they already tolerate and silently strip an incoming $schema property with zero new code. This function exists for the "don't yet know the kind" case: $schema selects which schema to run; the schema itself still does the real structural validation (a recognized $schema with a structurally invalid body throws the underlying ZodError, not UnrecognizedDocumentSchemaError). +// The ingest entry point for a value of unknown provenance. $schema selects which schema to run and the version gate decides whether this release may run it; the schema itself still does the real structural validation (a recognized $schema with a structurally invalid body throws the underlying ZodError, not one of this module's errors). Within one major the installed schema validates the dump -- patch and minor releases are semver-compatible with the major's schema generation -- and across majors it refuses, because a major boundary is exactly where the schema's shape may have changed incompatibly (4.0.0's tree-form envelope being the live example). A caller that already knows the kind and trusts the value's provenance can keep calling DocumentPackageSchema.parse(value) (etc.) directly, unchanged -- these schemas are plain (non-strict) z.object()s, so they already tolerate and silently strip an incoming $schema property with zero new code -- but such a caller is validating structure only, not version: that is the documented difference between a direct parse and this dispatch. export function documentFromJson(value: unknown): DocumentJsonResult { - const kind = documentSchemaKindOf(value); - if (kind === undefined) { + if (!isRecord(value) || typeof value.$schema !== 'string') { throw new UnrecognizedDocumentSchemaError(isRecord(value) ? value.$schema : undefined); } + const parts = parseSchemaUri(value.$schema); + if (parts === undefined) { + throw new UnrecognizedDocumentSchemaError(value.$schema); + } + if (parts.stem === 'layout-document') { + throw new LayoutSchemaDemotedError(value.$schema); + } + const kind = kindForFileStem(parts.stem); + // kindForFileStem answers every stem the pattern matches besides the layout-document one intercepted above, so a defined kind here is guaranteed; the check exists because the type system cannot see the pattern and the stem union, not because it can genuinely fire. + if (kind === undefined) { + throw new UnrecognizedDocumentSchemaError(value.$schema); + } + const dumpMajor = majorVersionOf(parts.version); + const installedMajor = majorVersionOf(__PACKAGE_VERSION__); + if (dumpMajor !== installedMajor) { + throw new SchemaVersionMismatchError(value.$schema, parts.version, __PACKAGE_VERSION__); + } switch (kind) { case 'DocumentPackage': return { kind, value: DocumentPackageSchema.parse(value) }; case 'ContentDocument': return { kind, value: ContentDocumentSchema.parse(value) }; - case 'LayoutDocument': - return { kind, value: LayoutDocumentSchema.parse(value) }; } } diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index a2fecba..41ea93d 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -6,21 +6,25 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; describe('smoke: ESM/CJS parity', () => { - it('loads the ESM build and exposes ContentDocumentSchema/LayoutDocumentSchema', async () => { + it('loads the ESM build and exposes the tree-form package and node schemas', async () => { const esm = await import('../dist/index.js'); - expect(typeof esm.ContentDocumentSchema.parse).toBe('function'); - expect(typeof esm.LayoutDocumentSchema.parse).toBe('function'); + expect(typeof esm.DocumentPackageSchema.parse).toBe('function'); + expect(typeof esm.PackageNodeSchema.safeParse).toBe('function'); + expect(typeof esm.resolveStyleChain).toBe('function'); + expect(esm.LayoutDocumentSchema).toBeUndefined(); }); - it('loads the CJS build and exposes ContentDocumentSchema/LayoutDocumentSchema', () => { + it('loads the CJS build and exposes the tree-form package and node schemas', () => { const require = createRequire(import.meta.url); const cjs = require('../dist/index.cjs'); - expect(typeof cjs.ContentDocumentSchema.parse).toBe('function'); - expect(typeof cjs.LayoutDocumentSchema.parse).toBe('function'); + expect(typeof cjs.DocumentPackageSchema.parse).toBe('function'); + expect(typeof cjs.PackageNodeSchema.safeParse).toBe('function'); + expect(typeof cjs.resolveStyleChain).toBe('function'); + expect(cjs.LayoutDocumentSchema).toBeUndefined(); }); }); -// Verifies scripts/generate-json-schemas.mjs's own output: the three published .schema.json files (see package.json's "files"/"exports"), generated fresh by `pnpm run build` immediately before this test project runs. +// Verifies scripts/generate-json-schemas.mjs's own output: the two published .schema.json files (see package.json's "files"/"exports"), generated fresh by `pnpm run build` immediately before this test project runs. describe('smoke: generated JSON Schema files', () => { const schemasDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'schemas'); const packageVersion = JSON.parse(readFileSync(join(schemasDir, '..', 'package.json'), 'utf8')).version; @@ -29,79 +33,88 @@ describe('smoke: generated JSON Schema files', () => { return JSON.parse(readFileSync(join(schemasDir, fileName), 'utf8')); } - it('all three .schema.json files exist and parse as valid JSON', () => { - for (const fileName of ['document-package.schema.json', 'content-document.schema.json', 'layout-document.schema.json']) { + it('both .schema.json files exist and parse as valid JSON, and the demoted layout-document file is gone', () => { + for (const fileName of ['document-package.schema.json', 'content-document.schema.json']) { expect(() => readSchema(fileName)).not.toThrow(); } + expect(() => readSchema('layout-document.schema.json')).toThrow(); }); - it("document-package.schema.json's $id is a jsdelivr URL pinned to the package's own published version, and its content ref shares that same version", () => { + it("document-package.schema.json's $id is a jsdelivr URL pinned to the package's own published version -- the URI IS the version", () => { const documentPackage = readSchema('document-package.schema.json'); expect(documentPackage.$id).toBe( `https://cdn.jsdelivr.net/npm/document-schema.js@${packageVersion}/schemas/document-package.schema.json`, ); - expect(documentPackage.properties.content.$ref).toBe( - `https://cdn.jsdelivr.net/npm/document-schema.js@${packageVersion}/schemas/content-document.schema.json`, - ); - // The fused-tree design (see src/package.ts): no more standalone `layout` field pairing a whole separate LayoutDocument -- position now lives on content nodes themselves via their own `frames`, and all that remains at the package level is each rendered page's own size. - expect(documentPackage.properties).not.toHaveProperty('layout'); - expect(documentPackage.properties.pages.type).toBe('array'); - expect(documentPackage.properties.pages.items.required).toEqual( - expect.arrayContaining(['widthPt', 'heightPt']), - ); - expect(documentPackage.required).toEqual(expect.arrayContaining(['formatVersion', 'content'])); - expect(documentPackage.required).not.toEqual(expect.arrayContaining(['pages'])); }); - it("content-document.schema.json's $defs.LayoutFrame and ContentParagraph's headingLevel/frames fields are present, matching the fused-tree design", () => { + it("document-package.schema.json is the tree form: five kind arms, no formatVersion or content field, per-kind children refs, and styles/definitions tables at the root", () => { + const documentPackage = readSchema('document-package.schema.json'); + expect(documentPackage.oneOf).toHaveLength(5); + const kinds = documentPackage.oneOf.map((variant) => variant.properties.kind.const); + expect(kinds).toEqual(['wordprocessing', 'presentation', 'spreadsheet', 'drawing', 'formula']); + for (const variant of documentPackage.oneOf) { + expect(variant.properties.formatVersion).toBeUndefined(); + expect(variant.properties.content).toBeUndefined(); + expect(variant.required).toEqual(expect.arrayContaining(['kind', 'metadata', 'children'])); + expect(variant.properties.pages.type).toBe('array'); + expect(variant.properties.styles.additionalProperties.$ref).toBe('#/$defs/StyleEntry'); + expect(variant.properties.definitions.additionalProperties.$ref).toBe('#/$defs/DefinitionEntry'); + } + // The per-kind root children: sections, slides, sheets, draw pages, and the formula leaf. + const byKind = Object.fromEntries(documentPackage.oneOf.map((variant) => [variant.properties.kind.const, variant])); + expect(byKind.wordprocessing.properties.children.items.$ref).toBe('#/$defs/SectionGroup'); + expect(byKind.presentation.properties.children.items.$ref).toBe('#/$defs/SlideGroup'); + expect(byKind.spreadsheet.properties.children.items.$ref).toBe('#/$defs/SheetGroup'); + expect(byKind.drawing.properties.children.items.$ref).toBe('#/$defs/DrawPageGroup'); + expect(byKind.formula.properties.children.items.$ref).toBe('#/$defs/ContentFormula'); + // The tree fragments resolve file-locally: both published files carry the same $defs block (the same object emitted twice in one generator run). + expect(Object.keys(documentPackage.$defs)).toContain('SectionGroup'); + expect(Object.keys(documentPackage.$defs)).toContain('StyleEntry'); + // The recursion itself: a section group's children point back at the shared HeadingGroup/ListGroup definitions, and those at ContentBlock. + expect(documentPackage.$defs.SectionGroup.properties.children.items.oneOf).toEqual([ + { $ref: '#/$defs/HeadingGroup' }, + { $ref: '#/$defs/ListGroup' }, + { $ref: '#/$defs/ContentBlock' }, + ]); + // Style entries enforce the ban list by shape: additionalProperties false on entry and both halves, with no frames/sourcePath/styleId field anywhere. + expect(documentPackage.$defs.StyleEntry.additionalProperties).toBe(false); + expect(documentPackage.$defs.StyleParagraphProperties.additionalProperties).toBe(false); + expect(documentPackage.$defs.StyleParagraphProperties.properties.frames).toBeUndefined(); + expect(documentPackage.$defs.StyleRunProperties.properties.sourcePath).toBeUndefined(); + }); + + it("content-document.schema.json's flat arms keep their symbol-table $ref and drop the retired formatVersion field", () => { const contentDocument = readSchema('content-document.schema.json'); + expect(contentDocument.oneOf).toHaveLength(5); + for (const variant of contentDocument.oneOf) { + expect(variant.properties.formatVersion).toBeUndefined(); + expect(variant.properties.symbolTable.$ref).toBe('#/$defs/SymbolTable'); + } expect(contentDocument.$defs.LayoutFrame.required).toEqual( expect.arrayContaining(['pageIndex', 'xPt', 'yPt', 'widthPt', 'heightPt']), ); expect(contentDocument.$defs.ContentParagraph.properties.headingLevel.type).toBe('integer'); expect(contentDocument.$defs.ContentParagraph.properties.frames.items.$ref).toBe('#/$defs/LayoutFrame'); - }); - - it("content-document.schema.json's root is a bare oneOf of the 5 ContentDocument variants, and $defs.ContentBlock has 5 members", () => { - const contentDocument = readSchema('content-document.schema.json'); - expect(contentDocument).not.toHaveProperty('type'); - expect(contentDocument.oneOf).toHaveLength(5); expect(contentDocument.$defs.ContentBlock.oneOf).toHaveLength(5); + // The embedded-object cycle is the one deliberate cross-file pointer. + expect(contentDocument.$defs.ContentEmbeddedObjectBlock.properties.document.$ref).toBe( + `https://cdn.jsdelivr.net/npm/document-schema.js@${packageVersion}/schemas/content-document.schema.json`, + ); }); - it("content-document.schema.json's formula variant refs the hand-authored ContentFormula and MathMlNode definitions", () => { + it("content-document.schema.json's formula and symbol-table definitions carry the two-layer math model", () => { const contentDocument = readSchema('content-document.schema.json'); const formula = contentDocument.oneOf.find((variant) => variant.properties.kind.const === 'formula'); expect(formula.properties.formula.$ref).toBe('#/$defs/ContentFormula'); expect(contentDocument.$defs.ContentFormula.properties.mathml.items.$ref).toBe('#/$defs/MathMlNode'); expect(contentDocument.$defs.MathMlNode.oneOf).toHaveLength(6); - // The recursion itself: an element's children point back at the shared MathMlNode definition rather than inlining. expect(contentDocument.$defs.MathMlElement.properties.children.items.$ref).toBe('#/$defs/MathMlNode'); - }); - - it("content-document.schema.json's formula and symbol-table definitions carry the two-layer math model", () => { - const contentDocument = readSchema('content-document.schema.json'); - // The two layers join in one hand-authored fragment: verbatim LaTeX presentation, semantic MathExpression content, and provenance alongside the MathML tree. expect(contentDocument.$defs.ContentFormula.properties.presentation.$ref).toBe('#/$defs/MathPresentation'); expect(contentDocument.$defs.ContentFormula.properties.content.$ref).toBe('#/$defs/MathExpression'); expect(contentDocument.$defs.ContentFormula.properties.provenance.$ref).toBe('#/$defs/MathProvenance'); expect(contentDocument.$defs.ContentFormula.required).toEqual(['mathml']); - // The closed grammar's eight variants, and the unparsed fallback inside them. expect(contentDocument.$defs.MathExpression.oneOf).toHaveLength(8); expect(contentDocument.$defs.MathUnparsed.required).toEqual(['kind', 'latex']); - // The document-level symbol table is one named reference from every ContentDocument arm, not five inlined copies. - for (const variant of contentDocument.oneOf) { - expect(variant.properties.symbolTable.$ref).toBe('#/$defs/SymbolTable'); - } expect(contentDocument.$defs.SymbolTable.required).toEqual(['symbols', 'units']); }); - - it('layout-document.schema.json has the expected pages/images shape', () => { - const layoutDocument = readSchema('layout-document.schema.json'); - expect(layoutDocument.type).toBe('object'); - expect(layoutDocument.properties.pages.type).toBe('array'); - expect(layoutDocument.properties.pages.items.type).toBe('object'); - expect(layoutDocument.properties.images.type).toBe('object'); - expect(layoutDocument.properties.images.additionalProperties.type).toBe('object'); - }); }); diff --git a/test/workers/document-schema.test.ts b/test/workers/document-schema.test.ts index 7cb6dc7..b9528be 100644 --- a/test/workers/document-schema.test.ts +++ b/test/workers/document-schema.test.ts @@ -1,17 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { - CONTENT_FORMAT_VERSION, - ContentDocumentSchema, - DOCUMENT_PACKAGE_FORMAT_VERSION, - DocumentPackageSchema, -} from '../../src'; +import { ContentDocumentSchema, DocumentPackageSchema, resolveStyleChain } from '../../src'; -// Proves document-schema.js's Zod schemas parse inside a Cloudflare Workers isolate (workerd, via @cloudflare/vitest-pool-workers) with no Node-only APIs. The package is pure Zod by design -- no node:fs, no Buffer, no process -- and zod is isomorphic, so if any schema (or its zod dependency) touched a Node-only API the workerd isolate would throw rather than these passing. This is the runtime complement to the static node test suite. +// Proves document-schema.js's Zod schemas and helpers parse inside a Cloudflare Workers isolate (workerd, via @cloudflare/vitest-pool-workers) with no Node-only APIs. The package is pure Zod by design -- no node:fs, no Buffer, no process -- and zod is isomorphic, so if any schema (or its zod dependency) touched a Node-only API the workerd isolate would throw rather than these passing. This is the runtime complement to the static node test suite. describe('document-schema.js under the Cloudflare Workers runtime', () => { it('ContentDocumentSchema parses a minimal wordprocessing document', () => { const document = { kind: 'wordprocessing', - formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, sections: [ { @@ -23,7 +17,6 @@ describe('document-schema.js under the Cloudflare Workers runtime', () => { }; const parsed = ContentDocumentSchema.parse(document); expect(parsed.kind).toBe('wordprocessing'); - expect(parsed.formatVersion).toBe(CONTENT_FORMAT_VERSION); expect(parsed.sections[0]?.blocks).toEqual([]); }); @@ -31,31 +24,37 @@ describe('document-schema.js under the Cloudflare Workers runtime', () => { expect(() => ContentDocumentSchema.parse({ kind: 'not-a-real-kind', - formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, sections: [], }), ).toThrow(); }); - it('DocumentPackageSchema parses a content-only package wrapping that document', () => { - const document = { + it('DocumentPackageSchema parses a tree-form package and its styles table, and resolveStyleChain resolves inside the isolate', () => { + const parsed = DocumentPackageSchema.parse({ kind: 'wordprocessing', - formatVersion: CONTENT_FORMAT_VERSION, metadata: {}, - sections: [ + styles: { s1: { paragraph: { alignment: 'justify' }, run: { sizePt: 11 } } }, + children: [ { - pageSize: { widthPt: 612, heightPt: 792 }, - margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 }, - blocks: [], + node: { + kind: 'section', + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 }, + }, + children: [ + { + node: { kind: 'paragraph', headingLevel: 1, runs: [{ text: 'Hello, workerd.' }] }, + style: 's1', + children: [], + }, + ], }, ], - }; - const parsed = DocumentPackageSchema.parse({ - formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, - content: document, }); - expect(parsed.content.kind).toBe('wordprocessing'); + expect(parsed.kind).toBe('wordprocessing'); expect(parsed.pages).toBeUndefined(); + const resolved = resolveStyleChain(parsed.styles ?? {}, ['s1']); + expect(resolved.paragraph).toEqual({ alignment: 'justify' }); }); }); From 1bb1204f8a18e947253fa20d53e526f74545a2b5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 18 Aug 2026 11:09:02 +0100 Subject: [PATCH 2/6] feat!: demote the LayoutDocument family to pdf-codec The layout item model (LayoutDocument, LayoutPage, LayoutItem and its text/image/rect/line/ellipse/path/link variants, LayoutImageAsset, LAYOUT_FORMAT_VERSION) moves to pdf-codec, where the only codec that ever read or wrote it owns it outright -- the family pattern every other format already follows. pdf-codec's own PR takes the file and flips its imports; its issue closes there. The LayoutCodec interface goes with it: it modelled the single format that produces layout cheaply on read (PDF), and a schema-package interface for one private implementation was an accident of pdf-codec predating the content pivot. ContentCodec stays, unchanged in shape. schema-io, the generator, and the smoke tests already carry the schema-file side of the demotion (two published .schema.json files, the URI-pattern tombstone recognising old layout-document dumps); this commit removes the model itself and retires the last stale comment references to it in geometry, metadata, and style. Dependents stay on document-schema.js 3.x via semver until their own majors, so this is not a cascade-breaker. --- src/codec.ts | 13 +-- src/geometry.ts | 2 +- src/layout.test.ts | 217 --------------------------------------------- src/layout.ts | 160 --------------------------------- src/metadata.ts | 2 +- src/style.ts | 2 +- 6 files changed, 6 insertions(+), 390 deletions(-) delete mode 100644 src/layout.test.ts delete mode 100644 src/layout.ts diff --git a/src/codec.ts b/src/codec.ts index 6d2f898..5c99e11 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -1,9 +1,8 @@ import type { ContentDocument } from './content'; -import type { LayoutDocument } from './layout'; -// These two contracts describe what an individual format's own codec naturally provides -- not what DocumentPackage requires. A codec's read() never returns a DocumentPackage directly; it returns one half. DocumentPackage (content required, layout optional -- see its own doc comment in package.ts) is the *assembled* format produced by composing a ContentCodec.read() result with a *separately run* layout-engine pass. Nothing here constructs a DocumentPackage; that composition happens one level up, in whatever code owns both a ContentCodec and a layout engine for the same format. +// This contract describes what an individual format's own codec naturally provides -- not what DocumentPackage requires. A codec's read() never returns a DocumentPackage directly; the package is the *assembled* tree produced by decomposing the flat ContentDocument a reader returns (src/package.ts's three laws). Nothing here constructs a DocumentPackage; that composition happens one level up, in whatever code owns both a ContentCodec and a layout engine for the same format. -// The two contracts are asymmetric rather than a single unified DocumentCodec operating on DocumentPackage, because the formats they model are asymmetric. Most formats (docx/pptx/odt/odp/ods/odg/markdown) only ever produce content on read -- layout for them is always a separate, later, engine-driven step, never something their own codec produces directly. PDF is the mirror image: it produces layout cheaply on read, and content only via a separate, expensive, lossy, opt-in reconstruction pass that is emphatically not part of "reading" a PDF. Forcing a single contract shaped like DocumentPackage would mean either fabricating empty content for every non-PDF format's codec (impossible, they have none to fabricate) or making every PDF read pay reconstruction cost it usually doesn't want (the wrong default). Keeping ContentCodec and LayoutCodec separate lets each format implement only the half it actually has. +// There is deliberately no LayoutCodec alongside this any more. Releases 1.x-3.x exported one -- read() to a LayoutDocument, write() back -- modelling the single format that produces layout cheaply on read: PDF. The whole LayoutDocument family moved to pdf-codec in this major (ExaDev/pdf-codec#65), where it is that codec's own private model, so the interface that described it belongs there too, alongside it. Callers that held a LayoutCodec over a PDF codec hold pdf-codec's own read/write signatures directly once they migrate. // A format's own content codec: read() decodes that format's bytes into a ContentDocument; write(), where the format supports it, encodes a ContentDocument back into that format's bytes. write() is deliberately optional -- this models a real, permanent asymmetry, not a temporary gap. The odf format (a standalone ODF formula document) has a reader but genuinely no builder at all: recovering structured MathML from rendered glyphs is a categorically different, OCR-adjacent problem than generating them, so no ContentDocument-to-odf writer exists or is planned. export interface ContentCodec { @@ -11,10 +10,4 @@ export interface ContentCodec { write?(content: ContentDocument, options?: TOptions): Uint8Array; } -// A format's own layout codec: read() decodes that format's bytes into a LayoutDocument; write() encodes a LayoutDocument back into that format's bytes. Unlike ContentCodec.write, LayoutCodec.write is not optional -- PDF is the only format with a LayoutCodec implementation anywhere in this family, and it always supports both directions (a PDF is written from a LayoutDocument exactly as readily as it is read into one), so there is no real asymmetry here to model. -export interface LayoutCodec { - read(bytes: Uint8Array, options?: TOptions): LayoutDocument; - write(layout: LayoutDocument, options?: TOptions): Uint8Array; -} - -// TOptions is generic per codec rather than a single shared options type across every ContentCodec/LayoutCodec implementation, since each real format's own read/write options are format-specific today (an AbortSignal, a font-substitution callback, a diagnostic sink) and this package has no reason to force them into one shared shape that would either be too narrow for some formats or carry fields meaningless to others. +// TOptions is generic per codec rather than a single shared options type across every ContentCodec implementation, since each real format's own read/write options are format-specific today (an AbortSignal, a font-substitution callback, a diagnostic sink) and this package has no reason to force them into one shared shape that would either be too narrow for some formats or carry fields meaningless to others. diff --git a/src/geometry.ts b/src/geometry.ts index eb901d2..e9a4535 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -30,7 +30,7 @@ export const MarginsSchema = z.object({ }); export type Margins = z.infer; -// A single positioned placement of a content node on one rendered page -- PDF user-space points (origin bottom-left, y increasing upward), matching LayoutItem's own xPt/yPt/widthPt/heightPt convention exactly (src/layout.ts), plus the page it belongs to. pageIndex is 0-based, matching DocumentPackageSchema's own `pages` array index (src/package.ts): `pages[frame.pageIndex]` names the page a given frame renders onto and that page's own dimensions. A content node carries an ARRAY of these (see FusedNode in src/content.ts), not a single optional one, because pagination or line-wrapping can render one semantic node -- a paragraph whose runs wrap across a page boundary is the common case -- into more than one place without splitting or duplicating the node itself. This is the fusion primitive that replaces DocumentPackage's old approach of correlating a wholly separate LayoutDocument's own positioned items back to their originating ContentDocument node purely by matching sourcePath strings. +// A single positioned placement of a content node on one rendered page -- PDF user-space points (origin bottom-left, y increasing upward), plus the page it belongs to. pageIndex is 0-based, matching DocumentPackageSchema's own `pages` array index (src/package.ts): `pages[frame.pageIndex]` names the page a given frame renders onto and that page's own dimensions. A content node carries an ARRAY of these (see FusedNode in src/content.ts), not a single optional one, because pagination or line-wrapping can render one semantic node -- a paragraph whose runs wrap across a page boundary is the common case -- into more than one place without splitting or duplicating the node itself. This is the fusion primitive that replaced DocumentPackage's original approach of correlating a wholly separate layout tree's own positioned items back to their originating content node purely by matching sourcePath strings. export const LayoutFrameSchema = z.object({ pageIndex: z.number().int().nonnegative(), xPt: z.number(), diff --git a/src/layout.test.ts b/src/layout.test.ts deleted file mode 100644 index 18b825b..0000000 --- a/src/layout.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { COLOR_BLACK } from './color'; -import { - LAYOUT_FORMAT_VERSION, - type LayoutDocument, - LayoutDocumentSchema, - type LayoutItem, - LayoutItemSchema, -} from './layout'; -import { DEFAULT_LAYOUT_FONT } from './style'; - -const text: LayoutItem = { - kind: 'text', - text: 'Hello, layout.', - xPt: 72, - yPt: 720, - font: DEFAULT_LAYOUT_FONT, - sizePt: 12, - color: COLOR_BLACK, - widthPt: 90.5, - rotationDeg: 0, - underline: true, -}; - -const imageItem: LayoutItem = { - kind: 'image', - imageId: 'logo', - xPt: 10, - yPt: 700, - widthPt: 50, - heightPt: 25, - rotationDeg: 5, -}; - -const rect: LayoutItem = { - kind: 'rect', - xPt: 0, - yPt: 0, - widthPt: 200, - heightPt: 100, - fill: { r: 0.9, g: 0.9, b: 0.9 }, - stroke: { color: COLOR_BLACK, widthPt: 1.5 }, -}; - -const line: LayoutItem = { - kind: 'line', - x1Pt: 0, - y1Pt: 0, - x2Pt: 100, - y2Pt: 100, - color: COLOR_BLACK, - widthPt: 2, -}; - -const ellipse: LayoutItem = { - kind: 'ellipse', - xPt: 20, - yPt: 20, - widthPt: 40, - heightPt: 40, - fill: { r: 0.1, g: 0.2, b: 0.3 }, -}; - -const path: LayoutItem = { - kind: 'path', - subpaths: [ - { - startXPt: 0, - startYPt: 0, - closed: true, - segments: [ - { kind: 'line', xPt: 10, yPt: 0 }, - { kind: 'cubic', c1xPt: 15, c1yPt: 5, c2xPt: 15, c2yPt: 15, xPt: 10, yPt: 20 }, - { kind: 'line', xPt: 0, yPt: 20 }, - ], - }, - ], - fill: { r: 0.4, g: 0.5, b: 0.6 }, - fillRule: 'evenodd', - stroke: { color: COLOR_BLACK, widthPt: 1 }, -}; - -const link: LayoutItem = { - kind: 'link', - uri: 'https://example.com/', - xPt: 5, - yPt: 5, - widthPt: 60, - heightPt: 15, -}; - -describe('LayoutItemSchema', () => { - it('accepts every item kind and preserves every field through a JSON round trip', () => { - for (const item of [text, imageItem, rect, line, ellipse, path, link]) { - const parsed = LayoutItemSchema.parse(item); - const roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); - expect(LayoutItemSchema.parse(roundTripped)).toEqual(item); - } - }); - - it('rejects an unknown kind', () => { - expect(LayoutItemSchema.safeParse({ kind: 'circle', xPt: 0, yPt: 0 }).success).toBe(false); - }); -}); - -describe('LayoutPathSchema', () => { - it('accepts a minimal open subpath with no fill, stroke, or fillRule', () => { - const minimal: LayoutItem = { - kind: 'path', - subpaths: [{ startXPt: 0, startYPt: 0, closed: false, segments: [{ kind: 'line', xPt: 10, yPt: 10 }] }], - }; - expect(LayoutItemSchema.parse(minimal)).toEqual(minimal); - }); - - it('accepts a path with multiple subpaths, matching an evenodd hole punched through a fill', () => { - const withHole: LayoutItem = { - kind: 'path', - subpaths: [ - { startXPt: 0, startYPt: 0, closed: true, segments: [{ kind: 'line', xPt: 20, yPt: 0 }, { kind: 'line', xPt: 20, yPt: 20 }, { kind: 'line', xPt: 0, yPt: 20 }] }, - { startXPt: 5, startYPt: 5, closed: true, segments: [{ kind: 'line', xPt: 15, yPt: 5 }, { kind: 'line', xPt: 15, yPt: 15 }, { kind: 'line', xPt: 5, yPt: 15 }] }, - ], - fill: COLOR_BLACK, - fillRule: 'evenodd', - }; - expect(LayoutItemSchema.parse(withHole)).toEqual(withHole); - }); - - it('rejects a segment kind other than line/cubic', () => { - const invalid = { kind: 'path', subpaths: [{ startXPt: 0, startYPt: 0, closed: false, segments: [{ kind: 'quadratic', xPt: 1, yPt: 1 }] }] }; - expect(LayoutItemSchema.safeParse(invalid).success).toBe(false); - }); -}); - -describe('LayoutItemSchema sourcePath', () => { - it('survives a JSON round trip when set on every item kind', () => { - const itemsWithSourcePath: LayoutItem[] = [ - { ...text, sourcePath: 'sections[0].blocks[0].runs[0]' }, - { ...imageItem, sourcePath: 'sections[0].blocks[1]' }, - { ...rect, sourcePath: 'slides[0].shapes[0]' }, - { ...line, sourcePath: 'slides[0].shapes[1]' }, - { ...ellipse, sourcePath: 'slides[0].shapes[2]' }, - { ...path, sourcePath: 'pages[0].vectors[0]' }, - { ...link, sourcePath: 'sections[0].blocks[0].runs[1]' }, - ]; - for (const item of itemsWithSourcePath) { - const parsed = LayoutItemSchema.parse(item); - const roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); - expect(LayoutItemSchema.parse(roundTripped)).toEqual(item); - } - }); - - it('parses correctly when sourcePath is omitted, matching every other optional field', () => { - for (const item of [text, imageItem, rect, line, ellipse, path, link]) { - const parsed = LayoutItemSchema.parse(item); - expect(parsed.sourcePath).toBeUndefined(); - } - }); -}); - -function layoutDocument(): LayoutDocument { - return { - formatVersion: LAYOUT_FORMAT_VERSION, - metadata: { - title: 'Layout round trip', - author: 'document-content-model', - subject: 'testing', - keywords: ['layout', 'pdf'], - creator: 'document-content-model tests', - producer: 'document-content-model tests', // producer is normally PDF-only; exercised here as a plain optional field - createdIso: '2026-07-30T00:00:00.000Z', - modifiedIso: '2026-07-30T01:00:00.000Z', - }, - pages: [ - { - widthPt: 612, - heightPt: 792, - items: [text, imageItem, rect, line, ellipse, path, link], - notes: 'Speaker notes carried as a hidden annotation.', - }, - { - widthPt: 612, - heightPt: 792, - items: [text], - // deliberately no `notes` field, exercising the page-without-notes case - }, - ], - images: { - logo: { format: 'png', base64: 'AA==', widthPx: 32, heightPx: 32 }, - photo: { format: 'jpeg', base64: '/9k=', widthPx: 1024, heightPx: 768 }, - }, - }; -} - -describe('LayoutDocumentSchema round trips', () => { - it('deep-equals the original document after a JSON round trip, covering a page with notes and a page without', () => { - const original = layoutDocument(); - const parsed = LayoutDocumentSchema.parse(original); - const roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); - expect(LayoutDocumentSchema.parse(roundTripped)).toEqual(original); - }); - - it('accepts a minimal document with an empty page and empty image registry', () => { - const doc: LayoutDocument = { - formatVersion: LAYOUT_FORMAT_VERSION, - metadata: {}, - pages: [{ widthPt: 612, heightPt: 792, items: [] }], - images: {}, - }; - expect(LayoutDocumentSchema.parse(doc)).toEqual(doc); - }); - - it('rejects a mismatched formatVersion', () => { - expect( - LayoutDocumentSchema.safeParse({ formatVersion: 2, metadata: {}, pages: [], images: {} }).success, - ).toBe(false); - }); -}); diff --git a/src/layout.ts b/src/layout.ts deleted file mode 100644 index 0eda59d..0000000 --- a/src/layout.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { z } from 'zod'; -import { ColorSchema } from './color'; -import { ContentStrokeStyleSchema } from './content'; -import { LayoutMetadataSchema } from './metadata'; -import { LayoutFontSchema } from './style'; - -// Bumped whenever LayoutDocumentSchema's shape changes incompatibly, so a value serialized by one version of a consumer can be recognised (and rejected, rather than silently misread) by another. -export const LAYOUT_FORMAT_VERSION = 1; - -// sourcePath is assigned by each format's reader at read time and copied onto emitted LayoutItems by the layout engine; this package only defines the field, it doesn't generate values. Known limitation: sourcePath values are stable within one read+layout pass over a single document, not across edits -- inserting content earlier in a document shifts every later path. This is not a stable identity scheme for incremental re-layout; it exists for tagged/accessible-PDF-style traceability and debugging, not edit-tracking. - -// A single painted or annotated element on a page. Coordinates are always PDF user space: origin bottom-left, y increasing upward, unit = point. Every field carries an explicit Pt suffix so a caller can never accidentally mix this with ContentShape.frame's OOXML (top-left, y-down) space. -export const LayoutTextSchema = z.object({ - kind: z.literal('text'), - text: z.string(), - xPt: z.number(), - yPt: z.number(), // baseline - font: LayoutFontSchema, - sizePt: z.number().positive(), - color: ColorSchema, - widthPt: z.number().nonnegative().optional(), // measured (write path) or reported (read path) - rotationDeg: z.number().optional(), - underline: z.boolean().optional(), - sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from -}); -export type LayoutText = z.infer; - -export const LayoutImageSchema = z.object({ - kind: z.literal('image'), - imageId: z.string(), // key into LayoutDocument.images - xPt: z.number(), // bottom-left corner - yPt: z.number(), - widthPt: z.number().positive(), - heightPt: z.number().positive(), - rotationDeg: z.number().optional(), - sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from -}); -export type LayoutImage = z.infer; - -export const LayoutRectSchema = z.object({ - kind: z.literal('rect'), - xPt: z.number(), - yPt: z.number(), - widthPt: z.number().nonnegative(), - heightPt: z.number().nonnegative(), - fill: ColorSchema.optional(), - stroke: z.object({ color: ColorSchema, widthPt: z.number().positive() }).optional(), - sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from -}); -export type LayoutRect = z.infer; - -export const LayoutLineSchema = z.object({ - kind: z.literal('line'), - x1Pt: z.number(), - y1Pt: z.number(), - x2Pt: z.number(), - y2Pt: z.number(), - color: ColorSchema, - widthPt: z.number().positive(), - style: ContentStrokeStyleSchema.optional(), // stroke dash pattern hint; absent means 'solid', matching ContentStrokeSchema's own documented default - sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from -}); -export type LayoutLine = z.infer; - -export const LayoutEllipseSchema = z.object({ - kind: z.literal('ellipse'), - xPt: z.number(), // bottom-left corner of the bounding box - yPt: z.number(), - widthPt: z.number().positive(), - heightPt: z.number().positive(), - fill: ColorSchema.optional(), - stroke: z.object({ color: ColorSchema, widthPt: z.number().positive() }).optional(), - sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from -}); -export type LayoutEllipse = z.infer; - -// A path segment in page-absolute PDF user space (see LayoutPathSchema below), not the subpath's own local coordinate space -- unlike ContentVector's 'path' variant (document-content-model's content.ts), which is still in the source shape's local, viewBox-relative space and needs a frame to place it. By the time a LayoutPath exists, the layout engine has already resolved every point through flipY and shape placement, matching how LayoutLine's x1Pt/y1Pt/x2Pt/y2Pt are already page-absolute rather than carrying a separate frame. -export const LayoutPathSegmentSchema = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('line'), xPt: z.number(), yPt: z.number() }), - z.object({ - kind: z.literal('cubic'), - c1xPt: z.number(), - c1yPt: z.number(), - c2xPt: z.number(), - c2yPt: z.number(), - xPt: z.number(), - yPt: z.number(), - }), -]); -export type LayoutPathSegment = z.infer; - -// One contiguous subpath: an initial moveto point, then a sequence of line/cubic segments, closed or open -- the PDF content-stream model directly (m, then l/c per segment, then an optional h). -export const LayoutSubpathSchema = z.object({ - startXPt: z.number(), - startYPt: z.number(), - segments: z.array(LayoutPathSegmentSchema), - closed: z.boolean(), -}); -export type LayoutSubpath = z.infer; - -// A general vector path: one or more subpaths sharing one fill/stroke, painted with the given fill rule (PDF's f vs f* / B vs B*) -- the LayoutRect/LayoutEllipse fill/stroke shape convention, reused verbatim, plus fillRule since a path (unlike a rect or ellipse) can be self-intersecting or contain nested/overlapping subpaths where nonzero vs evenodd actually changes what paints. -export const LayoutPathSchema = z.object({ - kind: z.literal('path'), - subpaths: z.array(LayoutSubpathSchema), - fill: ColorSchema.optional(), - fillRule: z.enum(['nonzero', 'evenodd']).optional(), - stroke: z.object({ color: ColorSchema, widthPt: z.number().positive() }).optional(), - style: ContentStrokeStyleSchema.optional(), // stroke dash pattern hint; absent means 'solid', matching ContentStrokeSchema's own documented default - sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from -}); -export type LayoutPath = z.infer; - -// A URI annotation rectangle -- not painted content, but a clickable region. -export const LayoutLinkSchema = z.object({ - kind: z.literal('link'), - uri: z.string(), - xPt: z.number(), - yPt: z.number(), - widthPt: z.number().nonnegative(), - heightPt: z.number().nonnegative(), - sourcePath: z.string().optional(), // deterministic, document-order-derived path copied from the ContentDocument item this was laid out from -}); -export type LayoutLink = z.infer; - -export const LayoutItemSchema = z.discriminatedUnion('kind', [ - LayoutTextSchema, - LayoutImageSchema, - LayoutRectSchema, - LayoutLineSchema, - LayoutEllipseSchema, - LayoutPathSchema, - LayoutLinkSchema, -]); -export type LayoutItem = z.infer; - -export const LayoutPageSchema = z.object({ - widthPt: z.number().positive(), - heightPt: z.number().positive(), - items: z.array(LayoutItemSchema), // paints in array order, like a PDF content stream - // pptx speaker notes for the slide this page came from, if any -- carried as a private, non-visible entry on the PDF page's own dictionary, never painted into the page content. PDF has no native concept of hidden presenter notes, so this is a round-trip mechanism specific to a writer/reader pair that both honour it, not a real PDF feature -- a PDF produced by anything else will never have it, and a PDF consumer that doesn't specifically know this convention will never see it either. - notes: z.string().optional(), -}); -export type LayoutPage = z.infer; - -// An entry in the top-level image registry: bytes live here once, keyed by imageId, so a repeated logo across many pages/slides embeds (or extracts) exactly once. Bytes are the original file bytes for the given format -- PNG bytes are re-encoded from decoded pixels where needed; JPEG bytes are the original encoded stream, verbatim, in both directions. -export const LayoutImageAssetSchema = z.object({ - format: z.enum(['png', 'jpeg']), - base64: z.string(), - widthPx: z.number().int().positive(), - heightPx: z.number().int().positive(), -}); -export type LayoutImageAsset = z.infer; - -export const LayoutDocumentSchema = z.object({ - formatVersion: z.literal(LAYOUT_FORMAT_VERSION), - metadata: LayoutMetadataSchema, - pages: z.array(LayoutPageSchema), - images: z.record(z.string(), LayoutImageAssetSchema), -}); -export type LayoutDocument = z.infer; diff --git a/src/metadata.ts b/src/metadata.ts index bc4f51f..5f90787 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -// The metadata shape shared by ContentDocument and LayoutDocument. Ported from documents.js's src/model/layout.ts (LayoutMetadataSchema), extracted into its own file rather than staying inside layout.ts so content.ts doesn't have to import from layout.ts for it -- a content model depending on the layout model for its own metadata type was backwards. `producer` is a PDF-only concept (the tool that wrote the PDF); it has no OOXML/ODF equivalent and is simply absent -- never set -- when a ContentDocument or a purely-semantic reader (e.g. ooxml.js's own DocumentMetadata) populates this shape. +// The metadata shape shared by ContentDocument and the DocumentPackage tree root. Ported from documents.js's src/model/layout.ts (LayoutMetadataSchema), extracted into its own file back when a layout model lived in this package, so content.ts never depended on that model for it -- a content model depending on the layout model for its own metadata type was backwards. `producer` is a PDF-only concept (the tool that wrote the PDF); it has no OOXML/ODF equivalent and is simply absent -- never set -- when a ContentDocument or a purely-semantic reader (e.g. ooxml.js's own DocumentMetadata) populates this shape. export const LayoutMetadataSchema = z.object({ title: z.string().optional(), author: z.string().optional(), diff --git a/src/style.ts b/src/style.ts index df7ea4e..07857cf 100644 --- a/src/style.ts +++ b/src/style.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; export const AlignmentSchema = z.enum(['left', 'center', 'right', 'justify']); export type Alignment = z.infer; -// A requested font, independent of any concrete rendering backend -- part of LayoutDocument's own shape (every LayoutText needs one), not OOXML/ODF-specific. documents.js's src/pdf/fonts.ts resolves this to one of the 14 standard PDF faces at write time; src/pdf/font-read.ts produces one from a PDF's own /BaseFont + /FontDescriptor at read time -- that resolution behaviour stays in documents.js, only the shape lives here. +// A requested font, independent of any concrete rendering backend -- a rendering concern, not OOXML/ODF-specific (it originated as part of the layout model this package once carried; it stays because the text-layout port contracts below it and pdf-codec's own layout model both speak it). documents.js's src/pdf/fonts.ts resolves this to one of the 14 standard PDF faces at write time; src/pdf/font-read.ts produces one from a PDF's own /BaseFont + /FontDescriptor at read time -- that resolution behaviour stays in documents.js, only the shape lives here. export const LayoutFontSchema = z.object({ family: z.string(), weight: z.enum(['normal', 'bold']), From 7b1cdec01dfc4b16664a3447c2e37e60825cac07 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 18 Aug 2026 11:09:06 +0100 Subject: [PATCH 3/6] docs: document the package tree, the three laws, and $schema versioning README gains "The package tree" (the group/leaf vocabulary, the container rules, and the three laws as the contract with document-outline.js), "Definitions tables and styles" (the tenant-generic facility with styles as first tenant, the schema-shape ban list, and the overlay chain), and "Versioning by $schema" (the release-pinned URI as the version, the documentFromJson dispatch contract, and the hash-exclusion rule). The Usage, Codecs, and JSON Schema sections are rewritten for the tree form, the two published schema files, and ContentCodec alone; the layout family is documented as pdf-codec-private. --- README.md | 113 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 82 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 42d1d3d..0484c75 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/document-schema.js) [![npm](https://img.shields.io/badge/npm-CB3837?logo=npm&logoColor=white)](https://www.npmjs.com/package/document-schema.js) [![Release](https://img.shields.io/github/v/release/ExaDev/document-schema.js)](https://github.com/ExaDev/document-schema.js/releases/latest) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/document-schema.js/ci.yml?branch=main)](https://github.com/ExaDev/document-schema.js/actions) -> The canonical, format-agnostic content and layout schema pivot shared by [ooxml.js](https://github.com/ExaDev/ooxml.js), [odf.js](https://github.com/ExaDev/odf.js), [documents.js](https://github.com/ExaDev/documents.js), [pdf-codec](https://github.com/ExaDev/pdf-codec), and [markdown-codec](https://github.com/ExaDev/markdown-codec). +> The canonical, format-agnostic content and document-package schema pivot shared by [ooxml.js](https://github.com/ExaDev/ooxml.js), [odf.js](https://github.com/ExaDev/odf.js), [documents.js](https://github.com/ExaDev/documents.js), [pdf-codec](https://github.com/ExaDev/pdf-codec), and [markdown-codec](https://github.com/ExaDev/markdown-codec). Both `ooxml.js` and `documents.js` independently arrived at the same content vocabulary, producing two field-identical copies in two places. This package is the fix: one schema, imported by every format package instead of redefined by each. It also sidesteps a circular dependency (`documents.js` depends on both `ooxml.js` and `odf.js`). @@ -48,30 +48,70 @@ graph TD style schema fill:#f9a825,stroke:#333,stroke-width:3px ``` -`ContentDocument` (the semantic pivot) is a discriminated union of five kinds: `wordprocessing` (docx/odt sections of paragraphs/runs/tables/images), `presentation` (pptx/odp slides of shapes), `spreadsheet` (xlsx/ods sheets of cells, columns, rows, print settings), `drawing` (odg pages of shapes plus vector primitives — rect/ellipse/line/path), and `formula` (an equation carrying its own MathML node tree plus StarMath source when the producing format had one, extended with the two-layer math model: an optional verbatim-LaTeX `presentation` authoritative for rendering, an optional semantic `content: MathExpression` tree authoritative for computation, and provenance — neither layer stored derived from the other, so editing one never silently mutates the other). `ContentEmbeddedObjectSchema` lets any of the five embed another whole `ContentDocument`. Every paragraph/run/image/table/shape/vector/spreadsheet-cell leaf also carries its own canonical `headingLevel`-or-position fields directly: a `ContentParagraph`'s optional `headingLevel` (1 = the outermost heading, independent of the round-trip-only `styleId`), and every such leaf's optional `frames: LayoutFrame[]` — that node's own rendered page position(s) (`pageIndex` plus PDF user-space `xPt`/`yPt`/`widthPt`/`heightPt`), fused directly onto the content tree once a layout pass has run. `LayoutDocument` (the PDF-rendering pivot pdf-codec's `readPdf`/`writePdf` operate on directly, independent of any `ContentDocument`) is pages of positioned `LayoutItem`s (`text`/`image`/`rect`/`line`/`ellipse`/`path`/`link`) in PDF user-space coordinates. `DocumentPackageSchema` wraps `content` (required) with `pages` (optional, derived: each rendered page's own size, indexed to match every node's own `frames[].pageIndex`) — a single fused tree rather than a second, independent `LayoutDocument` correlated back to `content` only by matching `sourcePath` strings; the schema does not keep `content`'s populated `frames` fields and `pages` in sync or detect staleness. Every one of the five kinds also accepts an optional document-level `symbolTable` — the math curation layer mapping each written symbol glyph (within a scope) to its id, quantity kind, preferred unit, and definition source, alongside the unit registry (SI dimension-exponent vectors, exact rational conversions, per-unit-system normalisation contexts) that the `qty` nodes of lowered formulas resolve against. +`ContentDocument` (the semantic pivot) is a discriminated union of five kinds: `wordprocessing` (docx/odt sections of paragraphs/runs/tables/images), `presentation` (pptx/odp slides of shapes), `spreadsheet` (xlsx/ods sheets of cells, columns, rows, print settings), `drawing` (odg pages of shapes plus vector primitives — rect/ellipse/line/path), and `formula` (an equation carrying its own MathML node tree plus StarMath source when the producing format had one, extended with the two-layer math model: an optional verbatim-LaTeX `presentation` authoritative for rendering, an optional semantic `content: MathExpression` tree authoritative for computation, and provenance — neither layer stored derived from the other, so editing one never silently mutates the other). `ContentEmbeddedObjectSchema` lets any of the five embed another whole `ContentDocument`. Every paragraph/run/image/table/shape/vector/spreadsheet-cell leaf also carries its own canonical `headingLevel`-or-position fields directly: a `ContentParagraph`'s optional `headingLevel` (1 = the outermost heading, independent of the round-trip-only `styleId`), and every such leaf's optional `frames: LayoutFrame[]` — that node's own rendered page position(s) (`pageIndex` plus PDF user-space `xPt`/`yPt`/`widthPt`/`heightPt`), fused directly onto the content tree once a layout pass has run. `DocumentPackage` is the single hierarchical artefact — structure, layout, and content fused in one tree (see [The package tree](#the-package-tree)): the root carries `kind`, `metadata`, the optional document-level `symbolTable` and rendered `pages`, the optional package-level `styles`/`definitions` tables (see [Definitions tables and styles](#definitions-tables-and-styles)), and `children` — one group per top-level container with the content tree grouped inside it; the schema does not keep populated `frames` fields and `pages` in sync or detect staleness, and does not check that a tree's `style` refs name table entries (both are producer responsibilities, exactly as the frames/pages pairing always was). Every one of the five kinds also accepts an optional document-level `symbolTable` — the math curation layer mapping each written symbol glyph (within a scope) to its id, quantity kind, preferred unit, and definition source, alongside the unit registry (SI dimension-exponent vectors, exact rational conversions, per-unit-system normalisation contexts) that the `qty` nodes of lowered formulas resolve against. -The package contains only [Zod](https://zod.dev) schemas, their inferred types, trivial schema-attached helpers (hex-colour conversion, recursive structural type guards), and two small structural interfaces (`ContentCodec`/`LayoutCodec`, see [Codecs](#codecs)). No XML, ZIP, PDF, or binary handling; the sole dependency is `zod`. +The `LayoutDocument` family (pages of positioned `LayoutItem`s — `text`/`image`/`rect`/`line`/`ellipse`/`path`/`link` in PDF user-space coordinates) no longer lives here: 4.0.0 demoted it to a pdf-codec-private model ([pdf-codec#65](https://github.com/ExaDev/pdf-codec/issues/65)), where the only codec that ever read or wrote it owns it outright. `documentFromJson` recognises old layout-document `$schema` URIs and throws a tombstone pointing at pdf-codec rather than failing as if the value were unrelated. Dependents stay on document-schema.js 3.x via semver until their own majors, so the demotion is not a cascade-breaker. + +The package contains only [Zod](https://zod.dev) schemas, their inferred types, trivial schema-attached helpers (hex-colour conversion, recursive structural type guards, the style-resolution helpers of `src/definitions.ts`), and one small structural interface (`ContentCodec`, see [Codecs](#codecs)). No XML, ZIP, PDF, or binary handling; the sole dependency is `zod`. Two format-agnostic helpers live here because they operate on the content model itself: cell-addressing utilities in `src/a1.ts` (0-based row/column indices, row-first order matching `ContentSheetCell`'s `{row, column}`) and the `FontFace` interface in `src/font-port.ts` (`{family, bold, italic}`). ## Usage ```ts -import { ContentDocumentSchema, DocumentPackageSchema, LayoutDocumentSchema } from 'document-schema.js'; +import { ContentDocumentSchema, DocumentPackageSchema } from 'document-schema.js'; +// The codec-exchange form: what every format's reader produces and every writer consumes -- always flat, +// always fully materialised (no styles table, no refs), never versioned (that lives on the serialised artefact). const content = ContentDocumentSchema.parse(someWordprocessingOrPresentationValue); -// A content-only package -- no layout pass has run yet, so no node carries `frames` and `pages` stays absent. -const pkg = DocumentPackageSchema.parse({ formatVersion: 2, content }); -// Once a layout pass has fused rendered positions onto content's own nodes (each via its own `frames` array) -// and reported each page's own size, `pages` is populated to match: -const laidOut = DocumentPackageSchema.parse({ formatVersion: 2, content: someAlreadyPositionedContent, pages: [{ widthPt: 612, heightPt: 792 }] }); +// The package tree: what a serialised dump carries. `children` holds one group per top-level container, +// with the content grouped inside it (see "The package tree" below). +const pkg = DocumentPackageSchema.parse({ + kind: 'wordprocessing', + metadata: { title: 'Example' }, + children: [ + { + node: { kind: 'section', pageSize: { widthPt: 612, heightPt: 792 }, margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 } }, + children: [ + { node: { kind: 'paragraph', headingLevel: 1, runs: [{ text: 'Heading' }] }, children: [] }, + { kind: 'paragraph', runs: [{ text: 'Body.' }] }, + ], + }, + ], +}); -// LayoutDocumentSchema is unrelated to DocumentPackageSchema -- it is the standalone PDF-rendering pivot -// pdf-codec's own readPdf/writePdf read and write directly, with no ContentDocument in the loop at all. -const layout = LayoutDocumentSchema.parse(somePdfPageLayoutValue); +// Once a layout pass has fused rendered positions onto the tree's own nodes (each via its own `frames` array) +// and reported each page's own size, `pages` is populated to match: +const laidOut = DocumentPackageSchema.parse({ ...pkg, pages: [{ widthPt: 612, heightPt: 792 }] }); ``` +## The package tree + +`DocumentPackage` ([#20](https://github.com/ExaDev/document-schema.js/issues/20)) is the promoted single hierarchical artefact — one tree where 3.x carried `{ formatVersion, content, pages }` with the content flat. The tree's vocabulary is defined in `src/package-node.ts` and was proven first as [document-outline.js](https://github.com/ExaDev/document-outline.js)'s phase-1 `decompose`/`flatten` implementation ([document-outline.js#2](https://github.com/ExaDev/document-outline.js/issues/2)); this package's schemas are that shape's schema-home port, matching it node for node: + +- **Groups** are `{ node, children }` where `node` embeds either an anchor paragraph (heading groups and list-item groups carry the full `ContentParagraph` — runs, formatting, frames — never a projected text label) or a container descriptor: `{ kind: 'section', pageSize, margins }`, `{ kind: 'slide', size, notes }`, `{ kind: 'sheet', name, cells, columns, rows, printSettings }`, `{ kind: 'drawPage', size }`, each tagged with a `kind` the flat container type does not carry, or a shape group's untagged frame descriptor. +- **Bare leaves** carry their own `kind` and never `children`. Discrimination is structural on `node`+`children`, not on the presence of a `kind`. +- **Section groups are mandatory** — one per `ContentSection` — because a section carries pre-layout page geometry (`pageSize`/`margins`) that a rendered `pages` array cannot hold. +- **Grouping never crosses container boundaries**: a shape is its own group with its inner blocks grouped inside it (never a slide's paragraphs flattened across its shapes — that is a TOC projection, not a decomposition); a sheet's grid rides on the sheet node with images and embedded documents as children; embedded documents stay intact as one leaf. +- **Style refs ride on group wrappers only** — a group may carry `style: string` naming a `styles` table entry; `ContentDocument` nodes carry no ref field, so the flat codec-exchange form is always fully materialised. + +The flat `ContentDocument` and the tree are **one format, two encodings**, related by three laws — the contract with document-outline.js (which property-tests them over real corpus documents) and, at the package boundary, with documents.js ([documents.js#623](https://github.com/ExaDev/documents.js/issues/623)): + +1. **Strict structural equality, both directions, for table-free packages** — `decompose(flatten(pkg))` and `flatten(decompose(pkg))` reproduce their input exactly. +2. **Effective-property equality, universally** — resolve styles first, then compare: a factored and an unfactored serialisation of one document are equal (this is also why content hashing and structural diffing resolve first). +3. **Minting idempotence** — factoring a package a second time mints the identical styles table: `decompose(flatten(decompose(x))) === decompose(x)`. + +The codecs do not change: they keep producing flat `ContentDocument`s (their natural reading shape); decomposition runs once at the package boundary in documents.js and flatten runs once where a builder consumes a package. + +## Definitions tables and styles + +The package root carries a generic definitions-table facility ([#21](https://github.com/ExaDev/document-schema.js/issues/21)): named tables whose entries tree nodes reference by string id. **Styles are the first tenant**; link and footnote definitions are future tenants of the same mechanism ([markdown-codec#63](https://github.com/ExaDev/markdown-codec/issues/63), [#22](https://github.com/ExaDev/document-schema.js/issues/22)) — which is why the tenant-generic `definitions` table (entries tagged with a `kind` discriminator and an open body, `src/definitions.ts`) sits alongside the styles-specific `styles` table rather than the facility being shaped around styles. + +A styles entry carries `{ paragraph?, run? }` sub-objects of **resolved canonical properties only**: paragraph `alignment`/`list`/`spacingBeforePt`/`spacingAfterPt`/`lineSpacing`/`indentLeftPt`/`indentFirstLinePt`, run `bold`/`italic`/`underline`/`strike`/`fontFamily`/`sizePt`/`color`. Never `frames`, never `sourcePath`, never `styleId` (per-node facts — a position is a fact about a node, not a style), never a `basedOn` graph (the table is a dictionary, not a program) — and the ban list is **enforced by schema shape** (strict objects that reject those keys outright), not merely documented. + +Resolution is one overlay chain — outermost ancestor group's style, each nearer group's style, the node's own direct properties; innermost wins, with the resolved run half applying one level further down as run defaults under each run's own properties. `src/definitions.ts` exports the pure helpers that implement it (`overlayStyleEntries`, `resolveStyleChain`, `applyParagraphStyleProperties`, `applyRunStyleProperties`); minting entries (the deterministic frequency pass that factors repeated property tuples into `s1`, `s2`, … refs) is documents.js's boundary behaviour, not this package's. + Every module is also importable directly — `tsdown` builds one file per source module, and `package.json`'s `"./*"` export makes each individually resolvable: ```ts @@ -81,24 +121,23 @@ import { ColorSchema } from 'document-schema.js/color'; ## Codecs -`ContentCodec`/`LayoutCodec` (`src/codec.ts`) are the format-agnostic *interfaces* a sibling package's docx/pptx/odt/odp/ods/odg/xlsx/markdown/PDF codec can implement, so a caller working across formats holds one of these instead of a format-specific function pair: +`ContentCodec` (`src/codec.ts`) is the format-agnostic *interface* a sibling package's docx/pptx/odt/odp/ods/odg/xlsx/markdown codec can implement, so a caller working across formats holds one of these instead of a format-specific function pair: ```ts -import type { ContentCodec, LayoutCodec } from 'document-schema.js'; +import type { ContentCodec } from 'document-schema.js'; declare const docxCodec: ContentCodec; // read(bytes) -> ContentDocument; write(content) -> bytes -- write is optional -declare const pdfCodec: LayoutCodec; // read(bytes) -> LayoutDocument; write(layout) -> bytes -- write is required ``` -The two are separate interfaces (not one `DocumentCodec`) because the formats are asymmetric: most produce only *content* on read (layout is a later engine-driven step); PDF produces *layout* cheaply and content only via a separate, lossy reconstruction pass. `ContentCodec.write` is optional (`odf` has a reader but no builder — recovering MathML from glyphs is OCR-adjacent); `LayoutCodec.write` is required. Both are generic over their own `TOptions`. +`ContentCodec.write` is optional (`odf` has a reader but no builder — recovering MathML from glyphs is OCR-adjacent), and the interface is generic over its own `TOptions`. There is no `LayoutCodec` any more: it modelled the one format that produces layout cheaply on read — PDF — and the whole `LayoutDocument` family it described moved to pdf-codec in 4.0.0 (see [pdf-codec#65](https://github.com/ExaDev/pdf-codec/issues/65)). -Neither interface constructs a `DocumentPackage`; composing one is the caller's job (`documents.js`'s `DOCUMENT_FORMAT_CODECS` registry is the concrete example). +The interface constructs no `DocumentPackage`; composing one (decomposing a codec's flat `ContentDocument` into the tree) is the caller's job (`documents.js`'s `DOCUMENT_FORMAT_CODECS` registry is the concrete example). This package also hosts the **port contracts** a layout engine consumes: `TextMeasurer`/`StyledRun`/`WrappedLine` (`src/text-layout.ts`), `ProvidedFont`/`FontSubstitution` (`src/font-port.ts`), `MathBox`/`MathFontMetrics`/`PositionedFormula` (`src/math-layout.ts`), and `Point` (`src/geometry.ts`). ## JSON Schema -Three plain [JSON Schema](https://json-schema.org) files are published — generated from the Zod definitions via [`z.toJSONSchema()`](https://zod.dev/json-schema) at build time (`scripts/generate-json-schemas.mjs`) — for non-TypeScript consumers: +Two plain [JSON Schema](https://json-schema.org) files are published — generated from the Zod definitions via [`z.toJSONSchema()`](https://zod.dev/json-schema) at build time (`scripts/generate-json-schemas.mjs`) — for non-TypeScript consumers: ```ts const documentPackageSchema = require('document-schema.js/schemas/document-package.schema.json'); @@ -111,14 +150,13 @@ or from any language/tool that can read a file out of `node_modules`: ``` node_modules/document-schema.js/schemas/document-package.schema.json node_modules/document-schema.js/schemas/content-document.schema.json -node_modules/document-schema.js/schemas/layout-document.schema.json ``` -Each file's `$id` is a jsdelivr URL pinned to the exact npm version — immutable and live on publish. The three files cross-reference via `$ref`s, so a validator resolving refs over HTTP can validate a whole `DocumentPackage`. `content-document.schema.json` carries a hand-authored `$defs` block for the recursive paragraph/table/embedded-object and MathML node models (Zod's converter cannot express these directly); `content-json-schema-defs.ts` holds those fragments, and a regression test compares each against a live `z.toJSONSchema()` of its real Zod counterpart so a field changed without updating its fragment fails a test. Fragments downstream of a `z.custom()` node (`ContentBlock`, `ContentTable`/`Cell`/`Row`, `ContentEmbeddedObjectBlock`, `MathMlNode`/`Element`/`Attribute`, `ContentFormula`, `MathExpression` and its recursive variants) still need hand re-verification against `src/content.ts`/`src/mathml.ts`/`src/math.ts` — see below. +Each file's `$id` is a jsdelivr URL pinned to the exact npm version — immutable and live on publish, and (see [Versioning by `$schema`](#versioning-by-schema)) the version of anything stamped with it. Both files carry the same hand-authored `$defs` block (the same object emitted twice in one generator run, so the copies cannot drift), covering the recursive paragraph/table/embedded-object, MathML, and package-tree node models that Zod's converter cannot express directly; `content-json-schema-defs.ts` holds those fragments, and a regression test compares each fragment that has a real Zod counterpart against a live `z.toJSONSchema()` of that schema so a field changed without updating its fragment fails a test. The one deliberate cross-file `$ref` is the embedded-object cycle back to a whole `ContentDocument`. Fragments downstream of a `z.custom()` node (`ContentBlock`, `ContentTable`/`Cell`/`Row`, `ContentEmbeddedObject(Block)`, the seven package-tree group wrappers, `MathMlNode`/`Element`/`Attribute`, `ContentFormula`, `MathExpression` and its recursive variants) still need hand re-verification against `src/content.ts`/`src/package-node.ts`/`src/mathml.ts`/`src/math.ts` — see below. ### `z.custom()` vs `z.lazy()` for recursive schemas -`ContentBlockSchema`, `ContentEmbeddedObjectSchema`, `MathMlNodeSchema`, and `MathExpressionSchema` are `z.custom()` type-guard predicates rather than real Zod schemas, because `z.lazy()` was believed to collapse to `unknown` for recursive children. A throwaway spike (reverted) re-tested `MathMlNodeSchema` (the simplest case) against `zod@4.4.3`. +`ContentBlockSchema`, `ContentEmbeddedObjectSchema`, the package tree's per-kind group schemas (`src/package-node.ts`), `MathMlNodeSchema`, and `MathExpressionSchema` are `z.custom()` type-guard predicates rather than real Zod schemas, because `z.lazy()` was believed to collapse to `unknown` for recursive children. A throwaway spike (reverted) re-tested `MathMlNodeSchema` (the simplest case) against `zod@4.4.3`. **Finding: `z.lazy()` works now, with one constructional gotcha.** The naive rewrite — @@ -138,41 +176,54 @@ export const MathMlNodeSchema: z.ZodType = z.discriminatedUnion('typ **This is a tracked follow-up, not carried out here.** Converting `MathMlNodeSchema` for real would let the JSON-schema generator drop its hand-authored `$defs` entries; `ContentBlockSchema`/`ContentEmbeddedObjectSchema` are harder (mutual recursion across table/cell/row, plus the full `ContentDocument` cycle) and were not spiked. +### Versioning by `$schema` + +There is no `formatVersion` field anywhere in a 4.0.0 dump. A serialised value states its version through the release-pinned `$schema` URI its dumper stamped, and that URI **is** the version — one source of truth instead of a hand-kept integer beside URIs that already named the release. `documentFromJson` is the enforcement point for untrusted input: + +- a URI from the **same major** as the installed release parses (patch and minor releases are semver-compatible with their major's schema generation); +- an **older major's** URI throws `SchemaVersionMismatchError` naming the change — pre-4.0.0 dumps carry the retired `formatVersion` field and the flat `{ formatVersion, content, pages }` package shape, replaced by the tree form ([#20](https://github.com/ExaDev/document-schema.js/issues/20)); +- a **newer major's** URI throws the same error with the upgrade pointer; +- a **layout-document** URI (any release) throws `LayoutSchemaDemotedError` pointing at pdf-codec — the demotion tombstone. + +A bare `DocumentPackageSchema.parse(value)` does **not** version-discriminate — it structurally validates whatever it is handed against the installed schema, full stop — so a caller ingesting a dump it did not itself produce must go through `documentFromJson`, not a direct parse. `documentSchemaKindOf(value)` still answers "which kind does this URI name" version-agnostically without parsing. Content hashes and structural comparisons over serialised dumps must exclude `$schema` — it is envelope metadata that names the dumper, not content; two dumps of one document by two releases hash equal once it is excluded. + ### Self-describing JSON -`documentPackageWithSchema`/`contentDocumentWithSchema`/`layoutDocumentWithSchema` each stamp a `$schema` property pointing at the `.schema.json` file for the currently installed version: +`documentPackageWithSchema`/`contentDocumentWithSchema` each stamp a `$schema` property pointing at the `.schema.json` file for the currently installed version: ```ts import { documentPackageWithSchema } from 'document-schema.js'; const tagged = documentPackageWithSchema(pkg); -// { $schema: 'https://cdn.jsdelivr.net/npm/document-schema.js@2.0.0/schemas/document-package.schema.json', formatVersion: 2, content: {...}, pages: [...] } +// { $schema: 'https://cdn.jsdelivr.net/npm/document-schema.js@4.0.0/schemas/document-package.schema.json', kind: 'wordprocessing', metadata: {...}, children: [...] } writeFileSync('package.json.doc', JSON.stringify(tagged, null, 2)); ``` -A caller who already knows the kind can keep using the schemas directly — `DocumentPackageSchema.parse(value)` tolerates and strips an incoming `$schema` (none are `.strict()`). `documentFromJson` is for the "don't yet know the kind" case, reading `$schema` to decide which schema to run: +A caller who already knows the kind can keep using the schemas directly — `DocumentPackageSchema.parse(value)` tolerates and strips an incoming `$schema` (none are `.strict()`). `documentFromJson` is for the "don't yet know the kind or provenance" case, reading `$schema` to decide which schema to run and whether this release may run it: ```ts -import { documentFromJson, UnrecognizedDocumentSchemaError } from 'document-schema.js'; +import { documentFromJson, SchemaVersionMismatchError, UnrecognizedDocumentSchemaError } from 'document-schema.js'; try { const { kind, value } = documentFromJson(JSON.parse(readFileSync('some-file.json', 'utf8'))); - // kind: 'DocumentPackage' | 'ContentDocument' | 'LayoutDocument' + // kind: 'DocumentPackage' | 'ContentDocument' } catch (error) { if (error instanceof UnrecognizedDocumentSchemaError) { console.error('not a document-schema.js value:', error.schema); + } else if (error instanceof SchemaVersionMismatchError) { + console.error(`dump is @${error.dumpVersion}, installed is @${error.installedVersion}`); } } ``` -`documentSchemaKindOf(value)` returns the kind version-agnostically without parsing; `schemaUriFor(kind)` is the URL builder. No JSON-Schema-validator dependency (e.g. `ajv`) for ingest — the `.schema.json` files are a weaker approximation of the real Zod schemas, so re-validating against them would be a fidelity regression. +`schemaUriFor(kind)` is the URL builder. No JSON-Schema-validator dependency (e.g. `ajv`) for ingest — the `.schema.json` files are a weaker approximation of the real Zod schemas, so re-validating against them would be a fidelity regression. ## Used by - [ooxml.js](https://github.com/ExaDev/ooxml.js) — `readDocx`/`readPptx`/`readXlsxContent` return types are typed against this package's schemas, not a local lookalike. - [odf.js](https://github.com/ExaDev/odf.js) — ODF typed readers return the same shared types, so ODF and OOXML speak the identical pivot. -- [documents.js](https://github.com/ExaDev/documents.js) — primary consumer of `ContentDocument`, `LayoutDocument`, and `DocumentPackage`; its `DOCUMENT_FORMAT_CODECS` registry implements `ContentCodec`/`LayoutCodec` per format. -- [pdf-codec](https://github.com/ExaDev/pdf-codec) — `readPdf`/`writePdf` operate on this package's `LayoutDocument` and item kinds, never redeclaring them. +- [documents.js](https://github.com/ExaDev/documents.js) — primary consumer of `ContentDocument` and `DocumentPackage`; its `DOCUMENT_FORMAT_CODECS` registry implements `ContentCodec` per format, and its package boundary runs decompose/flatten against the tree form. +- [pdf-codec](https://github.com/ExaDev/pdf-codec) — owns its layout item model outright since 4.0.0; `readPdf`/`writePdf` operate on pdf-codec's own `LayoutDocument`, and this package's `ContentDocument` remains its content pivot. - [markdown-codec](https://github.com/ExaDev/markdown-codec) — `readMarkdown`/`writeMarkdown` read and write this package's `ContentDocument` directly. None depend on each other for this vocabulary — each depends on `document-schema.js` directly. @@ -183,13 +234,13 @@ Requires Node.js `>=20` and pnpm `11.6.0` (pinned via `packageManager` in `packa ```sh pnpm install -pnpm build # turbo run _build -> tsdown && node scripts/generate-json-schemas.mjs (ESM + CJS + .d.ts in dist/, plus the three published .schema.json files in schemas/) +pnpm build # turbo run _build -> tsdown && node scripts/generate-json-schemas.mjs (ESM + CJS + .d.ts in dist/, plus the two published .schema.json files in schemas/) pnpm typecheck # turbo run _typecheck _typecheck:node -> tsc -p tsconfig.json && tsc -p tsconfig.node.json pnpm lint # turbo run _lint -> eslint . --fix --cache --max-warnings 0 pnpm test # turbo run _test -> vitest run --project unit pnpm test:workers # turbo run _test:workers -> vitest run --config vitest.workers.config.ts (runs the test/workers suite under the real Cloudflare Workers runtime via @cloudflare/vitest-pool-workers, turning "pure Zod, no Node-API usage" into a runtime-checked fact rather than an assertion) pnpm test:watch # vitest --project unit -pnpm test:smoke # turbo run _test:smoke -> rebuilds dist/ and schemas/ first, then verifies the built ESM/CJS output loads and exposes the public surface, and that the three generated JSON Schema files exist and are correctly version-pinned +pnpm test:smoke # turbo run _test:smoke -> rebuilds dist/ and schemas/ first, then verifies the built ESM/CJS output loads and exposes the public surface, and that the two generated JSON Schema files exist and are correctly version-pinned ``` To run a single test file: `pnpm vitest run src/path/to/file.test.ts`. From 9e4f348739a22964c8c1cbb5bcff03c5edcb8d3a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 18 Aug 2026 11:31:56 +0100 Subject: [PATCH 4/6] fix: reject wrapper keys outside node/style/children and style refs on bare package-tree leaves The runtime guards now enforce what the published JSON Schema fragments have always declared: every group wrapper fragment carries additionalProperties: false over exactly { node, style, children }, so isGroupWrapper rejects a wrapper with any fourth key instead of letting an unknown key ride through unvalidated. The leaf arms of the five child predicates reject a value carrying a top-level style key before delegating to the content schemas. A style ref is legal only on a group wrapper (resolution walks group ancestors, never leaf payloads), and the shared content schemas deliberately accept-and-ignore unknown keys -- tightening them to strict would change flat ContentDocument parsing far beyond the package tree -- so without this check a leaf-position ref parsed, sat inert through resolution, and was still rejected by the published .schema.json leaf fragments: a tree documentFromJson accepted that the CDN-published schema forbids. --- src/package-node.test.ts | 36 ++++++++++++++++++++++++++++++++++++ src/package-node.ts | 19 +++++++++++++------ src/package.test.ts | 21 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/package-node.test.ts b/src/package-node.test.ts index 51290ef..89ab6a2 100644 --- a/src/package-node.test.ts +++ b/src/package-node.test.ts @@ -283,6 +283,42 @@ describe('the package tree rejects near-misses', () => { expect(SectionGroupSchema.safeParse(broken).success).toBe(false); }); + it("rejects a group wrapper carrying a key outside { node, style, children }, exactly as the published fragments' additionalProperties: false does", () => { + const broken = { + node: { kind: 'section', pageSize: PAGE, margins: MARGINS }, + junkKey: 'x', + children: [], + }; + expect(SectionGroupSchema.safeParse(broken).success).toBe(false); + }); + + it('rejects a style ref on a bare leaf at a child position, in every leaf family -- refs sit on group wrappers only, so a leaf-position ref fails loudly instead of parsing inert', () => { + const section = sectionGroup(); + const withLeafRef = { + ...section, + children: [...section.children, { kind: 'paragraph', runs: [run('leaf')], style: 's1' }], + }; + expect(SectionGroupSchema.safeParse(withLeafRef).success).toBe(false); + + const sheet = sheetGroup(); + const sheetImage = sheet.children[0]; + if (sheetImage === undefined || !('format' in sheetImage)) throw new Error('fixture shape'); + const sheetWithLeafRef = { ...sheet, children: [{ ...sheetImage, style: 's1' }] }; + expect(SheetGroupSchema.safeParse(sheetWithLeafRef).success).toBe(false); + + const drawPage = drawPageGroup(); + const vectorWithRef = { + kind: 'line', + from: { xPt: 0, yPt: 0 }, + to: { xPt: 10, yPt: 10 }, + stroke: { color: { r: 0, g: 0, b: 0 }, widthPt: 1 }, + style: 's1', + }; + expect(DrawPageGroupSchema.safeParse({ ...drawPage, children: [...drawPage.children, vectorWithRef] }).success).toBe( + false, + ); + }); + it('rejects a malformed leaf payload at a child position (an image whose width is not a number)', () => { const broken = { node: { kind: 'section', pageSize: PAGE, margins: MARGINS }, diff --git a/src/package-node.ts b/src/package-node.ts index 10fa29e..dcf68a3 100644 --- a/src/package-node.ts +++ b/src/package-node.ts @@ -133,31 +133,38 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -// The shared wrapper shape every group guard checks: a record whose `node` is itself a record, whose `children` is an array of values each satisfying that group kind's own child predicate, and whose optional `style` ref is a string when present. Per-kind child predicates (not one generic isPackageNode) are what make these guards the untrusted-input boundary: a tree that hangs a paragraph leaf directly off a slide group, or a section group off a sheet, is structurally illegal and rejects here, where the reference implementation's own guard checks children generically (it walks trees it constructed itself; this schema's job is to validate trees it did not). +// The shared wrapper shape every group guard checks: a record whose `node` is itself a record, whose `children` is an array of values each satisfying that group kind's own child predicate, whose optional `style` ref is a string when present, and which carries no other keys -- every group fragment in content-json-schema-defs.ts declares additionalProperties: false over exactly { node, style, children }, so a wrapper with any fourth key must fail here too, or documentFromJson would accept a value the published .schema.json rejects. Per-kind child predicates (not one generic isPackageNode) are what make these guards the untrusted-input boundary: a tree that hangs a paragraph leaf directly off a slide group, or a section group off a sheet, is structurally illegal and rejects here, where the reference implementation's own guard checks children generically (it walks trees it constructed itself; this schema's job is to validate trees it did not). function isGroupWrapper(value: Record, isChild: (child: unknown) => boolean): boolean { if (!isRecord(value.node)) return false; if (value.style !== undefined && typeof value.style !== 'string') return false; + if (!Object.keys(value).every((key) => key === 'node' || key === 'style' || key === 'children')) return false; return Array.isArray(value.children) && value.children.every(isChild); } +// The leaf arm of every child predicate: a value carrying a top-level `style` key is rejected before the content schema ever sees it. A style ref is legal only on a group wrapper (the resolution chain, src/definitions.ts, walks group ancestors and never leaf payloads), and the content schemas deliberately accept-and-ignore unknown keys -- they are the shared flat-model schemas, and tightening them to strict would change flat ContentDocument parsing far beyond the package tree -- so without this check a leaf-position ref would parse, sit inert through resolution, and still be rejected by the published JSON Schema leaf fragments (additionalProperties: false over exactly the payload's own fields): a tree documentFromJson accepts that the CDN-published .schema.json forbids. Rejecting the key here keeps the runtime guard and the published face aligned at the one boundary this module owns. +function isLeafChild(schema: z.ZodType, value: unknown): boolean { + if (isRecord(value) && 'style' in value) return false; + return schema.safeParse(value).success; +} + function isSectionChild(value: unknown): value is SectionChild { - return isHeadingGroupNode(value) || isListGroupNode(value) || ContentBlockSchema.safeParse(value).success; + return isHeadingGroupNode(value) || isListGroupNode(value) || isLeafChild(ContentBlockSchema, value); } function isShapeChild(value: unknown): value is ShapeChild { - return isListGroupNode(value) || ContentBlockSchema.safeParse(value).success; + return isListGroupNode(value) || isLeafChild(ContentBlockSchema, value); } function isListChild(value: unknown): value is ListChild { - return isListGroupNode(value) || ContentBlockSchema.safeParse(value).success; + return isListGroupNode(value) || isLeafChild(ContentBlockSchema, value); } function isSheetChild(value: unknown): value is SheetChild { - return ContentSheetImageSchema.safeParse(value).success || ContentEmbeddedObjectSchema.safeParse(value).success; + return isLeafChild(ContentSheetImageSchema, value) || isLeafChild(ContentEmbeddedObjectSchema, value); } function isDrawPageChild(value: unknown): value is DrawPageChild { - return isShapeGroupNode(value) || ContentVectorSchema.safeParse(value).success; + return isShapeGroupNode(value) || isLeafChild(ContentVectorSchema, value); } export function isSectionGroupNode(value: unknown): value is SectionGroupNode { diff --git a/src/package.test.ts b/src/package.test.ts index ae81fc4..7c595ff 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -161,6 +161,27 @@ describe('DocumentPackageSchema round trips (tree form)', () => { expect(DocumentPackageSchema.safeParse(slideWithStrayParagraph).success).toBe(false); }); + it('rejects an unknown key on a group wrapper and a style ref on a bare leaf -- the runtime guard matches the published JSON Schema fragments key for key', () => { + const withJunkWrapperKey = { + kind: 'wordprocessing', + metadata: {}, + children: [{ node: { kind: 'section', pageSize: PAGE, margins: MARGINS }, junkKey: 'x', children: [] }], + }; + expect(DocumentPackageSchema.safeParse(withJunkWrapperKey).success).toBe(false); + + const withLeafStyleRef = { + kind: 'wordprocessing', + metadata: {}, + children: [ + { + node: { kind: 'section', pageSize: PAGE, margins: MARGINS }, + children: [{ kind: 'paragraph', runs: [{ text: 'Body.' }], style: 's1' }], + }, + ], + }; + expect(DocumentPackageSchema.safeParse(withLeafStyleRef).success).toBe(false); + }); + it('keeps the document-level symbolTable on the package root, spliced from the same declaration the content arms use', () => { const original = wordprocessingPackage(); const withSymbols = { ...original, symbolTable: { symbols: [], units: [] } }; From 9f38f036020c233a3b9078c8b81bcceb0db10a3a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 18 Aug 2026 11:32:17 +0100 Subject: [PATCH 5/6] feat: pin the formula package arm to exactly one ContentFormula child decompose emits a single ContentFormula and flatten requires exactly one (document-outline.js's phase-1 reference throws on any other count), so the schema states the cardinality the bijection needs instead of admitting trees that cannot round-trip. The generated document-package.schema.json picks up minItems/maxItems 1 alongside the existing items $ref, keeping the published face in agreement. --- src/package.test.ts | 7 +++++++ src/package.ts | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/package.test.ts b/src/package.test.ts index 7c595ff..b6dcf73 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -182,6 +182,13 @@ describe('DocumentPackageSchema round trips (tree form)', () => { expect(DocumentPackageSchema.safeParse(withLeafStyleRef).success).toBe(false); }); + it('pins the formula package to exactly one child -- decompose emits one ContentFormula and flatten requires one', () => { + const empty = { kind: 'formula', metadata: {}, children: [] }; + expect(DocumentPackageSchema.safeParse(empty).success).toBe(false); + const two = { kind: 'formula', metadata: {}, children: [{ mathml: [] }, { mathml: [] }] }; + expect(DocumentPackageSchema.safeParse(two).success).toBe(false); + }); + it('keeps the document-level symbolTable on the package root, spliced from the same declaration the content arms use', () => { const original = wordprocessingPackage(); const withSymbols = { ...original, symbolTable: { symbols: [], units: [] } }; diff --git a/src/package.ts b/src/package.ts index 45cd7ed..60df8ca 100644 --- a/src/package.ts +++ b/src/package.ts @@ -44,7 +44,8 @@ export const DocumentPackageSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('formula'), ...packageEnvelopeFields, - children: z.array(ContentFormulaSchema), + // Exactly one: decompose emits a single ContentFormula and flatten requires exactly one (document-outline.js's phase-1 reference throws on any other count), so the schema states the cardinality the bijection needs rather than admitting trees that cannot round-trip. + children: z.array(ContentFormulaSchema).length(1), }), ]); export type DocumentPackage = z.infer; From 60773a656d003f5a7f07bcc326e78037778d0118 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Tue, 18 Aug 2026 11:32:37 +0100 Subject: [PATCH 6/6] refactor: drop the unused CONTENT_DOCUMENT_KINDS export Nothing consumes the const or its ContentDocumentKind type: package.ts spells its own z.literal kind per discriminatedUnion arm, content.ts spells its own, and no test or script references either name, so the export was a third parallel copy of the five-kind list rather than the single shared declaration its comment promised. Reintroduce the day a real consumer exists. --- src/content.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/content.ts b/src/content.ts index 337babc..b823643 100644 --- a/src/content.ts +++ b/src/content.ts @@ -519,10 +519,6 @@ export const ContentFormulaSchema = z.object({ }); export type ContentFormula = z.infer; -// The five ContentDocument kinds, one shared declaration for every consumer that needs the union as a value or a type -- the package tree's root carries the same five (src/package.ts), and two hand copies of the list would drift the first time a kind was added. -export const CONTENT_DOCUMENT_KINDS = ['wordprocessing', 'presentation', 'spreadsheet', 'drawing', 'formula'] as const; -export type ContentDocumentKind = (typeof CONTENT_DOCUMENT_KINDS)[number]; - // ContentDocument carries no formatVersion of its own: it is the in-process codec-exchange type the codecs hand each other and never a serialised artefact in its own right, so it has no version to declare. Versioning lives entirely at the serialised-artefact boundary -- a dumped document or package states its version through the release-pinned $schema URI its dumper stamped (src/schema-io.ts), which is also what an ingesting documentFromJson dispatches on. Releases 1.x-3.x carried a per-arm formatVersion literal here; 4.0.0 retired it (ExaDev/document-schema.js#20's errata). // Fields every one of the five ContentDocument arms below carries in addition to its own kind and metadata -- currently the document-level math symbol table (SymbolTableSchema, src/math.ts): the curation layer mapping each written symbol glyph to its quantity kind, preferred unit, and definition, alongside the unit registry a formula's expressions resolve their symbol and unit references against. Spliced into each arm via spread rather than factored through a base schema the arms extend, because z.discriminatedUnion() needs each member as a plain z.object carrying its own literal `kind` field in place. Optional on every arm: a document with no lowered math content (most of them) simply omits it, and the table is presentation-inert by construction -- it curates what symbols mean, never how any formula renders -- so its presence or absence changes no rendering. Exported because DocumentPackageSchema's own arms (src/package.ts) spread the identical field set -- one declaration, so a shared field added here reaches the package root without a second edit.