fix: import space after paragraph

This commit is contained in:
Steffen Becker committed 2026-07-09 00:08:19 +02:00
1 parent 1fb81c389f
commit 0e96eaab97
2 files changed
+62 -20

No files matched your search

+22 -20
View File
@@ -1,5 +1,5 @@
import { unzipSync, strFromU8 } from 'fflate';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps } from './docxStyles';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
import { lengthToPt } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, normalizeColor } from '../export/odt';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
@@ -375,7 +375,11 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault:
// paragraph style commonly carries justify), so style-level alignment isn't lost.
const directJc = fc(ppr, 'jc');
const jcVal = directJc ? wVal(directJc) : ctx.styles.paragraphAlign(pStyle ? wVal(pStyle) : null);
const attrs = blockAttrs(ppr, kind, level, jcVal);
// Spacing: a paragraph style's own w:spacing (e.g. "No Spacing" after=0) is honored so
// it isn't lost to the editor default; direct w:pPr wins per attribute (in blockAttrs).
// Headings keep their em-based editor defaults (direct-only), so skip style resolution.
const styleSpacing = level == null ? ctx.styles.paragraphSpacing(pStyle ? wVal(pStyle) : null) : {};
const attrs = blockAttrs(ppr, kind, level, jcVal, styleSpacing);
const baseRun = mergeRunProps(ctx.styles.defaultRun(), ctx.styles.styleOwn(pStyle ? wVal(pStyle) : null));
const content = convertInline(el, ctx, baseRun, level, false, boldByDefault);
@@ -426,32 +430,30 @@ function headingLevelOf(ppr: Element | null, ctx: Ctx): number | null {
return null;
}
// Spacing/indent come from DIRECT w:pPr only — Word stores per-paragraph formatting
// inline (style-level defaults stay in the style and are left as the editor's defaults).
// Alignment (jcVal) is resolved through the style chain by the caller.
function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | null, jcVal: string | null): Record<string, unknown> {
// Spacing = the paragraph style's own w:spacing (styleSpacing, resolved by the caller)
// overridden per-attribute by DIRECT w:pPr; indent comes from direct w:pPr only.
// docDefaults stay the editor's defaults. jcVal is resolved through the chain by the caller.
function blockAttrs(ppr: Element | null, kind: BlockKind, headingLevel: number | null, jcVal: string | null, styleSpacing: ParaSpacing): Record<string, unknown> {
const attrs: Record<string, unknown> = {};
if (jcVal === 'center') attrs.textAlign = 'center';
else if (jcVal === 'both' || jcVal === 'distribute') attrs.textAlign = 'justify';
else if (jcVal === 'right' || jcVal === 'end') attrs.textAlign = 'right';
if (!ppr) return attrs;
const sp = fc(ppr, 'spacing');
if (sp) {
const before = intAttr(sp, W, 'before');
const after = intAttr(sp, W, 'after');
if (before != null) attrs.spaceBefore = snapPt(twipToPt(before));
if (after != null) attrs.spaceAfter = snapPt(twipToPt(after));
const line = intAttr(sp, W, 'line');
const rule = sp.getAttributeNS(W, 'lineRule');
if (line != null && (!rule || rule === 'auto')) {
const mult = round2(line / 240);
if (Math.abs(mult - 1) > 0.01) attrs.lineHeight = String(mult);
}
const sp = ppr ? fc(ppr, 'spacing') : null;
const before = (sp ? intAttr(sp, W, 'before') : null) ?? styleSpacing.before ?? null;
const after = (sp ? intAttr(sp, W, 'after') : null) ?? styleSpacing.after ?? null;
const line = (sp ? intAttr(sp, W, 'line') : null) ?? styleSpacing.line ?? null;
const rule = (sp && sp.getAttributeNS(W, 'lineRule')) || styleSpacing.lineRule || null;
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);
}
if (!ppr) return attrs;
if (kind !== 'list') {
const ind = fc(ppr, 'ind');
const left = ind ? intAttr(ind, W, 'left') ?? intAttr(ind, W, 'start') : null;
+40
View File
@@ -31,6 +31,10 @@ export type RunProps = {
// w:rPr/w:rFonts (Wingdings/Symbol give the bullet glyph its meaning).
export type LevelDef = { numFmt?: string; lvlText?: string; leftTwip?: number; start?: number; bulletFont?: string };
// Paragraph spacing from a w:pPr/w:spacing (only the attributes actually present, so
// omitted ones inherit up the style chain). before/after in twips, line per w:line.
export type ParaSpacing = { before?: number; after?: number; line?: number; lineRule?: string };
export function wVal(el: Element): string | null {
return el.getAttributeNS(W, 'val');
}
@@ -92,6 +96,7 @@ export class DocxStyles {
private styleNum = new Map<string, { numId: number; ilvl: number }>();
private ownOutline = new Map<string, number>(); // style's own w:outlineLvl (heading marker)
private ownAlign = new Map<string, string>(); // style's own w:pPr/w:jc
private ownSpacing = new Map<string, ParaSpacing>(); // style's own w:pPr/w:spacing
private defaultParaStyle: string | null = null; // the w:default="1" paragraph style
private defaultsAlign: string | null = null; // docDefaults w:pPrDefault/w:jc
private numToAbstract = new Map<string, string>();
@@ -146,6 +151,8 @@ export class DocxStyles {
if (ol) { const n = parseInt(wVal(ol) ?? '', 10); if (Number.isFinite(n)) this.ownOutline.set(id, n); }
const jc = ppr && firstChild(ppr, 'jc');
if (jc) { const v = wVal(jc); if (v) this.ownAlign.set(id, v); }
const sp = ppr && firstChild(ppr, 'spacing');
if (sp) this.ownSpacing.set(id, readSpacing(sp));
}
}
@@ -229,6 +236,23 @@ export class DocxStyles {
return this.defaultsAlign;
}
// The style's effective spacing along the w:basedOn chain (root → leaf, leaf wins per
// attribute). Excludes docDefaults on purpose: those stay the editor's own defaults.
private styleSpacing(styleId: string | null | undefined, seen = new Set<string>()): ParaSpacing {
if (!styleId || seen.has(styleId)) return {};
seen.add(styleId);
const base = this.styleSpacing(this.basedOn.get(styleId) ?? null, seen);
return { ...base, ...(this.ownSpacing.get(styleId) ?? {}) };
}
// Effective paragraph spacing from styles only (direct w:pPr/w:spacing wins in the
// caller): the pStyle's basedOn chain, else the default paragraph style. Honors an
// explicit style like "No Spacing" (after=0) that the editor default would otherwise
// override. docDefaults are deliberately left as the editor's defaults.
paragraphSpacing(pStyleId: string | null | undefined): ParaSpacing {
return this.styleSpacing(pStyleId ?? this.defaultParaStyle);
}
level(numId: number, ilvl: number): LevelDef {
const abs = this.numToAbstract.get(String(numId));
if (!abs) return {};
@@ -241,6 +265,22 @@ function firstChild(el: Element, localName: string): Element | null {
return null;
}
// A w:spacing element's present attributes only (omitted ones inherit up the chain).
export function readSpacing(sp: Element): ParaSpacing {
const num = (name: string) => {
const v = sp.getAttributeNS(W, name);
if (v == null) return undefined;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : undefined;
};
const out: ParaSpacing = {};
const before = num('before'); if (before != null) out.before = before;
const after = num('after'); if (after != null) out.after = after;
const line = num('line'); if (line != null) out.line = line;
const rule = sp.getAttributeNS(W, 'lineRule'); if (rule) out.lineRule = rule;
return out;
}
export function readNumPr(numPr: Element): { numId: number; ilvl: number } | null {
const idEl = firstChild(numPr, 'numId');
if (!idEl) return null;