fix: a heading in a table cell keeps the spacing its file gives it

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
This commit is contained in:
Steffen Becker committed 2026-09-11 09:10:28 +02:00
1 parent b233f0e160
commit 1e7d38e561
2 files changed
+21 -2

No files matched your search

+6
View File
@@ -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
+15 -2
View File
@@ -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;