Skip to content
This repository was archived by the owner on Aug 20, 2026. It is now read-only.

Commit 26cf115

Browse files
committed
feat: add spreadsheet/drawing ContentDocument variants and embedded-object recursion
Widens ContentDocument from a 2-member to a 4-member discriminated union by adding 'spreadsheet' (sheets of sparsely-addressed cells, columns, rows, anchored images, and print settings, with a ContentCellValueSchema discriminated union mirroring ODF's office:value-type set) and 'drawing' (pages of ContentShape text frames plus a new ContentVectorSchema union for rect/ellipse/line/path primitives with no docx/pptx analogue). Adds ContentEmbeddedObjectSchema, a recursive embedding mechanism (an embedded object carries a whole ContentDocument, which can itself embed another object) using the same hand-written-structural-guard-plus-z.custom pattern already established for ContentBlock/isContentBlock, since z.lazy() collapses to `unknown` for recursive children in the pinned Zod version. The guard delegates document validation to ContentDocumentSchema itself via a forward reference rather than duplicating a parallel structural walk of every ContentDocument variant. Wires embedding into each host kind via the anchoring point already established for images: a new ContentBlock 'embeddedObject' variant covers wordprocessing sections and presentation/drawing shapes for free (all three already flow through ContentBlock via their own blocks arrays), while ContentSheetSchema gets its own explicit embeddedObjects array since spreadsheets have no block-flow concept to anchor into. Does not bump CONTENT_FORMAT_VERSION: every addition is purely additive, matching the reasoning already used for LayoutPage's own optional notes field.
1 parent 02945bf commit 26cf115

2 files changed

Lines changed: 576 additions & 4 deletions

File tree

src/content.test.ts

Lines changed: 342 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
CONTENT_FORMAT_VERSION,
77
type ContentDocument,
88
ContentDocumentSchema,
9+
type ContentEmbeddedObject,
910
ContentRunSchema,
1011
ContentShapeSchema,
1112
type ContentTable,
@@ -181,6 +182,140 @@ function presentationDocument(): ContentDocument {
181182
};
182183
}
183184

