From 3344d20481dc86fdb4241112c2999dd136700a81 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 7 Aug 2026 22:27:06 +0000 Subject: [PATCH] fix: apply a table style's paragraph spacing to its cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word's Table Grid — the style nearly every real table names — sets space after 0 and single line spacing in its own w:pPr, which is why a table's rows are line-tight. The cell paragraphs were taking the document default instead. An explicit single spacing is now written out as well: it only used to be dropped as redundant, but the block's own style may set another one. --- src/lib/import/docx.ts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 78d1343..ad6f15a 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -45,6 +45,8 @@ type Ctx = { listCounters: Map>; // numId → ilvl → last number used // Text width (cm) of the file's page setup; a table's margins are relative to it. contentWidthCm: number; + // The enclosing table style's w:pPr/w:spacing, applied to its cells' paragraphs. + cellSpacing: ParaSpacing; }; // ---- units & editor defaults to suppress ----------------------------------- @@ -123,7 +125,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = if (!body) throw new Error('Not a Word document (no w:body).'); const contentWidthCm = sectionContentWidthCm(fc(body, 'sectPr')); - const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm }; + const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, cellSpacing: {} }; // Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares // more than one column becomes a columns node (the trailing group is described @@ -682,8 +684,9 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: 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. - const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null, {}); + // 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); applyContextualSpacing(el, ppr, ctx, styleId, attrs); // Widow-orphan control has no registry home, so the resolved value rides the block. @@ -783,10 +786,9 @@ function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | // default (editor.css) — so it stays unset rather than accreting an explicit 0. if (before != null) attrs.spaceBefore = snapPt(twipToPt(before)); if (after != null) attrs.spaceAfter = snapPt(twipToPt(after)); - if (line != null && (!rule || rule === 'auto')) { - const mult = round2(line / 240); - if (Math.abs(mult - 1) > 0.01) attrs.lineHeight = String(mult); - } + // Single spacing is written out too: the block's own style may set another one, and + // then an unset attr is not the same thing. + if (line != null && (!rule || rule === 'auto')) attrs.lineHeight = String(round2(line / 240)); if (!ppr) return attrs; @@ -1282,6 +1284,20 @@ function docxBorderAttr(el: Element | null): string | null | undefined { } 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. + 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); + try { + return buildTable(tbl, ctx); + } finally { + ctx.cellSpacing = outerSpacing; + } +} + +function buildTable(tbl: Element, ctx: Ctx): Node | null { const grid = fc(tbl, 'tblGrid'); const weights = grid ? fcAll(grid, 'gridCol').map((g) => Math.max(1, intAttr(g, W, 'w') ?? 1)) : null; const useWeights = weights && weights.length ? weights : null;