diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index c1fd2c8..2638b33 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -69,6 +69,12 @@ layer in Word's ascending precedence, over the table's borders and under the cel `w:tblLook` decides which areas apply at all — named flags where the file has them, the older `w:val` bitmask otherwise, and no element at all means none. +**A table style's `w:pPr` ranks *below* the paragraph style**, so a cell's spacing layers +docDefaults ← the table style ← the cell paragraph's own style chain ← direct `w:pPr` +(`paragraphSpacing(id, under)`). The chain has to be baked in because a cell carries no +style name; read as an override the way a table style zeroing `w:after` reads, every table +loses the space after its cells' paragraphs and a long document comes out pages short. + Body text with no resolved font falls back to the *document's own theme minor font* (`docx.ts` `runMarks`), not the editor default — Word's implicit body default. Headings don't (they keep the editor heading default); DOCX page margins default to Word's 2.54cm. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 6810a08..e6989c9 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -834,11 +834,12 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: // paragraph style commonly carries justify), so style-level alignment isn't lost. const directJc = fc(ppr, 'jc'); const jcVal = directJc ? wVal(directJc) : ctx.styles.paragraphAlign(pStyle ? wVal(pStyle) : null); - // Spacing/alignment from the style now live in the style registry, so only DIRECT - // w:pPr counts as formatting on the block — except in a cell, where the table style's - // w:pPr governs and no registry style reaches the cell's paragraphs. - const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null, kind === 'cell' ? ctx.cellSpacing : {}); const styleId = styleIdOf(ppr, ctx); + // Only DIRECT w:pPr counts as formatting on the block; the style's own lives in the + // registry — except in a cell, which carries no style name, so its chain is baked in + // over the table style's w:pPr (probed: that ranks *below* the paragraph style). + const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null, + kind === 'cell' ? ctx.styles.paragraphSpacing(styleId, ctx.cellSpacing) : {}); applyContextualSpacing(el, ppr, ctx, styleId, attrs); // Widow-orphan control has no registry home, so the resolved value rides the block. const directWc = fc(ppr, 'widowControl'); @@ -1847,12 +1848,12 @@ function bakeCellRuns(nodes: Node[], props: RunProps): void { } function convertTable(tbl: Element, ctx: Ctx): Node | null { - // The table style's own w:pPr/w:spacing governs its cells' paragraphs (Word's Table - // Grid zeroes the space after and the line spacing). Restored for a nested table. + // The table style's own w:pPr/w:spacing reaches its cells' paragraphs (Word's Table Grid + // zeroes the space after and the line spacing), under their own style chain. Restored + // for a nested table. const outerSpacing = ctx.cellSpacing; const tblStyleEl = fc(fc(tbl, 'tblPr'), 'tblStyle'); - const tblStyleId = tblStyleEl ? wVal(tblStyleEl) : null; - if (tblStyleId) ctx.cellSpacing = ctx.styles.paragraphSpacing(tblStyleId); + ctx.cellSpacing = ctx.styles.tableSpacing(tblStyleEl ? wVal(tblStyleEl) : null); try { return buildTable(tbl, ctx); } finally { diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index de7ee01..3df4ea6 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -478,10 +478,16 @@ export class DocxStyles { } // Effective paragraph spacing from styles only (direct w:pPr/w:spacing wins in the caller): - // docDefaults ← the pStyle's basedOn chain, or the default paragraph style when there is no - // w:pStyle. An attribute no layer sets is Word's implied 0 (applied in blockAttrs). - paragraphSpacing(pStyleId: string | null | undefined): ParaSpacing { - return { ...this.defaultsSpacing, ...this.styleSpacing(pStyleId ?? this.defaultParaStyle) }; + // docDefaults ← `under` ← the pStyle's basedOn chain, or the default paragraph style when + // there is no w:pStyle. An attribute no layer sets is Word's implied 0 (blockAttrs). + paragraphSpacing(pStyleId: string | null | undefined, under: ParaSpacing = {}): ParaSpacing { + return { ...this.defaultsSpacing, ...under, ...this.styleSpacing(pStyleId ?? this.defaultParaStyle) }; + } + + // A table style's own w:pPr/w:spacing along its w:basedOn chain. Word ranks it under + // the paragraph style, so it goes in as `under` above, never on top. + tableSpacing(styleId: string | null | undefined): ParaSpacing { + return this.styleSpacing(styleId); } // The table style's own w:tblCellMar along the w:basedOn chain (leaf wins). The diff --git a/tests/unit/docx-table-style.test.ts b/tests/unit/docx-table-style.test.ts index dbbb802..e01a030 100644 --- a/tests/unit/docx-table-style.test.ts +++ b/tests/unit/docx-table-style.test.ts @@ -117,3 +117,45 @@ describe('a Word table style paints its conditional areas', () => { expect(marksOf(again[1][0])).toEqual([{ type: 'bold' }]); }); }); + +// docDefaults ← the table style's w:pPr ← the paragraph style's chain ← direct w:pPr. +// Probed against soffice: a table style zeroing the space after does not take it off a +// Normal that declares one, but its line spacing does beat docDefaults'. +const SPACING_STYLES = ` + + + + + + + + +`; + +const spacedTable = (cellPPr = '') => + zipSync({ + 'word/document.xml': strToU8(`` + + `` + + `${cellPPr}cell` + + ``), + 'word/styles.xml': strToU8(SPACING_STYLES), + }); + +describe('a table style’s w:pPr ranks under the paragraph style', () => { + const attrsOf = (bytes: Uint8Array) => cellsOf(bytes)[0][0].content[0].attrs; + + it('keeps the paragraph style’s space after and takes the table style’s line', () => { + expect(attrsOf(spacedTable())).toMatchObject({ spaceAfter: 8, lineHeight: '1' }); + }); + + it('lets direct w:pPr beat both', () => { + const direct = spacedTable(''); + expect(attrsOf(direct)).toMatchObject({ spaceAfter: 24, lineHeight: '2' }); + }); + + it('round-trips the baked values', async () => { + const doc = { type: 'doc', content: importDocx(spacedTable()).content.content }; + const bytes = await buildDocx(doc as N, { top: 2, bottom: 2, left: 2, right: 2 }); + expect(attrsOf(bytes)).toMatchObject({ spaceAfter: 8, lineHeight: '1' }); + }); +});