feat: an ODF paragraph reads the contextual spacing its style declares

style:contextual-spacing drops a paragraph's own spacing towards a neighbour of
the same style; LibreOffice's List styles carry it, and only the docx path read
its w:contextualSpacing counterpart. Unread, every list point kept the 8pt its
style declares and a long document accumulated a page every dozen items.

The editor has no such mode, so the suppressed side becomes an explicit 0. In a
list each item wraps its own paragraph, so the neighbour is the next item's.
This commit is contained in:
Steffen Becker committed 2026-08-17 09:17:33 +02:00
1 parent b65920f7b7
commit b3c52e53fc
1 file changed
+25
+25
View File
@@ -1568,6 +1568,7 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
if (kind === 'cell' && !isHeading) { defaults.marginTopPt = 0; defaults.marginBottomPt = 0; }
const attrs = blockAttrs(paraProps, baseTextProps, defaults, kind);
if (paraProps['style:contextual-spacing'] === 'true') applyContextualSpacing(el, styleName, attrs);
// A style:master-page-name switches the page master, which is how ODF gives a section
// its own header/footer; the block that does it opens that section.
const master = kind === 'body' ? resolver.masterPageOf(styleName) : null;
@@ -1720,6 +1721,30 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, defaults: BlockDefau
return attrs;
}
// style:contextual-spacing drops a paragraph's own spacing towards a neighbour of the
// same style — LibreOffice's List styles carry it. The editor has no such mode, so the
// suppressed side becomes an explicit 0, as the docx path does for w:contextualSpacing.
function applyContextualSpacing(el: Element, styleName: string | null, attrs: Record<string, unknown>): void {
const same = (sib: Element | null) => !!sib && sib.getAttributeNS(NS.text, 'style-name') === styleName;
if (same(flowSibling(el, 'previous'))) attrs.spaceBefore = 0;
if (same(flowSibling(el, 'next'))) attrs.spaceAfter = 0;
}
// The paragraph before or after this one in the flow: its own sibling, or — in a list,
// where each item wraps its paragraphs — the neighbouring item's nearest one.
function flowSibling(el: Element, dir: 'previous' | 'next'): Element | null {
const isPara = (e: Element) => e.namespaceURI === NS.text && (e.localName === 'p' || e.localName === 'h');
const step = (e: Element) => (dir === 'previous' ? e.previousElementSibling : e.nextElementSibling);
for (let s = step(el); s; s = step(s)) if (isPara(s)) return s;
const item = el.parentElement;
if (!item || item.namespaceURI !== NS.text || item.localName !== 'list-item') return null;
for (let s = step(item); s; s = step(s)) {
const kids = Array.from(s.children).filter(isPara);
if (kids.length) return dir === 'previous' ? kids[kids.length - 1] : kids[0];
}
return null;
}
// Paragraph background + per-side border attrs (paragraphBox.ts) from resolved paraProps.
// Each key is omitted when absent so a paragraph with no box props stays clean. Borders:
// a per-side fo:border-* overrides the all-sides fo:border; 'none' ⇒ no border (undefined).