From 1e7d38e56191d033b23b05980c14fbd3c17b9ec9 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 11 Sep 2026 00:16:37 +0200 Subject: [PATCH] fix: a heading in a table cell keeps the spacing its file gives it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No style name reaches a cell, so the editor draws the level's own 12pt/6pt margins there — but a Word heading whose style chain sets no spacing has none, and the chain is baked into the cell's blocks, so its silence is a real 0. It is written as one now; a cell paragraph's 0 stays unset, as it was. A subtitle under a heading came out 4.2mm low, and everything under it with it. Claude-Session: https://claude.ai/code/session_01Wzs2qG6Y5qm7mKmJHfu44G --- src/lib/import/CLAUDE.md | 6 ++++++ src/lib/import/docx.ts | 17 +++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 3ea1f62..4a1a10e 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -115,6 +115,12 @@ a producer writing EMU there (360000 = 1cm in EMU, 635cm read as twips) would ot leave columns of no width. Under that width LibreOffice lays the declared gap out literally — probed to 10.16cm on a 15.24cm text — so only the unholdable value is dropped. +**A heading in a cell carries its own 0.** No style name reaches a cell, so the editor +draws the level's 12pt/6pt margins (`styles/headings.ts`) there — where a Word heading +whose style chain sets no spacing has none. The baked-in chain's silence is therefore +written as an explicit 0 (`convertParaLike`, the `kind === 'cell'` branch), while a cell +*paragraph*'s 0 stays unset as before. Missing it put every such heading 4.2mm low. + **A `w:numStyleLink` abstract carries no levels** — it defers to its numbering style's own numbering, so `DocxStyles.level()` resolves through the link (style → its `w:numPr` → the `w:styleLink` abstract); unresolved it read as `{}` and every linked list silently imported diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 302092f..30947f2 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -133,6 +133,10 @@ const BODY_FONT_SIZE_PT = 12; // Rounded to half points: that is all Word can store, so it is what our own export // writes and what an imported heading must be compared against. const HEADING_SIZES = HEADING_STYLE_OVERRIDES.map((h) => Math.round(lengthToPt(h.fontSize)! * 2) / 2); +// The vertical margins a heading renders with when the block carries none of its own. +const HEADING_MARGINS = HEADING_STYLE_OVERRIDES.map((h) => ({ + topPt: lengthToPt(h.marginTop)!, bottomPt: lengthToPt(h.marginBottom)!, +})); const HEADING_ITALIC = HEADING_STYLE_OVERRIDES.map((h) => h.italic === true); const DEFAULT_FONTS = new Set(['times new roman', 'liberation serif']); // Headings render sans (HEADING_FONT); Word writes Arial, LibreOffice Liberation Sans. @@ -1123,8 +1127,17 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: // unset spacing renders 0/0 (no word processor passes the default style's spacing // into a cell), and single line height is only kept over a non-single default. if (kind === 'cell') { - if (attrs.spaceBefore === 0) delete attrs.spaceBefore; - if (attrs.spaceAfter === 0) delete attrs.spaceAfter; + // A heading is the other way round: no style name reaches a cell, so the editor + // draws its level's own 12pt/6pt margins (styles/headings.ts) there — the chain's + // silence is a real 0 and has to be written, or that pair lands in every heading a + // cell holds. Word's own spacing at this point is the chain's, baked in above. + if (level) { + const hm = HEADING_MARGINS[level - 1]; + if (hm?.topPt) attrs.spaceBefore ??= 0; + if (hm?.bottomPt) attrs.spaceAfter ??= 0; + } + if (attrs.spaceBefore === 0 && !level) delete attrs.spaceBefore; + if (attrs.spaceAfter === 0 && !level) delete attrs.spaceAfter; const def = ctx.styles.paragraphSpacing(null); const defSingle = (def.line ?? 240) === 240 && (!def.lineRule || def.lineRule === 'auto'); if (attrs.lineHeight === '1' && defSingle) delete attrs.lineHeight;