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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
"packageManager": "pnpm@11.6.0",
"dependencies": {
"byte-codec": "^1.1.9",
"document-schema.js": "^4.0.0",
"document-schema.js": "^4.1.0",
"fflate": "^0.8.3",
"markdown-codec": "^3.0.1",
"odf.js": "^4.0.1",
Expand Down
4 changes: 2 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 19 additions & 2 deletions src/convert/decompose.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { ContentBlock, ContentDocument, ContentEmbeddedObject, ContentFormula, ContentSection, ContentShape, ContentSheetCell, ContentSheetImage, ContentSheetPrintSettings, ContentVector, DocumentPackage, SheetGroupNode } from 'document-schema.js';
import { isHeadingGroupNode, isListGroupNode, type ContentBlock, type ContentDocument, type ContentEmbeddedObject, type ContentFormula, type ContentSection, type ContentShape, type ContentSheetCell, type ContentSheetImage, type ContentSheetPrintSettings, type ContentVector, type DocumentPackage, type SectionConstructGroupNode, type SectionGroupNode, type ShapeConstructGroupNode, type ShapeGroupNode, type SheetGroupNode, type SlideGroupNode } from 'document-schema.js';
import { decompose, decomposeSection, decomposeSheet, isHeadingParagraph } from './decompose';
import { flattenPackage } from './flatten';

Expand Down Expand Up @@ -159,6 +159,22 @@ describe('spreadsheet decomposition', () => {
});
});

// document-schema.js 4.1.0 added SectionConstructGroupNode/ShapeConstructGroupNode (docx SDTs, ODF fields, tracked changes, and the rest of document-schema.js#22's fidelity-construct vocabulary) to the tree. decompose.ts never manufactures one (grepping this package's src/ for ConstructDescriptor/SectionConstructGroupNode/ShapeConstructGroupNode returns nothing outside document-schema.js's own types), but a hand-built or third-party tree can carry one, and ContentBlock has no construct carrier to flatten it into (document-schema.js#22 tracks that separately) -- so flatten refuses loudly rather than silently dropping the construct's own semantic wrapper and keeping only its flattened children, the same "fail loudly, never silently skip" rule the sheet-group guard above follows.
describe('construct group refusal', () => {
it('refuses a section construct group loudly -- ContentBlock has no construct carrier yet', () => {
const constructGroup: SectionConstructGroupNode = { node: { kind: 'field', instruction: 'PAGE' }, children: [paragraph('inside a field')] };
const sectionGroup: SectionGroupNode = { node: { kind: 'section', pageSize: SECTION_GEOMETRY.pageSize, margins: SECTION_GEOMETRY.margins }, children: [constructGroup] };
expect(() => flattenPackage({ kind: 'wordprocessing', metadata: {}, children: [sectionGroup] })).toThrow(/construct group/);
});

it('refuses a shape construct group loudly -- the identical gap, on the shape/list flow', () => {
const constructGroup: ShapeConstructGroupNode = { node: { kind: 'anchor', anchorType: 'bookmark', name: 'b1' }, children: [paragraph('inside a bookmark')] };
const shapeGroup: ShapeGroupNode = { node: { frame: { xPt: 0, yPt: 0, widthPt: 400, heightPt: 300 }, insetLeftPt: 0, insetTopPt: 0, insetRightPt: 0, insetBottomPt: 0 }, children: [constructGroup] };
const slideGroup: SlideGroupNode = { node: { kind: 'slide', size: { widthPt: 960, heightPt: 540 }, notes: '' }, children: [shapeGroup] };
expect(() => flattenPackage({ kind: 'presentation', metadata: {}, children: [slideGroup] })).toThrow(/construct group/);
});
});

