diff --git a/README.md b/README.md index 2727b65..68da675 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ 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). `ContentEmbeddedObjectSchema` lets any of the five embed another whole `ContentDocument`. `LayoutDocument` (the PDF-rendering pivot) is pages of positioned `LayoutItem`s (`text`/`image`/`rect`/`line`/`ellipse`/`path`/`link`) in PDF user-space coordinates. `DocumentPackageSchema` pairs the two: `content` required, `layout` optional (derived, absent until something lays content out); the schema does not keep them in sync or detect staleness. +`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). `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. 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`. @@ -60,8 +60,16 @@ Two format-agnostic helpers live here because they operate on the content model import { ContentDocumentSchema, DocumentPackageSchema, LayoutDocumentSchema } from 'document-schema.js'; const content = ContentDocumentSchema.parse(someWordprocessingOrPresentationValue); -const layout = LayoutDocumentSchema.parse(somePageLayoutValue); -const pkg = DocumentPackageSchema.parse({ formatVersion: 1, content, layout }); +// 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 }] }); + +// 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); ``` Every module is also importable directly — `tsdown` builds one file per source module, and `package.json`'s `"./*"` export makes each individually resolvable: @@ -138,7 +146,7 @@ export const MathMlNodeSchema: z.ZodType = z.discriminatedUnion('typ import { documentPackageWithSchema } from 'document-schema.js'; const tagged = documentPackageWithSchema(pkg); -// { $schema: 'https://cdn.jsdelivr.net/npm/document-schema.js@1.6.1/schemas/document-package.schema.json', formatVersion: 1, content: {...}, layout: {...} } +// { $schema: 'https://cdn.jsdelivr.net/npm/document-schema.js@2.0.0/schemas/document-package.schema.json', formatVersion: 2, content: {...}, pages: [...] } writeFileSync('package.json.doc', JSON.stringify(tagged, null, 2)); ``` diff --git a/src/content-json-schema-defs.test.ts b/src/content-json-schema-defs.test.ts index 12ec0f7..b822222 100644 --- a/src/content-json-schema-defs.test.ts +++ b/src/content-json-schema-defs.test.ts @@ -12,16 +12,17 @@ import { ContentStrokeStyleSchema, } from './content'; import { CONTENT_DEFS } from './content-json-schema-defs'; -import { BoxSchema } from './geometry'; +import { BoxSchema, LayoutFrameSchema } from './geometry'; 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, 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 three genuinely un-representable z.custom() nodes (ContentBlockSchema, ContentEmbeddedObjectSchema, MathMlNodeSchema -- 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, Alignment, ContentStrokeStyle, ContentBorder, ContentCellBorders, ContentListMembership, ContentRun, ContentParagraph, ContentImageBlock, ContentPageBreak. None of these reaches ContentBlockSchema, ContentEmbeddedObjectSchema, or MathMlNodeSchema from anywhere in their own field tree, so they 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, 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 three genuinely un-representable z.custom() nodes (ContentBlockSchema, ContentEmbeddedObjectSchema, MathMlNodeSchema -- 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. None of these reaches ContentBlockSchema, ContentEmbeddedObjectSchema, or MathMlNodeSchema from anywhere in their own field tree, so they 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. const REGISTERED_SCHEMAS = { Color: ColorSchema, Box: BoxSchema, + LayoutFrame: LayoutFrameSchema, Alignment: AlignmentSchema, ContentStrokeStyle: ContentStrokeStyleSchema, ContentBorder: ContentBorderSchema, diff --git a/src/content-json-schema-defs.ts b/src/content-json-schema-defs.ts index ce752bc..ad72785 100644 --- a/src/content-json-schema-defs.ts +++ b/src/content-json-schema-defs.ts @@ -22,7 +22,7 @@ export const CONTENT_DOCUMENT_URI = schemaUriFor('ContentDocument'); // -- Hand-authored $defs, spliced into content-document.schema.json only (via scripts/generate-json-schemas.mjs's own ContentDocumentSchema override branch) -- // -// The fragments below are transcribed by hand, field-for-field, from src/content.ts's real Zod object definitions (ContentParagraphSchema, ContentTableSchema/ContentTableRowSchema/ContentTableCellSchema, ContentImageBlockSchema, ContentPageBreakSchema, ContentRunSchema, ContentListMembershipSchema, ColorSchema, BoxSchema, AlignmentSchema, ContentStrokeStyleSchema, ContentBorderSchema, ContentCellBordersSchema -- each cross-checked directly against a real z.toJSONSchema() call over that exact exported schema, and the ones with a real, non-recursive, non-custom counterpart are held to that comparison as a running test by content-json-schema-defs.test.ts) plus the ContentEmbeddedObject/ContentEmbeddedObjectBlock TS interfaces, which have no exported z.object() counterpart at all (both are validated only via the isContentEmbeddedObject*() z.custom() guards). Re-verify this block against src/content.ts whenever that file's field shapes change -- nothing here is generated or checked against the real schemas at build time, other than the eleven leaf/near-leaf fragments the regression test below does cover. +// The fragments below are transcribed by hand, field-for-field, from src/content.ts's real Zod object definitions (ContentParagraphSchema, ContentTableSchema/ContentTableRowSchema/ContentTableCellSchema, ContentImageBlockSchema, ContentPageBreakSchema, ContentRunSchema, ContentListMembershipSchema, ColorSchema, BoxSchema, LayoutFrameSchema, AlignmentSchema, ContentStrokeStyleSchema, ContentBorderSchema, ContentCellBordersSchema -- each cross-checked directly against a real z.toJSONSchema() call over that exact exported schema, and the ones with a real, non-recursive, non-custom counterpart are held to that comparison as a running test by content-json-schema-defs.test.ts) plus the ContentEmbeddedObject/ContentEmbeddedObjectBlock TS interfaces, which have no exported z.object() counterpart at all (both are validated only via the isContentEmbeddedObject*() z.custom() guards). Re-verify this block against src/content.ts whenever that file's field shapes change -- nothing here is generated or checked against the real schemas at build time, other than the twelve leaf/near-leaf fragments the regression test below does cover. export const CONTENT_DEFS: Record = { Color: { type: 'object', @@ -45,6 +45,18 @@ export const CONTENT_DEFS: Record = { required: ['xPt', 'yPt', 'widthPt', 'heightPt'], additionalProperties: false, }, + LayoutFrame: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, + xPt: { type: 'number' }, + yPt: { type: 'number' }, + widthPt: { type: 'number', minimum: 0 }, + heightPt: { type: 'number', minimum: 0 }, + }, + required: ['pageIndex', 'xPt', 'yPt', 'widthPt', 'heightPt'], + additionalProperties: false, + }, Alignment: { type: 'string', enum: ['left', 'center', 'right', 'justify'], @@ -95,6 +107,7 @@ export const CONTENT_DEFS: Record = { color: { $ref: '#/$defs/Color' }, hyperlink: { type: 'string' }, // resolved external URI sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, }, required: ['text'], additionalProperties: false, @@ -105,6 +118,7 @@ export const CONTENT_DEFS: Record = { kind: { type: 'string', const: 'paragraph' }, runs: { type: 'array', items: { $ref: '#/$defs/ContentRun' } }, styleId: { type: 'string' }, // w:pStyle/@w:val, e.g. 'Heading1' + headingLevel: { type: 'integer', exclusiveMinimum: 0, maximum: MAX_SAFE_INTEGER }, // canonical, format-agnostic heading depth -- see src/content.ts's own field comment alignment: { $ref: '#/$defs/Alignment' }, list: { $ref: '#/$defs/ContentListMembership' }, spacingBeforePt: { type: 'number' }, @@ -113,6 +127,7 @@ export const CONTENT_DEFS: Record = { indentLeftPt: { type: 'number' }, indentFirstLinePt: { type: 'number' }, sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, }, required: ['kind', 'runs'], additionalProperties: false, @@ -127,6 +142,7 @@ export const CONTENT_DEFS: Record = { heightPt: { type: 'number', exclusiveMinimum: 0 }, altText: { type: 'string' }, sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, }, required: ['kind', 'format', 'base64', 'widthPt', 'heightPt'], additionalProperties: false, @@ -136,6 +152,7 @@ export const CONTENT_DEFS: Record = { properties: { kind: { type: 'string', const: 'pageBreak' }, sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, }, required: ['kind'], additionalProperties: false, @@ -150,6 +167,7 @@ export const CONTENT_DEFS: Record = { background: { $ref: '#/$defs/Color' }, borders: { $ref: '#/$defs/ContentCellBorders' }, sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, }, required: ['blocks'], additionalProperties: false, @@ -172,6 +190,7 @@ export const CONTENT_DEFS: Record = { // Pre-existing discrepancy, not fixed here: ContentTableSchema.columnWidthsPt is z.array(z.number().positive()), stricter than isContentBlock's own runtime guard (src/content.ts), which only checks `typeof w === 'number'` for each width in its 'table' branch. This fragment matches the stricter declared Zod schema, not the looser guard -- flagged, not silently normalized away. columnWidthsPt: { type: 'array', items: { type: 'number', exclusiveMinimum: 0 } }, sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, }, required: ['kind', 'rows', 'columnWidthsPt'], additionalProperties: false, @@ -185,6 +204,7 @@ export const CONTENT_DEFS: Record = { document: { $ref: CONTENT_DOCUMENT_URI }, frame: { $ref: '#/$defs/Box' }, sourcePath: { type: 'string' }, + frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } }, // Cell-anchor position, all four optional -- only set on an embedded object held in a ContentSheetSchema.embeddedObjects array; mirrors ContentSheetImageSchema's own anchorRow/anchorColumn/offsetXPt/offsetYPt representation exactly (see schemas/content-document.schema.json's own ContentSheetImage fragment, generated -- not hand-transcribed -- since that schema is a real z.object()). anchorRow: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, anchorColumn: { type: 'integer', minimum: 0, maximum: MAX_SAFE_INTEGER }, diff --git a/src/content.test.ts b/src/content.test.ts index 483af00..038ecfa 100644 --- a/src/content.test.ts +++ b/src/content.test.ts @@ -8,13 +8,17 @@ import { ContentDocumentSchema, type ContentEmbeddedObject, ContentEmbeddedObjectSchema, + ContentParagraphSchema, ContentRunSchema, ContentShapeSchema, + ContentSheetCellSchema, ContentSheetColumnSchema, ContentSheetRowSchema, type ContentTable, + clampHeadingLevel, isContentBlock, } from './content'; +import { LayoutFrameSchema } from './geometry'; const paragraph: ContentBlock = { kind: 'paragraph', @@ -506,6 +510,126 @@ describe('sourcePath', () => { }); }); +describe('ContentParagraphSchema headingLevel', () => { + it('accepts an explicit heading level, independent of styleId', () => { + const parsed = ContentParagraphSchema.parse({ + kind: 'paragraph', + runs: [{ text: 'A heading' }], + styleId: 'Heading2', + headingLevel: 2, + }); + expect(parsed.headingLevel).toBe(2); + expect(parsed.styleId).toBe('Heading2'); + }); + + it('accepts a heading level beyond 6, since the canonical field is not itself clamped (ODF permits ten levels)', () => { + expect(ContentParagraphSchema.parse({ kind: 'paragraph', runs: [], headingLevel: 9 }).headingLevel).toBe(9); + }); + + it('parses with headingLevel omitted, matching every other optional field', () => { + const parsed = ContentParagraphSchema.parse({ kind: 'paragraph', runs: [{ text: 'Body text' }] }); + expect(parsed.headingLevel).toBeUndefined(); + }); + + it('rejects a zero, negative, or non-integer headingLevel', () => { + expect(ContentParagraphSchema.safeParse({ kind: 'paragraph', runs: [], headingLevel: 0 }).success).toBe(false); + expect(ContentParagraphSchema.safeParse({ kind: 'paragraph', runs: [], headingLevel: -1 }).success).toBe(false); + expect(ContentParagraphSchema.safeParse({ kind: 'paragraph', runs: [], headingLevel: 1.5 }).success).toBe(false); + }); + + it('survives a JSON round trip', () => { + const original = ContentParagraphSchema.parse({ + kind: 'paragraph', + runs: [{ text: 'Heading' }], + headingLevel: 3, + }); + const roundTripped: unknown = JSON.parse(JSON.stringify(original)); + expect(ContentParagraphSchema.parse(roundTripped)).toEqual(original); + }); +}); + +describe('clampHeadingLevel', () => { + it('leaves a level already within 1-6 untouched', () => { + expect(clampHeadingLevel(1)).toBe(1); + expect(clampHeadingLevel(3)).toBe(3); + expect(clampHeadingLevel(6)).toBe(6); + }); + + it('clamps a level above 6 down to 6', () => { + expect(clampHeadingLevel(7)).toBe(6); + expect(clampHeadingLevel(10)).toBe(6); + expect(clampHeadingLevel(999)).toBe(6); + }); + + it('clamps a level below 1 up to 1', () => { + expect(clampHeadingLevel(0)).toBe(1); + expect(clampHeadingLevel(-5)).toBe(1); + }); + + it('rounds a fractional level to the nearest integer before clamping', () => { + expect(clampHeadingLevel(2.4)).toBe(2); + expect(clampHeadingLevel(2.6)).toBe(3); + }); +}); + +describe('frames (the FusedNode pattern)', () => { + it('accepts a LayoutFrame array on every content-kind leaf that carries one', () => { + const frame = { pageIndex: 0, xPt: 10, yPt: 700, widthPt: 100, heightPt: 12 }; + + expect(ContentRunSchema.parse({ text: 'Fused', frames: [frame] }).frames).toEqual([frame]); + expect(ContentParagraphSchema.parse({ kind: 'paragraph', runs: [], frames: [frame] }).frames).toEqual([frame]); + expect( + ContentBlockSchema.parse({ kind: 'image', format: 'png', base64: 'AA==', widthPt: 1, heightPt: 1, frames: [frame] }), + ).toMatchObject({ frames: [frame] }); + expect(ContentBlockSchema.parse({ kind: 'pageBreak', frames: [frame] })).toMatchObject({ frames: [frame] }); + + const shape = ContentShapeSchema.parse({ + frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 100 }, + insetLeftPt: 0, + insetTopPt: 0, + insetRightPt: 0, + insetBottomPt: 0, + blocks: [], + frames: [frame], + }); + expect(shape.frames).toEqual([frame]); + + const cell = ContentSheetCellSchema.parse({ + row: 0, + column: 0, + value: { kind: 'string', value: 'x' }, + displayText: 'x', + frames: [frame], + }); + expect(cell.frames).toEqual([frame]); + }); + + it('accepts a node with multiple frames -- one node appearing at more than one rendered position', () => { + const frames = [ + { pageIndex: 0, xPt: 72, yPt: 60, widthPt: 468, heightPt: 24 }, + { pageIndex: 1, xPt: 72, yPt: 720, widthPt: 200, heightPt: 12 }, + ]; + const parsed = ContentParagraphSchema.parse({ kind: 'paragraph', runs: [], frames }); + expect(parsed.frames).toHaveLength(2); + expect(parsed.frames?.map((f) => f.pageIndex)).toEqual([0, 1]); + }); + + it('parses correctly when frames is omitted, matching every other optional field', () => { + expect(ContentRunSchema.parse({ text: 'No frames' }).frames).toBeUndefined(); + }); + + it('rejects a malformed frame (negative pageIndex, missing fields)', () => { + expect(LayoutFrameSchema.safeParse({ pageIndex: -1, xPt: 0, yPt: 0, widthPt: 1, heightPt: 1 }).success).toBe( + false, + ); + expect(LayoutFrameSchema.safeParse({ pageIndex: 0, xPt: 0, yPt: 0 }).success).toBe(false); + expect( + ContentRunSchema.safeParse({ text: 'Bad', frames: [{ pageIndex: -1, xPt: 0, yPt: 0, widthPt: 1, heightPt: 1 }] }) + .success, + ).toBe(false); + }); +}); + describe('ContentDocumentSchema round trips', () => { it('deep-equals the original wordprocessing document after a JSON round trip', () => { const original = wordprocessingDocument(); diff --git a/src/content.ts b/src/content.ts index cdccb73..e349a61 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,15 +1,18 @@ import { z } from 'zod'; import { ColorSchema } from './color'; import type { Color } from './color'; -import { BoxSchema, MarginsSchema, PageSizeSchema } from './geometry'; -import type { Box } from './geometry'; +import { BoxSchema, LayoutFrameSchema, MarginsSchema, PageSizeSchema } from './geometry'; +import type { Box, LayoutFrame } from './geometry'; 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. -// 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. +// 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. + +// The fusion primitive every content-kind leaf below adds via its own literal `frames?: LayoutFrame[]` field (Zod's discriminated-union/object model needs the field spliced in field-by-field per variant, not layered on generically through this generic type) -- FusedNode names that exact pattern once, for a consumer describing "a content node carrying its own rendered position(s)" in the general case rather than repeating the union of leaf types by hand. A node's own `frames` entries record wherever -- and on however many pages -- its rendered content actually landed, replacing DocumentPackage's old two-tree design of correlating a wholly separate LayoutDocument's own positioned items back to their originating ContentDocument node purely by matching sourcePath strings (see src/package.ts). A node with more than one frame appeared in more than one rendered position -- a paragraph's runs wrapping across a page boundary is the common case -- without the content itself needing to be split or duplicated. `frames` is absent on a content-only value that has never been through a layout pass, exactly mirroring how DocumentPackage.layout used to be absent for the same reason. +export type FusedNode = T & { frames?: LayoutFrame[] }; export const ContentRunSchema = z.object({ text: z.string(), @@ -22,6 +25,7 @@ export const ContentRunSchema = z.object({ color: ColorSchema.optional(), hyperlink: z.string().optional(), // resolved external URI sourcePath: z.string().optional(), // deterministic, document-order-derived path assigned by the format reader + frames: z.array(LayoutFrameSchema).optional(), // this run's own rendered position(s), once a layout pass has fused one in -- see FusedNode above }); export type ContentRun = z.infer; @@ -34,7 +38,8 @@ export type ContentListMembership = z.infer; export const ContentParagraphSchema = z.object({ kind: z.literal('paragraph'), runs: z.array(ContentRunSchema), - styleId: z.string().optional(), // w:pStyle/@w:val, e.g. 'Heading1' + styleId: z.string().optional(), // w:pStyle/@w:val, e.g. 'Heading1' -- round-trip-only: a producer's own style name, meaningful only to a consumer that already knows that producer's naming convention + headingLevel: z.number().int().positive().optional(), // canonical, format-agnostic heading depth (1 = the outermost heading), independent of styleId's own producer-specific spelling -- e.g. docx's w:outlineLvl (0-based, so read as level + 1), odf's text:outline-level (already 1-based), markdown's '#' count. Deliberately unbounded here (ODF alone permits ten levels): a format whose own vocabulary tops out lower than what's present (six for HTML/Markdown) clamps on its own way out, via clampHeadingLevel below, rather than this canonical field silently losing information a richer source format actually carried. alignment: AlignmentSchema.optional(), list: ContentListMembershipSchema.optional(), spacingBeforePt: z.number().optional(), @@ -43,9 +48,15 @@ export const ContentParagraphSchema = z.object({ indentLeftPt: z.number().optional(), indentFirstLinePt: z.number().optional(), sourcePath: z.string().optional(), // deterministic, document-order-derived path assigned by the format reader + frames: z.array(LayoutFrameSchema).optional(), // this paragraph's own rendered position(s), once a layout pass has fused one in -- see FusedNode above }); export type ContentParagraph = z.infer; +// Clamps an arbitrary heading level to the 1-6 range every consumer whose own heading vocabulary tops out at six shares -- HTML/Markdown's h1-h6, docx's built-in Heading1-Heading6 style set. Exported so a writer targeting one of those (markdown-codec's own private clamp-to-6 logic on write is the motivating case) can share this exact clamp instead of reimplementing it. Deliberately simple: rounds a fractional level to the nearest integer first (a level is conceptually a whole step of depth; a producer should never genuinely hand this a fraction, but rounding rather than truncating avoids silently favouring shallower headings if one ever does), then clamps into [1, 6]. +export function clampHeadingLevel(level: number): number { + return Math.min(6, Math.max(1, Math.round(level))); +} + export const ContentImageBlockSchema = z.object({ kind: z.literal('image'), format: z.enum(['png', 'jpeg']), @@ -54,12 +65,14 @@ export const ContentImageBlockSchema = z.object({ heightPt: z.number().positive(), altText: z.string().optional(), sourcePath: z.string().optional(), // deterministic, document-order-derived path assigned by the format reader + frames: z.array(LayoutFrameSchema).optional(), // this image's own rendered position(s), once a layout pass has fused one in -- see FusedNode above }); export type ContentImageBlock = z.infer; export const ContentPageBreakSchema = z.object({ kind: z.literal('pageBreak'), sourcePath: z.string().optional(), // deterministic, document-order-derived path assigned by the format reader + frames: z.array(LayoutFrameSchema).optional(), // where this page break actually landed, once a layout pass has fused one in -- see FusedNode above }); export type ContentPageBreak = z.infer; @@ -71,6 +84,7 @@ export interface ContentTableCell { background?: Color; borders?: ContentCellBorders; sourcePath?: string; + frames?: LayoutFrame[]; // this cell's own rendered position(s), once a layout pass has fused one in -- see FusedNode above } export interface ContentTableRow { @@ -84,6 +98,7 @@ export interface ContentTable { rows: ContentTableRow[]; columnWidthsPt: number[]; sourcePath?: string; // deterministic, document-order-derived path assigned by the format reader + frames?: LayoutFrame[]; // this table's own rendered position(s), once a layout pass has fused one in -- see FusedNode above } // ContentEmbeddedObject is mutually recursive with ContentDocument (an embedded object carries a whole ContentDocument, which can itself contain another embedded object -- e.g. a formula embedded inside a drawing embedded inside a spreadsheet) -- hand-written, mirroring ContentTable/ContentBlock's own recursive-guard-plus-z.custom pattern immediately below, since z.lazy() collapses to `unknown` for recursive children in this pinned Zod version. Every objectKind names an embedded whole sub-document of the identically-named ContentDocument kind, 'formula' included now that ContentDocument has a real 'formula' variant of its own (below) -- so an embedded equation carries genuine MathML rather than, as before, a wordprocessing document standing in for one. That pairing is a producer convention, not a constraint this schema enforces: objectKind and document.kind are independently typed, and nothing here rejects a mismatched pair. A 'formula' object is expected to be short enough that a layout engine can reasonably lay it out and render it; the other four are expected to round-trip through this model losslessly without ever being laid out or rendered. This package holds schemas only, so no rendering/layout logic lives here regardless of objectKind. @@ -104,6 +119,7 @@ export interface ContentEmbeddedObject { export interface ContentEmbeddedObjectBlock extends ContentEmbeddedObject { kind: 'embeddedObject'; sourcePath?: string; // deterministic, document-order-derived path assigned by the format reader + frames?: LayoutFrame[]; // this embedded object's own rendered position(s), once a layout pass has fused one in -- see FusedNode above } export type ContentBlock = ContentParagraph | ContentTable | ContentImageBlock | ContentPageBreak | ContentEmbeddedObjectBlock; @@ -229,6 +245,7 @@ export const ContentTableCellSchema = z.object({ background: ColorSchema.optional(), borders: ContentCellBordersSchema.optional(), sourcePath: z.string().optional(), // deterministic, document-order-derived path assigned by the format reader + frames: z.array(LayoutFrameSchema).optional(), // this cell's own rendered position(s), once a layout pass has fused one in -- see FusedNode above }); export const ContentTableRowSchema = z.object({ @@ -241,6 +258,7 @@ export const ContentTableSchema = z.object({ rows: z.array(ContentTableRowSchema), columnWidthsPt: z.array(z.number().positive()), sourcePath: z.string().optional(), // deterministic, document-order-derived path assigned by the format reader + frames: z.array(LayoutFrameSchema).optional(), // this table's own rendered position(s), once a layout pass has fused one in -- see FusedNode above }); // A docx section: a run of pages sharing one page size/margins (a w:sectPr boundary starts a new one). @@ -264,6 +282,7 @@ export const ContentShapeSchema = z.object({ lineSpacingReduction: z.number().nonnegative().optional(), paintOrder: z.number().optional(), sourcePath: z.string().optional(), // deterministic, document-order-derived path assigned by the format reader + frames: z.array(LayoutFrameSchema).optional(), // this shape's own rendered position(s), once a layout pass has fused one in -- see FusedNode above blocks: z.array(ContentBlockSchema), }); export type ContentShape = z.infer; @@ -313,6 +332,7 @@ export const ContentSheetCellSchema = z.object({ alignment: AlignmentSchema.optional(), // override; absent means the existing value-kind default verticalAlignment: z.enum(['top', 'middle', 'bottom']).optional(), // absent means 'bottom' sourcePath: z.string().optional(), // deterministic, document-order-derived path assigned by the format reader + frames: z.array(LayoutFrameSchema).optional(), // this cell's own rendered position(s), once a layout pass has fused one in -- see FusedNode above }); export type ContentSheetCell = z.infer; @@ -427,6 +447,7 @@ export const ContentVectorSchema = z.discriminatedUnion('kind', [ stroke: ContentStrokeSchema.optional(), paintOrder: z.number().optional(), sourcePath: z.string().optional(), + frames: z.array(LayoutFrameSchema).optional(), // this vector's own rendered position(s), once a layout pass has fused one in -- see FusedNode above }), z.object({ kind: z.literal('ellipse'), @@ -436,6 +457,7 @@ export const ContentVectorSchema = z.discriminatedUnion('kind', [ stroke: ContentStrokeSchema.optional(), paintOrder: z.number().optional(), sourcePath: z.string().optional(), + frames: z.array(LayoutFrameSchema).optional(), }), z.object({ kind: z.literal('line'), @@ -444,6 +466,7 @@ export const ContentVectorSchema = z.discriminatedUnion('kind', [ stroke: ContentStrokeSchema, paintOrder: z.number().optional(), sourcePath: z.string().optional(), + frames: z.array(LayoutFrameSchema).optional(), }), z.object({ kind: z.literal('path'), @@ -455,6 +478,7 @@ export const ContentVectorSchema = z.discriminatedUnion('kind', [ stroke: ContentStrokeSchema.optional(), paintOrder: z.number().optional(), sourcePath: z.string().optional(), + frames: z.array(LayoutFrameSchema).optional(), }), ]); export type ContentVector = z.infer; @@ -476,8 +500,8 @@ 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. -export const CONTENT_FORMAT_VERSION = 2; +// 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; export const ContentDocumentSchema = z.discriminatedUnion('kind', [ z.object({ diff --git a/src/geometry.ts b/src/geometry.ts index e09710b..eb901d2 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -30,6 +30,16 @@ 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. +export const LayoutFrameSchema = z.object({ + pageIndex: z.number().int().nonnegative(), + xPt: z.number(), + yPt: z.number(), + widthPt: z.number().nonnegative(), + heightPt: z.number().nonnegative(), +}); +export type LayoutFrame = z.infer; + // US Letter: 612 x 792 pt (8.5 x 11 in). The default page size when a docx section has no explicit w:sectPr/w:pgSz. export const PAGE_SIZE_LETTER: PageSize = { widthPt: 612, heightPt: 792 }; diff --git a/src/package.test.ts b/src/package.test.ts index 3189912..a345b07 100644 --- a/src/package.test.ts +++ b/src/package.test.ts @@ -1,9 +1,6 @@ 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, DocumentPackageSchema } from './package'; -import { DEFAULT_LAYOUT_FONT } from './style'; function wordprocessingDocument(): ContentDocument { return { @@ -17,8 +14,14 @@ function wordprocessingDocument(): ContentDocument { blocks: [ { kind: 'paragraph', - runs: [{ text: 'Hello, package.', sourcePath: 'sections[0].blocks[0].runs[0]' }], - sourcePath: 'sections[0].blocks[0]', + 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 }], }, ], }, @@ -26,46 +29,44 @@ function wordprocessingDocument(): ContentDocument { }; } -// Correlates with wordprocessingDocument() above via sourcePath -- the same 'sections[0].blocks[0].runs[0]' value, matching what a real read+layout pass would copy from content onto the laid-out item. -function layoutDocument(): LayoutDocument { +// 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 { return { - formatVersion: LAYOUT_FORMAT_VERSION, - metadata: { title: 'Package round trip', author: 'document-content-model' }, - pages: [ + kind: 'wordprocessing', + formatVersion: CONTENT_FORMAT_VERSION, + metadata: { title: 'Package round trip (paginated)' }, + sections: [ { - widthPt: 612, - heightPt: 792, - items: [ + pageSize: { widthPt: 612, heightPt: 792 }, + margins: { topPt: 72, rightPt: 72, bottomPt: 72, leftPt: 72 }, + blocks: [ { - kind: 'text', - text: 'Hello, package.', - xPt: 72, - yPt: 720, - font: DEFAULT_LAYOUT_FONT, - sizePt: 12, - color: COLOR_BLACK, - sourcePath: 'sections[0].blocks[0].runs[0]', + 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 }, + ], }, ], }, ], - images: {}, }; } describe('DocumentPackageSchema round trips', () => { - it('deep-equals the original package after a JSON round trip when layout is present', () => { + 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(), - layout: layoutDocument(), + pages: [{ widthPt: 612, heightPt: 792 }], }; const parsed = DocumentPackageSchema.parse(original); const roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); expect(DocumentPackageSchema.parse(roundTripped)).toEqual(original); }); - it('deep-equals the original package after a JSON round trip when layout is absent', () => { + 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(), @@ -75,21 +76,76 @@ describe('DocumentPackageSchema round trips', () => { expect(DocumentPackageSchema.parse(roundTripped)).toEqual(original); }); - it('serializes with layout omitted entirely, not as null or an empty object', () => { + it('serializes with pages omitted entirely, not as null or an empty array', () => { const original: DocumentPackage = { formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content: wordprocessingDocument(), }; const parsed = DocumentPackageSchema.parse(original); - expect(parsed.layout).toBeUndefined(); + expect(parsed.pages).toBeUndefined(); const serialized: unknown = JSON.parse(JSON.stringify(parsed)); - expect(serialized).not.toHaveProperty('layout'); + expect(serialized).not.toHaveProperty('pages'); }); it('rejects a mismatched formatVersion', () => { - expect( - DocumentPackageSchema.safeParse({ formatVersion: 2, content: wordprocessingDocument() }).success, - ).toBe(false); + expect(DocumentPackageSchema.safeParse({ formatVersion: 1, content: wordprocessingDocument() }).success).toBe( + false, + ); + }); + + 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 }, + ], + }; + 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 roundTripped: unknown = JSON.parse(JSON.stringify(parsed)); + expect(DocumentPackageSchema.parse(roundTripped)).toEqual(original); + }); + + // 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: '', + }, + ], + }, + }; + expect(DocumentPackageSchema.safeParse(withBadFrame).success).toBe(false); }); }); diff --git a/src/package.ts b/src/package.ts index 66a33e6..2deb737 100644 --- a/src/package.ts +++ b/src/package.ts @@ -1,17 +1,20 @@ import { z } from 'zod'; import { ContentDocumentSchema } from './content'; -import { LayoutDocumentSchema } from './layout'; +import { PageSizeSchema } from './geometry'; -// DocumentPackage is a superset envelope pairing the two existing pivots, not a pivot in its own right -- it exists so a caller that wants to carry both a document's semantic content and its rendered layout through one value (e.g. a single serialized artifact) doesn't have to invent its own wrapper shape. content is required; layout is optional, because layout 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. +// 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. // -// When layout IS present, it correlates with content via each item's own sourcePath field (already present on both ContentDocument's blocks/runs/shapes/cells/vectors and LayoutDocument's items). That correlation is only valid as of the exact read+layout pass that produced this particular package -- there is no automatic invalidation if a caller mutates content after the fact and keeps the stale layout around. A DocumentPackage carrying a layout that no longer matches its own content is not detected or rejected by this schema; keeping the two in sync is entirely the caller's responsibility. +// 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. -// 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. -export const DOCUMENT_PACKAGE_FORMAT_VERSION = 1; +// 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; export const DocumentPackageSchema = z.object({ formatVersion: z.literal(DOCUMENT_PACKAGE_FORMAT_VERSION), content: ContentDocumentSchema, - layout: LayoutDocumentSchema.optional(), + // 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. + pages: z.array(PageSizeSchema).optional(), }); export type DocumentPackage = z.infer; diff --git a/src/schema-io.test.ts b/src/schema-io.test.ts index c18224e..6196343 100644 --- a/src/schema-io.test.ts +++ b/src/schema-io.test.ts @@ -71,7 +71,7 @@ function documentPackage(): DocumentPackage { return { formatVersion: DOCUMENT_PACKAGE_FORMAT_VERSION, content: wordprocessingDocument(), - layout: layoutDocument(), + pages: [{ widthPt: 612, heightPt: 792 }], }; } diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index 2948ec1..2a7441f 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -35,7 +35,7 @@ describe('smoke: generated JSON Schema files', () => { } }); - it("document-package.schema.json's $id is a jsdelivr URL pinned to the package's own published version, and its content/layout refs share that same version", () => { + 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", () => { 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`, @@ -43,10 +43,23 @@ describe('smoke: generated JSON Schema files', () => { expect(documentPackage.properties.content.$ref).toBe( `https://cdn.jsdelivr.net/npm/document-schema.js@${packageVersion}/schemas/content-document.schema.json`, ); - expect(documentPackage.properties.layout.$ref).toBe( - `https://cdn.jsdelivr.net/npm/document-schema.js@${packageVersion}/schemas/layout-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", () => { + const contentDocument = readSchema('content-document.schema.json'); + 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", () => { diff --git a/test/workers/document-schema.test.ts b/test/workers/document-schema.test.ts index e8360c8..7cb6dc7 100644 --- a/test/workers/document-schema.test.ts +++ b/test/workers/document-schema.test.ts @@ -56,6 +56,6 @@ describe('document-schema.js under the Cloudflare Workers runtime', () => { content: document, }); expect(parsed.content.kind).toBe('wordprocessing'); - expect(parsed.layout).toBeUndefined(); + expect(parsed.pages).toBeUndefined(); }); });