diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index fd240bf..052107a 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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 { +// 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 { const attrs: Record = {}; 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; diff --git a/src/lib/import/docxStyles.ts b/src/lib/import/docxStyles.ts index 0e08e57..31175af 100644 --- a/src/lib/import/docxStyles.ts +++ b/src/lib/import/docxStyles.ts @@ -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(); private ownOutline = new Map(); // style's own w:outlineLvl (heading marker) private ownAlign = new Map(); // style's own w:pPr/w:jc + private ownSpacing = new Map(); // 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(); @@ -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()): 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;