describe('drawing and formula decomposition', () => {
it('orders a draw page\'s children shapes-then-vectors and nests each shape\'s flow inside it', () => {
const vector: ContentVector = { kind: 'rect', frame: { xPt: 1, yPt: 2, widthPt: 3, heightPt: 4 } };
Expand Down Expand Up @@ -190,7 +206,8 @@ describe('ownership', () => {
const source: ContentSection = { ...SECTION_GEOMETRY, blocks: [heading, body] };
const sectionGroup = decomposeSection(source);
const [headingGroup] = sectionGroup.children;
if (headingGroup === undefined || !('node' in headingGroup) || !('children' in headingGroup)) {
// A plain 'node'/'children' presence check no longer narrows out every non-anchor shape: since document-schema.js 4.1.0, a SectionConstructGroupNode carries both too. isHeadingGroupNode/isListGroupNode are the real schema guards, so reaching for them here (rather than reinventing the anchor-vs-construct narrow this test doesn't need to know about) both fixes the narrowing and states the assertion's actual intent.
if (headingGroup === undefined || !(isHeadingGroupNode(headingGroup) || isListGroupNode(headingGroup))) {
throw new Error('expected the heading paragraph to open the section flow');
}
expect(headingGroup.node).toBe(heading);
Expand Down
55 changes: 53 additions & 2 deletions src/convert/factor-styles.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { ContentBlock, ContentDocument, ContentParagraph, ContentRun, DocumentPackage } from 'document-schema.js';
import type { ContentBlock, ContentDocument, ContentParagraph, ContentRun, DocumentPackage, SectionConstructGroupNode, SectionGroupNode, ShapeConstructGroupNode, ShapeGroupNode, SlideGroupNode } from 'document-schema.js';
import { DocumentPackageSchema } from 'document-schema.js';
import { assemblePackage, factorStyles } from './factor-styles';
import { assemblePackage, factorStyles, mint } from './factor-styles';
import { flattenPackage } from './flatten';
import { canonicalise } from './canonicalise';

Expand Down Expand Up @@ -285,6 +285,57 @@ describe('factorStyles minting', () => {
expect(minted.styles).toBeUndefined();
expect(DocumentPackageSchema.safeParse(minted).success).toBe(true);
});

it('factors a paragraph tuple nested inside a construct group\'s children onto the construct group\'s own ref (document-schema.js 4.1.0)', () => {
// decompose.ts never manufactures a construct group, and flattenPackage now refuses one outright (see flatten.ts), so the only way to exercise extentOf/flowExtent's construct-group recognition is a hand-built tree run straight through mint() -- assemblePackage/factorStyles can never hand one to it.
const outside = paragraph([run('outside')], { alignment: 'right' });
const insideA = paragraph([run('a')], { indentLeftPt: 20 });
const insideB = paragraph([run('b')], { indentLeftPt: 20 });
const constructGroup: SectionConstructGroupNode = { node: { kind: 'contentControl', controlType: 'richText' }, children: [insideA, insideB] };
const sectionGroup: SectionGroupNode = { node: { kind: 'section', ...SECTION }, children: [outside, constructGroup] };
const pkg: DocumentPackage = { kind: 'wordprocessing', metadata: {}, children: [sectionGroup] };
const minted = mint(pkg);
// Rule 1 (every extent paragraph must carry a minted key) means the section's own three-paragraph extent shares no key across all three -- outside lacks indentLeftPt, insideA/insideB lack alignment -- so the section wrapper itself mints nothing. Only once the walk descends INTO the construct group's own two-paragraph extent (proof extentOf/flowExtent recurse into a construct group's children rather than stopping at or skipping it) does indentLeftPt become common there and mint.
expect(refsOf(minted)).toEqual([{ ref: 's1', nodeKind: 'contentControl' }]);
expect(minted.styles?.s1).toEqual({ paragraph: { indentLeftPt: 20 } });
if (minted.kind !== 'wordprocessing') throw new Error('expected wordprocessing');
const mintedSection = minted.children[0];
if (mintedSection === undefined) throw new Error('expected the section group');
const mintedConstruct = mintedSection.children[1];
if (mintedConstruct === undefined || !('node' in mintedConstruct) || !('children' in mintedConstruct)) {
throw new Error('expected the construct group to survive minting');
}
expect(mintedConstruct.style).toBe('s1');
expect(mintedConstruct.children[0]).not.toHaveProperty('indentLeftPt');
expect(mintedConstruct.children[1]).not.toHaveProperty('indentLeftPt');
});

it('factors a paragraph tuple nested inside a shape-flow construct group onto the construct group\'s own ref (document-schema.js 4.1.0)', () => {
// The section-flow test above exercises rebuildSectionConstructGroup and the isConstructGroup arm in rebuildSectionChild; this mirrors it through the shape/list-flow vocabulary instead -- a ShapeConstructGroupNode sat inside a ShapeGroupNode's own children, nested under a SlideGroupNode -- so rebuildShapeConstructGroup and the isConstructGroup dispatch arm in rebuildListChild get their own coverage rather than riding untested on the section-flow rebuilder's coattails. decompose.ts never manufactures a construct group and flattenPackage now refuses one outright (see flatten.ts), so mint() run directly on a hand-built tree is again the only route that reaches either.
const outside = paragraph([run('outside')], { alignment: 'right' });
const insideA = paragraph([run('a')], { indentLeftPt: 20 });
const insideB = paragraph([run('b')], { indentLeftPt: 20 });
const constructGroup: ShapeConstructGroupNode = { node: { kind: 'contentControl', controlType: 'richText' }, children: [insideA, insideB] };
const shapeGroup: ShapeGroupNode = { node: { frame: { xPt: 0, yPt: 0, widthPt: 400, heightPt: 300 }, insetLeftPt: 0, insetTopPt: 0, insetRightPt: 0, insetBottomPt: 0 }, children: [outside, constructGroup] };
const slideGroup: SlideGroupNode = { node: { kind: 'slide', size: { widthPt: 960, heightPt: 540 }, notes: '' }, children: [shapeGroup] };
const pkg: DocumentPackage = { kind: 'presentation', metadata: {}, children: [slideGroup] };
const minted = mint(pkg);
// Neither the slide's nor the shape's own extent shares a key across all three paragraphs (outside lacks indentLeftPt, insideA/insideB lack alignment), so both mint nothing; only descending into the construct group's own two-paragraph extent makes indentLeftPt common there and mints.
expect(refsOf(minted)).toEqual([{ ref: 's1', nodeKind: 'contentControl' }]);
expect(minted.styles?.s1).toEqual({ paragraph: { indentLeftPt: 20 } });
if (minted.kind !== 'presentation') throw new Error('expected presentation');
const mintedSlide = minted.children[0];
if (mintedSlide === undefined) throw new Error('expected the slide group');
const mintedShape = mintedSlide.children[0];
if (mintedShape === undefined) throw new Error('expected the shape group');
const mintedConstruct = mintedShape.children[1];
if (mintedConstruct === undefined || !('node' in mintedConstruct) || !('children' in mintedConstruct)) {
throw new Error('expected the construct group to survive minting');
}
expect(mintedConstruct.style).toBe('s1');
expect(mintedConstruct.children[0]).not.toHaveProperty('indentLeftPt');
expect(mintedConstruct.children[1]).not.toHaveProperty('indentLeftPt');
});
});

// Finds the first group wrapper anywhere in the tree whose anchor paragraph's first run text matches -- the frozen-key test's H2 group sits nested inside the H1 group, not at any fixed depth.
Expand Down
Loading