diff --git a/package.json b/package.json index 34846858..41ce040a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a615f73..7b187ea3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: specifier: ^1.1.9 version: 1.1.9 document-schema.js: - specifier: ^4.0.0 - version: 4.0.0 + specifier: ^4.1.0 + version: 4.1.0 fflate: specifier: ^0.8.3 version: 0.8.3 diff --git a/src/convert/decompose.test.ts b/src/convert/decompose.test.ts index f6aef8d3..79eb2eb1 100644 --- a/src/convert/decompose.test.ts +++ b/src/convert/decompose.test.ts @@ -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'; @@ -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 } }; @@ -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); diff --git a/src/convert/factor-styles.test.ts b/src/convert/factor-styles.test.ts index b107582e..6c0435c1 100644 --- a/src/convert/factor-styles.test.ts +++ b/src/convert/factor-styles.test.ts @@ -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'; @@ -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. diff --git a/src/convert/factor-styles.ts b/src/convert/factor-styles.ts index 090259c8..7fa6a4dd 100644 --- a/src/convert/factor-styles.ts +++ b/src/convert/factor-styles.ts @@ -1,12 +1,14 @@ import type { - ContentBlock, ContentDocument, ContentParagraph, ContentRun, DocumentPackage, + ListChild, PageSize, SectionChild, + SectionConstructGroupNode, SectionGroupNode, + ShapeConstructGroupNode, ShapeGroupNode, SlideGroupNode, StyleEntry, @@ -44,13 +46,13 @@ const RUN_STYLE_KEYS = ['bold', 'italic', 'underline', 'strike', 'fontFamily', ' type ParagraphKey = (typeof PARAGRAPH_STYLE_KEYS)[number]; type RunKey = (typeof RUN_STYLE_KEYS)[number]; -// The wrapper kinds that can carry a ref and hold block-flow paragraphs. SheetGroupNode fits the wrapper shape but its children are images and embedded objects -- no paragraphs, an always-empty extent -- so it never mints and is excluded from the walk's type. -type MintWrapper = SectionGroupNode | SlideGroupNode | DrawPageGroupNode | ShapeGroupNode | HeadingGroupNode | ListGroupNode; +// The wrapper kinds that can carry a ref and hold block-flow paragraphs. SheetGroupNode fits the wrapper shape but its children are images and embedded objects -- no paragraphs, an always-empty extent -- so it never mints and is excluded from the walk's type. SectionConstructGroupNode/ShapeConstructGroupNode (document-schema.js 4.1.0) join the set on equal footing: each carries the same `{ node, style?, children }` shape as every other wrapper here, and neither needs a dedicated dispatch arm below -- their node is never the 'paragraph'/'slide'/'drawPage' discriminant any other wrapper matches on, so both fall straight through to extentOf/childWrappers' shared "no anchor of its own" default, the same default a plain SectionGroupNode already relies on. +type MintWrapper = SectionGroupNode | SlideGroupNode | DrawPageGroupNode | ShapeGroupNode | HeadingGroupNode | ListGroupNode | SectionConstructGroupNode | ShapeConstructGroupNode; -// One child position of any block flow: the union of the section, list, and shape flows' child vocabularies (ListChild and ShapeChild are both ListGroupNode | ContentBlock, sub-ranges of SectionChild), so the extent walk serves all three with one function. -type FlowChild = SectionChild; +// One child position of any block flow: the union of the section, list, and shape flows' child vocabularies. ListChild and ShapeChild are the identical type (ListGroupNode | ShapeConstructGroupNode | ContentBlock) since 4.1.0, no longer a sub-range of SectionChild (which carries SectionConstructGroupNode instead) -- so the extent walk needs both halves explicitly to serve all three flows with one function. +type FlowChild = SectionChild | ListChild; -// Per-kind narrowers over MintWrapper. These exist because TypeScript does not narrow a union from a comparison against a NESTED discriminant (`wrapper.node.kind === 'section'` narrows wrapper.node at best, never `wrapper`) -- the identical reason document-schema.js's own package-node.ts writes per-kind predicates, and an explicit guard is what narrows the wrapper itself. A shape group is the no-kind arm (ContentShape carries no kind field); heading and list groups share the 'paragraph' node discriminant and stay one arm because the minting walk treats every anchor alike. +// Per-kind narrowers over MintWrapper. These exist because TypeScript does not narrow a union from a comparison against a NESTED discriminant (`wrapper.node.kind === 'section'` narrows wrapper.node at best, never `wrapper`) -- the identical reason document-schema.js's own package-node.ts writes per-kind predicates, and an explicit guard is what narrows the wrapper itself. A shape group is the no-kind arm (ContentShape carries no kind field); heading and list groups share the 'paragraph' node discriminant and stay one arm because the minting walk treats every anchor alike. SectionGroupNode, SectionConstructGroupNode, and ShapeConstructGroupNode get no guard of their own: none of their node kinds ('section', or one of the six construct kinds) matches any check below, so all three fall through to the shared "no anchor" default at the foot of extentOf/childWrappers. function isShapeGroupWrapper(wrapper: MintWrapper): wrapper is ShapeGroupNode { return !('kind' in wrapper.node); } @@ -72,6 +74,11 @@ function isHeadingGroup(group: HeadingGroupNode | ListGroupNode): group is Headi return group.node.headingLevel !== undefined; } +// A section- or shape-flow child position whose own node is a construct descriptor rather than a paragraph -- the same structural narrow flatten.ts uses (node.kind is never 'paragraph' for a ConstructDescriptor), needed here in the rebuild walk below to dispatch a construct-group position to its own rebuilder rather than treating it as a heading/list anchor. +function isConstructGroup(child: SectionChild | ListChild): child is SectionConstructGroupNode | ShapeConstructGroupNode { + return 'node' in child && 'children' in child && child.node.kind !== 'paragraph'; +} + // Assembles the tree-form DocumentPackage every construction site reports: decompose the flat content into its children, splice the envelope fields (kind, metadata, symbolTable) out of the content onto the root, carry `pages` when a layout pass produced rendered page sizes, then mint the styles table over the result. `pages` is spread-copied because the schema's array field is mutable while callers hand us readonly views of the layout engine's own array. export function assemblePackage(content: ContentDocument, pages?: readonly PageSize[]): DocumentPackage { const envelope = { @@ -122,11 +129,11 @@ function extentOf(wrapper: MintWrapper): ContentParagraph[] { } return paragraphs; } - // A section group: no anchor, its whole flow is the extent. + // A section group or a construct group (section or shape variant): no anchor of its own, its whole flow is the extent -- a construct descriptor is never a paragraph, so it never contributes a paragraph itself, exactly like a plain section group's descriptor. return flowExtent(wrapper.children); } -// The block-flow extent of one section/heading/list/shape child list: nested heading and list groups contribute their anchors and recurse, bare paragraph leaves contribute themselves, every other leaf (tables, images, page breaks, embedded objects) contributes nothing. +// The block-flow extent of one section/heading/list/shape/construct child list: nested heading, list, and construct groups contribute their anchors (construct groups contribute none of their own) and recurse, bare paragraph leaves contribute themselves, every other leaf (tables, images, page breaks, embedded objects) contributes nothing. function flowExtent(children: readonly FlowChild[]): ContentParagraph[] { const paragraphs: ContentParagraph[] = []; for (const child of children) { @@ -328,8 +335,8 @@ function childWrappers(wrapper: MintWrapper): MintWrapper[] { return wrappers; } -// The entry point over a whole tree: plan (outermost-first, freezing keys and factoring positions down each chain), order the entries, then rebuild the tree stamping refs and stripping keys per chain. -function mint(pkg: DocumentPackage): DocumentPackage { +// The entry point over a whole tree: plan (outermost-first, freezing keys and factoring positions down each chain), order the entries, then rebuild the tree stamping refs and stripping keys per chain. Exported (beyond assemblePackage's own internal use, which only ever calls it on a tree decompose.ts just produced) because it is otherwise unreachable on a hand-built tree carrying a construct group: assemblePackage only ever takes flat content (decompose never manufactures a construct group), and factorStyles flattens its input first, which now refuses one (see flatten.ts) -- mint's own construct-group handling is real, load-bearing code with no other route a test (or a caller with an already-tree-form package) can reach it through. +export function mint(pkg: DocumentPackage): DocumentPackage { const state: MintState = { wrapperRefs: new Map(), wrapperStrips: new Map(), @@ -433,8 +440,11 @@ function rebuildSectionGroup(group: SectionGroupNode, chain: ChainStrips, state: return unchanged ? group : { node: group.node, ...(ref !== undefined ? { style: ref } : {}), children }; } -// One section-flow child position: a heading group recurses through the section-flow vocabulary, a list group through the list-flow vocabulary (its own children are ListChild, the shared list/shape vocabulary), a bare paragraph leaf is copied only when stripped, every other leaf passes through as the same object. +// One section-flow child position: a construct group recurses through its own rebuilder (no anchor to narrow), a heading group recurses through the section-flow vocabulary, a list group through the list-flow vocabulary (its own children are ListChild, the shared list/shape vocabulary), a bare paragraph leaf is copied only when stripped, every other leaf passes through as the same object. function rebuildSectionChild(child: SectionChild, chain: ChainStrips, state: MintState): SectionChild { + if (isConstructGroup(child)) { + return rebuildSectionConstructGroup(child, chain, state); + } if ('node' in child && 'children' in child) { return isHeadingGroup(child) ? rebuildHeadingGroup(child, chain, state, rebuildSectionChild) : rebuildListGroup(child, chain, state, rebuildListChild); } @@ -445,7 +455,10 @@ function rebuildSectionChild(child: SectionChild, chain: ChainStrips, state: Min } // One list-flow child position -- the shared vocabulary of list-group children and shape flows. -function rebuildListChild(child: ListGroupNode | ContentBlock, chain: ChainStrips, state: MintState): ListGroupNode | ContentBlock { +function rebuildListChild(child: ListChild, chain: ChainStrips, state: MintState): ListChild { + if (isConstructGroup(child)) { + return rebuildShapeConstructGroup(child, chain, state); + } if ('node' in child && 'children' in child) { return rebuildListGroup(child, chain, state, rebuildListChild); } @@ -464,6 +477,24 @@ function rebuildShapeGroup(group: ShapeGroupNode, chain: ChainStrips, state: Min return unchanged ? group : { node: group.node, ...(ref !== undefined ? { style: ref } : {}), children }; } +// A construct group sat in a section flow: no anchor of its own (its node is a ConstructDescriptor, never a paragraph), so it rebuilds exactly like rebuildSectionGroup -- stamp its own ref when minted, rebuild its section-flow children below it. +function rebuildSectionConstructGroup(group: SectionConstructGroupNode, chain: ChainStrips, state: MintState): SectionConstructGroupNode { + const inner = innerChain(group, chain, state); + const children = group.children.map((child) => rebuildSectionChild(child, inner, state)); + const ref = state.wrapperRefs.get(group); + const unchanged = ref === undefined && children.every((child, index) => child === group.children[index]); + return unchanged ? group : { node: group.node, ...(ref !== undefined ? { style: ref } : {}), children }; +} + +// A construct group sat in a shape or list-item flow: the same shape as rebuildSectionConstructGroup, over the list-flow vocabulary instead. +function rebuildShapeConstructGroup(group: ShapeConstructGroupNode, chain: ChainStrips, state: MintState): ShapeConstructGroupNode { + const inner = innerChain(group, chain, state); + const children = group.children.map((child) => rebuildListChild(child, inner, state)); + const ref = state.wrapperRefs.get(group); + const unchanged = ref === undefined && children.every((child, index) => child === group.children[index]); + return unchanged ? group : { node: group.node, ...(ref !== undefined ? { style: ref } : {}), children }; +} + function rebuildHeadingGroup(group: HeadingGroupNode, chain: ChainStrips, state: MintState, rebuildChild: (child: SectionChild, chain: ChainStrips, state: MintState) => SectionChild): HeadingGroupNode { const inner = innerChain(group, chain, state); const anchor = rebuildParagraph(group.node, inner); @@ -474,7 +505,7 @@ function rebuildHeadingGroup(group: HeadingGroupNode, chain: ChainStrips, state: return unchanged ? group : { node: anchor, ...(ref !== undefined ? { style: ref } : {}), children }; } -function rebuildListGroup(group: ListGroupNode, chain: ChainStrips, state: MintState, rebuildChild: (child: ListGroupNode | ContentBlock, chain: ChainStrips, state: MintState) => ListGroupNode | ContentBlock): ListGroupNode { +function rebuildListGroup(group: ListGroupNode, chain: ChainStrips, state: MintState, rebuildChild: (child: ListChild, chain: ChainStrips, state: MintState) => ListChild): ListGroupNode { const inner = innerChain(group, chain, state); const anchor = rebuildParagraph(group.node, inner); assertListAnchor(anchor); diff --git a/src/convert/flatten.ts b/src/convert/flatten.ts index 71073a62..e6930660 100644 --- a/src/convert/flatten.ts +++ b/src/convert/flatten.ts @@ -16,8 +16,11 @@ import { type ContentVector, type DocumentPackage, type HeadingGroupNode, + type ListChild, type ListGroupNode, type SectionChild, + type SectionConstructGroupNode, + type ShapeConstructGroupNode, type ShapeGroupNode, type StyleEntry, type StylesTable, @@ -134,7 +137,10 @@ function flattenShape(styles: StylesTable | undefined, chain: readonly string[], return { ...group.node, blocks: flattenListChildren(styles, chainWithRef(chain, group), group.children) }; } -// One section-flow child walk: nested heading/list groups recurse with their extended chain, a bare paragraph leaf resolves against the incoming chain (it carries no ref of its own -- refs are legal only on group wrappers), and every other leaf is its own payload, untouched. +// The message a construct group throws with, wherever flatten meets one -- both flow walks below name the identical gap, so the wording is not tied to which flow found it. +const CONSTRUCT_GROUP_UNFLATTENABLE = 'flattenPackage: cannot flatten a construct group -- ContentBlock has no construct carrier yet (see document-schema.js#22)'; + +// One section-flow child walk: nested heading/list groups recurse with their extended chain, a bare paragraph leaf resolves against the incoming chain (it carries no ref of its own -- refs are legal only on group wrappers), a construct group refuses loudly (see isConstructGroup below), and every other leaf is its own payload, untouched. function flattenSectionChildren(styles: StylesTable | undefined, chain: readonly string[], children: readonly SectionChild[]): ContentBlock[] { const blocks: ContentBlock[] = []; for (const child of children) { @@ -144,6 +150,8 @@ function flattenSectionChildren(styles: StylesTable | undefined, chain: readonly } else if (isListGroup(child)) { const own = chainWithRef(chain, child); blocks.push(resolveAnchor(styles, own, child.node), ...flattenListChildren(styles, own, child.children)); + } else if (isConstructGroup(child)) { + throw new Error(CONSTRUCT_GROUP_UNFLATTENABLE); } else if (child.kind === 'paragraph') { const entry = entryOf(styles, chain); blocks.push(entry === undefined ? child : applyEntry(entry, child)); @@ -154,13 +162,15 @@ function flattenSectionChildren(styles: StylesTable | undefined, chain: readonly return blocks; } -// The shared vocabulary of shape flows and list-group children (ListGroupNode | ContentBlock), so one walk serves both. -function flattenListChildren(styles: StylesTable | undefined, chain: readonly string[], children: readonly (ListGroupNode | ContentBlock)[]): ContentBlock[] { +// The shared vocabulary of shape flows and list-group children (ListChild: ListGroupNode | ShapeConstructGroupNode | ContentBlock), so one walk serves both. +function flattenListChildren(styles: StylesTable | undefined, chain: readonly string[], children: readonly ListChild[]): ContentBlock[] { const blocks: ContentBlock[] = []; for (const child of children) { if (isListGroup(child)) { const own = chainWithRef(chain, child); blocks.push(resolveAnchor(styles, own, child.node), ...flattenListChildren(styles, own, child.children)); + } else if (isConstructGroup(child)) { + throw new Error(CONSTRUCT_GROUP_UNFLATTENABLE); } else if (child.kind === 'paragraph') { const entry = entryOf(styles, chain); blocks.push(entry === undefined ? child : applyEntry(entry, child)); @@ -171,15 +181,20 @@ function flattenListChildren(styles: StylesTable | undefined, chain: readonly st return blocks; } -// Structural narrows over the already-typed child unions, avoiding a widening round-trip through the schema's unknown-taking guards inside this module's own walks: both group kinds carry `node`+`children`, no block leaf does. +// Structural narrows over the already-typed child unions, avoiding a widening round-trip through the schema's unknown-taking guards inside this module's own walks: every group kind carries `node`+`children`, no block leaf does. function isHeadingGroup(child: SectionChild): child is HeadingGroupNode { return 'node' in child && 'children' in child && child.node.kind === 'paragraph' && child.node.headingLevel !== undefined; } -function isListGroup(child: SectionChild | ListGroupNode | ContentBlock): child is ListGroupNode { +function isListGroup(child: SectionChild | ListChild): child is ListGroupNode { return 'node' in child && 'children' in child && child.node.kind === 'paragraph' && child.node.list !== undefined; } +// A construct group's own node is a ConstructDescriptor (contentControl/field/anchor/link/provenance/division), never a paragraph -- discriminated off the same node.kind property the heading/list narrows above read, since a ConstructDescriptor's `kind` is always disjoint from `'paragraph'`. Section and shape construct groups share this one narrow: flatten treats both identically (refuse), so there is no need to tell them apart. +function isConstructGroup(child: SectionChild | ListChild): child is SectionConstructGroupNode | ShapeConstructGroupNode { + return 'node' in child && 'children' in child && child.node.kind !== 'paragraph'; +} + // One group anchor under its own chain: an empty chain leaves the anchor object as-is (the ownership discipline -- no copies when nothing resolves), anything else resolves the entry and applies it; the anchor's required grouping signal survives gap-fill by construction, and a resolved heading/list anchor keeps its narrowed type through the shared ContentParagraph return. function resolveAnchor(styles: StylesTable | undefined, chain: readonly string[], anchor: HeadingGroupNode['node'] | ListGroupNode['node']): ContentParagraph { const entry = entryOf(styles, chain);