Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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:
Expand Down Expand Up @@ -138,7 +146,7 @@ export const MathMlNodeSchema: z.ZodType<MathMlNode> = 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));
```

Expand Down
5 changes: 3 additions & 2 deletions src/content-json-schema-defs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>` 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,
Expand Down
22 changes: 21 additions & 1 deletion src/content-json-schema-defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JsonSchema> = {
Color: {
type: 'object',
Expand All @@ -45,6 +45,18 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
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'],
Expand Down Expand Up @@ -95,6 +107,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
color: { $ref: '#/$defs/Color' },
hyperlink: { type: 'string' }, // resolved external URI
sourcePath: { type: 'string' },
frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } },
},
required: ['text'],
additionalProperties: false,
Expand All @@ -105,6 +118,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
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' },
Expand All @@ -113,6 +127,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
indentLeftPt: { type: 'number' },
indentFirstLinePt: { type: 'number' },
sourcePath: { type: 'string' },
frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } },
},
required: ['kind', 'runs'],
additionalProperties: false,
Expand All @@ -127,6 +142,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
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,
Expand All @@ -136,6 +152,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
properties: {
kind: { type: 'string', const: 'pageBreak' },
sourcePath: { type: 'string' },
frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } },
},
required: ['kind'],
additionalProperties: false,
Expand All @@ -150,6 +167,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
background: { $ref: '#/$defs/Color' },
borders: { $ref: '#/$defs/ContentCellBorders' },
sourcePath: { type: 'string' },
frames: { type: 'array', items: { $ref: '#/$defs/LayoutFrame' } },
},
required: ['blocks'],
additionalProperties: false,
Expand All @@ -172,6 +190,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
// 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,
Expand All @@ -185,6 +204,7 @@ export const CONTENT_DEFS: Record<string, JsonSchema> = {
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 },
Expand Down
Loading
Loading