From 9f6736d5a8df6413329a429eb5212f9028077ade Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 7 Aug 2026 22:26:28 +0000 Subject: [PATCH] fix: honour w:contextualSpacing on import Word's List Paragraph carries it, so consecutive list points sit line-tight. Without it every one of them took the document default's 8pt after, and a document that is mostly bullets drifted more than a page. --- src/lib/import/docx.ts | 13 +++++++++++++ src/lib/import/docxStyles.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 21d4cd8..78d1343 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -685,6 +685,7 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: // w:pPr counts as formatting on the block. const attrs = blockAttrs(ppr, kind, level, directJc ? jcVal : null, {}); 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. const directWc = fc(ppr, 'widowControl'); if (!(directWc ? onOff(directWc) : ctx.styles.paragraphWidowControl(styleId))) { @@ -719,6 +720,18 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: return node; } +// w:contextualSpacing drops a paragraph's own spacing towards a neighbour of the same +// style — Word's List Paragraph carries it, which is why list points sit line-tight. The +// editor has no such mode, so the suppressed side becomes an explicit 0. +function applyContextualSpacing(el: Element, ppr: Element | null, ctx: Ctx, styleId: string | null, attrs: Record): void { + const direct = fc(ppr, 'contextualSpacing'); + if (!(direct ? onOff(direct) : ctx.styles.paragraphContextualSpacing(styleId))) return; + const sameStyle = (sib: Element | null) => + !!sib && sib.namespaceURI === W && sib.localName === 'p' && styleIdOf(fc(sib, 'pPr'), ctx) === styleId; + if (sameStyle(el.previousElementSibling)) attrs.spaceBefore = 0; + if (sameStyle(el.nextElementSibling)) attrs.spaceAfter = 0; +} + // The paragraph mark's resolved font size (w:pPr/w:rPr, incl. its rStyle), as a CSS // pt string, or null when it matches what the block renders at anyway (suppressed // like run sizes, against the same yardstick). diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index 2ddb056..d5abdd7 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -124,6 +124,7 @@ export class DocxStyles { private defaultsAlign: string | null = null; // docDefaults w:pPrDefault/w:jc private defaultsSpacing: ParaSpacing = {}; // docDefaults w:pPrDefault/w:spacing private ownWidow = new Map(); // style's own w:pPr/w:widowControl + private ownContextual = new Map(); // style's own w:pPr/w:contextualSpacing private ownTabs = new Map(); // style's own w:pPr/w:tabs private defaultsWidow: boolean | null = null; // docDefaults w:pPrDefault/w:widowControl private numToAbstract = new Map(); @@ -186,6 +187,8 @@ export class DocxStyles { if (sp) this.ownSpacing.set(id, readSpacing(sp)); const wc = ppr && firstChild(ppr, 'widowControl'); if (wc) this.ownWidow.set(id, toggle(wc)); + const cs = ppr && firstChild(ppr, 'contextualSpacing'); + if (cs) this.ownContextual.set(id, toggle(cs)); const tabs = ppr && firstChild(ppr, 'tabs'); if (tabs) this.ownTabs.set(id, readTabStops(tabs)); const ind = ppr && firstChild(ppr, 'ind'); @@ -329,6 +332,16 @@ export class DocxStyles { return this.paragraphTabs(this.basedOn.get(styleId) ?? null, seen); } + // w:contextualSpacing along the w:basedOn chain: the style drops its own spacing + // between neighbouring paragraphs that share it (Word's List Paragraph does). + paragraphContextualSpacing(styleId: string | null | undefined, seen = new Set()): boolean { + if (!styleId || seen.has(styleId)) return false; + seen.add(styleId); + const own = this.ownContextual.get(styleId); + if (own != null) return own; + return this.paragraphContextualSpacing(this.basedOn.get(styleId) ?? null, seen); + } + private styleWidow(styleId: string | null | undefined, seen = new Set()): boolean | null { if (!styleId || seen.has(styleId)) return null; seen.add(styleId);