185+
function spreadsheetDocument(): ContentDocument {
186+
return {
187+
kind: 'spreadsheet',
188+
formatVersion: CONTENT_FORMAT_VERSION,
189+
metadata: { title: 'Quarterly figures' },
190+
sheets: [
191+
{
192+
name: 'Sheet1',
193+
cells: [
194+
{ row: 0, column: 0, value: { kind: 'string', value: 'Revenue' }, displayText: 'Revenue' },
195+
{
196+
row: 0,
197+
column: 1,
198+
value: { kind: 'currency', value: 125000, currency: 'USD' },
199+
formula: '=SUM(B2:B10)',
200+
displayText: '$125,000.00',
201+
},
202+
{ row: 1, column: 1, value: { kind: 'percentage', value: 0.235 }, displayText: '23.5%' },
203+
{ row: 2, column: 1, value: { kind: 'boolean', value: true }, displayText: 'TRUE' },
204+
{ row: 3, column: 1, value: { kind: 'date', value: '2026-07-30' }, displayText: '30/07/2026' },
205+
{ row: 4, column: 1, value: { kind: 'time', value: '13:30:00' }, displayText: '1:30 PM' },
206+
{ row: 5, column: 1, value: { kind: 'error', value: '#DIV/0!' }, displayText: '#DIV/0!' },
207+
{ row: 6, column: 1, value: { kind: 'empty' }, displayText: '', colSpan: 2 },
208+
{
209+
row: 7,
210+
column: 0,
211+
value: { kind: 'string', value: 'Mixed formatting' },
212+
displayText: 'Mixed formatting',
213+
runs: [{ text: 'Mixed ' }, { text: 'formatting', bold: true }],
214+
},
215+
],
216+
columns: [
217+
{ index: 0, widthPt: 120 },
218+
{ index: 1, widthPt: 80, hidden: false },
219+
],
220+
rows: [
221+
{ index: 0, heightPt: 15 },
222+
{ index: 1, heightPt: 15, hidden: true },
223+
],
224+
images: [
225+
{
226+
kind: 'image',
227+
format: 'png',
228+
base64: 'AA==',
229+
widthPt: 40,
230+
heightPt: 40,
231+
anchorRow: 0,
232+
anchorColumn: 3,
233+
offsetXPt: 2,
234+
offsetYPt: 2,
235+
},
236+
],
237+
printSettings: {
238+
pageSize: { widthPt: 612, heightPt: 792 },
239+
margins: { topPt: 36, rightPt: 36, bottomPt: 36, leftPt: 36 },
240+
printRange: { startRow: 0, startColumn: 0, endRow: 10, endColumn: 5 },
241+
scale: 100,
242+
fitToPages: { width: 1, height: 1 },
243+
repeatRows: { start: 0, end: 0 },
244+
repeatColumns: { start: 0, end: 0 },
245+
gridlines: true,
246+
headers: false,
247+
pageOrder: 'downThenOver',
248+
manualBreaks: { rows: [20], columns: [] },
249+
},
250+
},
251+
],
252+
};
253+
}
254+
255+
function drawingDocument(): ContentDocument {
256+
return {
257+
kind: 'drawing',
258+
formatVersion: CONTENT_FORMAT_VERSION,
259+
metadata: { title: 'Org chart' },
260+
pages: [
261+
{
262+
size: { widthPt: 842, heightPt: 595 },
263+
shapes: [
264+
{
265+
frame: { xPt: 50, yPt: 50, widthPt: 200, heightPt: 60 },
266+
insetLeftPt: 3.6,
267+
insetTopPt: 3.6,
268+
insetRightPt: 3.6,
269+
insetBottomPt: 3.6,
270+
blocks: [paragraph],
271+
},
272+
],
273+
vectors: [
274+
{
275+
kind: 'rect',
276+
frame: { xPt: 50, yPt: 50, widthPt: 200, heightPt: 60 },
277+
fill: { r: 0.9, g: 0.9, b: 1 },
278+
stroke: { color: COLOR_BLACK, widthPt: 1 },
279+
},
280+
{
281+
kind: 'ellipse',
282+
frame: { xPt: 300, yPt: 50, widthPt: 100, heightPt: 100 },
283+
fill: { r: 1, g: 1, b: 0.8 },
284+
},
285+
{
286+
kind: 'line',
287+
from: { xPt: 250, yPt: 80 },
288+
to: { xPt: 300, yPt: 100 },
289+
stroke: { color: COLOR_BLACK, widthPt: 2 },
290+
},
291+
{
292+
kind: 'path',
293+
frame: { xPt: 400, yPt: 200, widthPt: 100, heightPt: 100 },
294+
subpaths: [
295+
{
296+
start: { xPt: 0, yPt: 0 },
297+
segments: [
298+
{ kind: 'line', to: { xPt: 100, yPt: 0 } },
299+
{
300+
kind: 'cubic',
301+
control1: { xPt: 100, yPt: 50 },
302+
control2: { xPt: 50, yPt: 100 },
303+
to: { xPt: 0, yPt: 100 },
304+
},
305+
],
306+
closed: true,
307+
},
308+
],
309+
fill: { r: 0.2, g: 0.8, b: 0.2 },
310+
fillRule: 'nonzero',
311+
stroke: { color: COLOR_BLACK, widthPt: 0.5 },
312+
},
313+
],
314+
},
315+
],
316+
};
317+
}
318+
184319
describe('sourcePath', () => {
185320
it('survives a JSON round trip when set on every block kind that carries it', () => {
186321
const runWithSourcePath: ContentBlock = {
@@ -256,8 +391,22 @@ describe('ContentDocumentSchema round trips', () => {
256391
expect(ContentDocumentSchema.parse(roundTripped)).toEqual(original);
257392
});
258393

394+
it('deep-equals the original spreadsheet document after a JSON round trip', () => {
395+
const original = spreadsheetDocument();
396+
const parsed = ContentDocumentSchema.parse(original);
397+
const roundTripped: unknown = JSON.parse(JSON.stringify(parsed));
398+
expect(ContentDocumentSchema.parse(roundTripped)).toEqual(original);
399+
});
400+
401+
it('deep-equals the original drawing document after a JSON round trip', () => {
402+
const original = drawingDocument();
403+
const parsed = ContentDocumentSchema.parse(original);
404+
const roundTripped: unknown = JSON.parse(JSON.stringify(parsed));
405+
expect(ContentDocumentSchema.parse(roundTripped)).toEqual(original);
406+
});
407+
259408
it('rejects an unknown discriminant', () => {
260-
expect(ContentDocumentSchema.safeParse({ kind: 'spreadsheet' }).success).toBe(false);
409+
expect(ContentDocumentSchema.safeParse({ kind: 'bogus' }).success).toBe(false);
261410
});
262411

263412
it('rejects a mismatched formatVersion', () => {
@@ -271,3 +420,195 @@ describe('ContentDocumentSchema round trips', () => {
271420
).toBe(false);
272421
});
273422
});
423+
424+
// 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.
425+
const formulaDocument: ContentDocument = {
426+
kind: 'wordprocessing',
427+
formatVersion: CONTENT_FORMAT_VERSION,
428+
metadata: { title: 'Formula' },
429+
sections: [
430+
{
431+
pageSize: { widthPt: 200, heightPt: 50 },
432+
margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 },
433+
blocks: [{ kind: 'paragraph', runs: [{ text: 'x^2 + y^2 = z^2' }] }],
434+
},
435+
],
436+
};
437+
438+
const formulaEmbeddedBlock: ContentBlock = {
439+
kind: 'embeddedObject',
440+
objectKind: 'formula',
441+
document: formulaDocument,
442+
frame: { xPt: 10, yPt: 10, widthPt: 80, heightPt: 20 },
443+
};
444+
445+
const drawingWithFormula: ContentDocument = {
446+
kind: 'drawing',
447+
formatVersion: CONTENT_FORMAT_VERSION,
448+
metadata: {},
449+
pages: [
450+
{
451+
size: { widthPt: 400, heightPt: 300 },
452+
shapes: [
453+
{
454+
frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 40 },
455+
insetLeftPt: 0,
456+
insetTopPt: 0,
457+
insetRightPt: 0,
458+
insetBottomPt: 0,
459+
blocks: [formulaEmbeddedBlock],
460+
},
461+
],
462+
vectors: [],
463+
},
464+
],
465+
};
466+
467+
const drawingEmbeddedObject: ContentEmbeddedObject = {
468+
objectKind: 'drawing',
469+
document: drawingWithFormula,
470+
frame: { xPt: 100, yPt: 100, widthPt: 200, heightPt: 150 },
471+
};
472+
473+
const spreadsheetWithDrawing: ContentDocument = {
474+
kind: 'spreadsheet',
475+
formatVersion: CONTENT_FORMAT_VERSION,
476+
metadata: {},
477+
sheets: [
478+
{
479+
name: 'Sheet1',
480+
cells: [],
481+
columns: [],
482+
rows: [],
483+
images: [],
484+
printSettings: {
485+
pageSize: { widthPt: 612, heightPt: 792 },
486+
margins: { topPt: 36, rightPt: 36, bottomPt: 36, leftPt: 36 },
487+
gridlines: true,
488+
headers: true,
489+
pageOrder: 'downThenOver',
490+
},
491+
embeddedObjects: [drawingEmbeddedObject],
492+
},
493+
],
494+
};
495+
496+
describe('ContentEmbeddedObjectSchema deep recursion', () => {
497+
it('accepts a formula embedded inside a drawing embedded inside a spreadsheet, three levels deep', () => {
498+
expect(isContentBlock(formulaEmbeddedBlock)).toBe(true);
499+
expect(ContentDocumentSchema.safeParse(spreadsheetWithDrawing).success).toBe(true);
500+
});
501+
502+
it('genuinely walks every level, not just the outermost shell', () => {
503+
const parsed = ContentDocumentSchema.parse(spreadsheetWithDrawing);
504+
if (parsed.kind !== 'spreadsheet') {
505+
throw new Error('expected a spreadsheet document');
506+
}
507+
const sheet = parsed.sheets[0];
508+
if (sheet === undefined) {
509+
throw new Error('expected a sheet');
510+
}
511+
const embeddedDrawing = sheet.embeddedObjects?.[0];
512+
if (embeddedDrawing?.document.kind !== 'drawing') {
513+
throw new Error('expected the level-2 embedded object to be a drawing document');
514+
}
515+
const page = embeddedDrawing.document.pages[0];
516+
if (page === undefined) {
517+
throw new Error('expected a drawing page');
518+
}
519+
const shape = page.shapes[0];
520+
if (shape === undefined) {
521+
throw new Error('expected a shape');
522+
}
523+
const embeddedFormulaBlock = shape.blocks[0];
524+
if (embeddedFormulaBlock?.kind !== 'embeddedObject') {
525+
throw new Error('expected the level-3 block to be an embedded object');
526+
}
527+
expect(embeddedFormulaBlock.objectKind).toBe('formula');
528+
if (embeddedFormulaBlock.document.kind !== 'wordprocessing') {
529+
throw new Error('expected the level-3 embedded document to be wordprocessing');
530+
}
531+
const formulaParagraph = embeddedFormulaBlock.document.sections[0]?.blocks[0];
532+
if (formulaParagraph?.kind !== 'paragraph') {
533+
throw new Error('expected a paragraph');
534+
}
535+
expect(formulaParagraph.runs[0]?.text).toBe('x^2 + y^2 = z^2');
536+
});
537+
538+
it('survives a JSON round trip at full depth', () => {
539+
const parsed = ContentDocumentSchema.parse(spreadsheetWithDrawing);
540+
const roundTripped: unknown = JSON.parse(JSON.stringify(parsed));
541+
expect(ContentDocumentSchema.parse(roundTripped)).toEqual(spreadsheetWithDrawing);
542+
});
543+
544+
it('rejects a malformed embedded object buried three levels deep, not just at the outermost shell', () => {
545+
const deeplyMalformed: unknown = {
546+
kind: 'spreadsheet',
547+
formatVersion: CONTENT_FORMAT_VERSION,
548+
metadata: {},
549+
sheets: [
550+
{
551+
name: 'Sheet1',
552+
cells: [],
553+
columns: [],
554+
rows: [],
555+
images: [],
556+
printSettings: {
557+
pageSize: { widthPt: 612, heightPt: 792 },
558+
margins: { topPt: 36, rightPt: 36, bottomPt: 36, leftPt: 36 },
559+
gridlines: true,
560+
headers: true,
561+
pageOrder: 'downThenOver',
562+
},
563+
embeddedObjects: [
564+
{
565+
objectKind: 'drawing',
566+
frame: { xPt: 100, yPt: 100, widthPt: 200, heightPt: 150 },
567+
document: {
568+
kind: 'drawing',
569+
formatVersion: CONTENT_FORMAT_VERSION,
570+
metadata: {},
571+
pages: [
572+
{
573+
size: { widthPt: 400, heightPt: 300 },
574+
vectors: [],
575+
shapes: [
576+
{
577+
frame: { xPt: 0, yPt: 0, widthPt: 100, heightPt: 40 },
578+
insetLeftPt: 0,
579+
insetTopPt: 0,
580+
insetRightPt: 0,
581+
insetBottomPt: 0,
582+
blocks: [
583+
{
584+
kind: 'embeddedObject',
585+
objectKind: 'formula',
586+
frame: { xPt: 10, yPt: 10, widthPt: 80, heightPt: 20 },
587+
document: {
588+
kind: 'wordprocessing',
589+
formatVersion: CONTENT_FORMAT_VERSION,
590+
metadata: {},
591+
sections: [
592+
{
593+
pageSize: { widthPt: 200, heightPt: 50 },
594+
margins: { topPt: 0, rightPt: 0, bottomPt: 0, leftPt: 0 },
595+
// malformed: a run's text must be a string, not a number -- must still fail even though every ancestor around it is well-formed.
596+
blocks: [{ kind: 'paragraph', runs: [{ text: 1 }] }],
597+
},
598+
],
599+
},
600+
},
601+
],
602+
},
603+
],
604+
},
605+
],
606+
},
607+
},
608+
],
609+
},
610+
],
611+
};
612+
expect(ContentDocumentSchema.safeParse(deeplyMalformed).success).toBe(false);
613+
});
614+
});

0 commit comments

Comments
 (0)