From fb51d866d81e389b5278d3c2304eac6769e4ee7b Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 29 Jan 2026 10:56:00 -0300 Subject: [PATCH 1/5] fix: paragraph auto spacing computation --- packages/layout-engine/contracts/src/index.ts | 2 +- .../src/attributes/paragraph.test.ts | 2 +- .../src/attributes/spacing-indent.test.ts | 59 ++++++++++++++----- .../src/attributes/spacing-indent.ts | 51 ++++++++++------ .../pm-adapter/src/index.test.ts | 2 +- 5 files changed, 80 insertions(+), 36 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index fc0e485a6d..95c3afad8b 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -945,7 +945,7 @@ export type ExclusionZone = { export type ParagraphSpacing = { before?: number; after?: number; - line?: number; + line?: number; // multiple of font size lineRule?: 'auto' | 'exact' | 'atLeast'; beforeAutospacing?: boolean; afterAutospacing?: boolean; diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts index 99a171afb5..faf24f7255 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -96,7 +96,7 @@ describe('computeParagraphAttrs', () => { attrs: { paragraphProperties: { justification: 'center', - spacing: { before: 240, after: 120, line: 2, lineRule: 'auto' }, + spacing: { before: 240, after: 120, line: 2, lineRule: 'exact' }, indent: { left: 720, hanging: 360 }, tabStops: [{ val: 'left', pos: 48 }], }, diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts index 2a2a60991a..9915ac65b6 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts @@ -30,49 +30,78 @@ const getIndent = (indent: ParagraphIndent | null | undefined) => { describe('normalizeParagraphSpacing', () => { it('converts before/after from twips to px', () => { const spacing = { before: 240, after: 360 } as ParagraphSpacing; // 16px, 24px - const result = normalizeParagraphSpacing(spacing); + const result = normalizeParagraphSpacing(spacing, false); expect(result?.before).toBe(twipsToPx(240)); expect(result?.after).toBe(twipsToPx(360)); }); - it('converts line from twips to px when lineRule is exact', () => { + it('converts line from twips to multiplier when lineRule is exact', () => { const spacing = { line: 360, lineRule: 'exact' as const } as ParagraphSpacing; // 24px - const result = normalizeParagraphSpacing(spacing); - expect(result?.line).toBe(twipsToPx(360)); + const result = normalizeParagraphSpacing(spacing, false); + expect(result?.line).toBe(1.5); expect(result?.lineRule).toBe('exact'); }); it('treats auto line values <= 10 as multipliers', () => { - const spacing = { line: 1.15, lineRule: 'auto' as const } as ParagraphSpacing; - const result = normalizeParagraphSpacing(spacing); + const spacing = { line: 1.15, lineRule: 'exact' as const } as ParagraphSpacing; + const result = normalizeParagraphSpacing(spacing, false); expect(result?.line).toBe(1.15); - expect(result?.lineRule).toBe('auto'); + expect(result?.lineRule).toBe('exact'); }); it('converts auto line values > 10 from 240ths of a line', () => { const spacing = { line: 360, lineRule: 'auto' as const } as ParagraphSpacing; // 1.5x - const result = normalizeParagraphSpacing(spacing); - expect(result?.line).toBe(1.5); + const result = normalizeParagraphSpacing(spacing, false); + expect(result?.line).toBeCloseTo(1.725, 5); expect(result?.lineRule).toBe('auto'); }); it('preserves contextual spacing flags', () => { const spacing = { before: 240, beforeAutospacing: true, afterAutospacing: false } as ParagraphSpacing; - const result = normalizeParagraphSpacing(spacing); - expect(result?.before).toBe(twipsToPx(240)); + const result = normalizeParagraphSpacing(spacing, false); + expect(result?.before).toBeCloseTo(twipsToPx(276), 5); expect(result?.beforeAutospacing).toBe(true); expect(result?.afterAutospacing).toBe(false); }); + it('uses default line value for auto spacing when line is missing', () => { + const spacing = { beforeAutospacing: true, afterAutospacing: true } as ParagraphSpacing; + const result = normalizeParagraphSpacing(spacing, false); + expect(result?.before).toBeCloseTo(twipsToPx(276), 5); + expect(result?.after).toBeCloseTo(twipsToPx(276), 5); + }); + + it('drops auto spacing values for lists', () => { + const spacing = { beforeAutospacing: true, afterAutospacing: true, line: 360 } as ParagraphSpacing; + const result = normalizeParagraphSpacing(spacing, true); + expect(result?.before).toBeUndefined(); + expect(result?.after).toBeUndefined(); + expect(result?.beforeAutospacing).toBe(true); + expect(result?.afterAutospacing).toBe(true); + }); + + it('treats auto line values <= 10 as raw multipliers', () => { + const spacing = { line: 2, lineRule: 'exact' as const } as ParagraphSpacing; + const result = normalizeParagraphSpacing(spacing, false); + expect(result?.line).toBe(2); + }); + + it('converts line to multiplier when lineRule is missing', () => { + const spacing = { line: 360 } as ParagraphSpacing; + const result = normalizeParagraphSpacing(spacing, false); + expect(result?.line).toBe(1.5); + expect(result?.lineRule).toBeUndefined(); + }); + it('returns undefined for empty or invalid inputs', () => { - expect(normalizeParagraphSpacing(undefined)).toBeUndefined(); - expect(normalizeParagraphSpacing(null as never)).toBeUndefined(); - expect(normalizeParagraphSpacing({} as ParagraphSpacing)).toBeUndefined(); + expect(normalizeParagraphSpacing(undefined, false)).toBeUndefined(); + expect(normalizeParagraphSpacing(null as never, false)).toBeUndefined(); + expect(normalizeParagraphSpacing({} as ParagraphSpacing, false)).toEqual({ line: 1.15 }); }); it('skips non-numeric values but preserves valid ones', () => { const spacing = { before: 'not-a-number', after: 300 } as unknown as ParagraphSpacing; - const result = normalizeParagraphSpacing(spacing); + const result = normalizeParagraphSpacing(spacing, false); expect(result?.before).toBeUndefined(); expect(result?.after).toBe(twipsToPx(300)); }); diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts index 81ee80b1ea..cfa9da9357 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts @@ -10,7 +10,7 @@ import type { ParagraphSpacing as OoxmlParagraphSpacing } from '@superdoc/style- import { twipsToPx, pickNumber } from '../utilities.js'; /** - * Maximum line spacing multiplier for auto line spacing. + * Minimum threshold for distinguishing OOXML auto line spacing multipliers * * OOXML auto line spacing uses multipliers (e.g., 1.5 for 1.5x line spacing). * Values above this threshold are assumed to be OOXML "240ths of a line" values. @@ -18,7 +18,11 @@ import { twipsToPx, pickNumber } from '../utilities.js'; * Rationale: Typical multipliers are 1.0-3.0. The minimum meaningful twips * value for line spacing is ~240 (12pt font), so 10 provides a safe boundary. */ -const MAX_AUTO_LINE_MULTIPLIER = 10; +const MIN_AUTO_LINE_TWIPS = 10; + +const AUTO_SPACING_DEFAULT_MULTIPLIER = 1.15; + +const AUTO_SPACING_LINE_DEFAULT = 240; // Default OOXML auto line spacing in twips /** * Threshold for distinguishing pixel values from twips in indent values. @@ -108,16 +112,23 @@ export const normalizeParagraphSpacing = ( const lineRule = normalizeLineRule(value.lineRule); const beforeAutospacing = value.beforeAutospacing; const afterAutospacing = value.afterAutospacing; + const line = normalizeLineValue(lineRaw, lineRule); - if (beforeAutospacing && isList) { - before = undefined; + if (beforeAutospacing) { + if (isList) { + before = undefined; + } else { + before = (lineRaw ?? AUTO_SPACING_LINE_DEFAULT) * AUTO_SPACING_DEFAULT_MULTIPLIER; + } } - if (afterAutospacing && isList) { - after = undefined; + if (afterAutospacing) { + if (isList) { + after = undefined; + } else { + after = (lineRaw ?? AUTO_SPACING_LINE_DEFAULT) * AUTO_SPACING_DEFAULT_MULTIPLIER; + } } - const line = normalizeLineValue(lineRaw, lineRule); - if (before != null) spacing.before = twipsToPx(before); if (after != null) spacing.after = twipsToPx(after); if (line != null) spacing.line = line; @@ -128,18 +139,22 @@ export const normalizeParagraphSpacing = ( return Object.keys(spacing).length > 0 ? spacing : undefined; }; -const normalizeLineValue = ( - value: number | undefined, - lineRule: ParagraphSpacing['lineRule'] | undefined, -): number | undefined => { - if (value == null) return undefined; +/** + * Normalizes line spacing value based on line rule. + * Converts OOXML line spacing values to a multiplier of font size. + * @param value - OOXML line spacing value in twips + * @param lineRule - Line rule ('auto', 'exact', 'atLeast') + * @returns Normalized line spacing value as a multiplier, or undefined + */ +const normalizeLineValue = (value: number | undefined, lineRule: ParagraphSpacing['lineRule'] | undefined): number => { + if (value == null) return AUTO_SPACING_DEFAULT_MULTIPLIER; + if (value > 0 && value <= MIN_AUTO_LINE_TWIPS) { + value = value * AUTO_SPACING_LINE_DEFAULT; + } if (lineRule === 'auto') { - if (value > 0 && value <= MAX_AUTO_LINE_MULTIPLIER) { - return value; - } - return value / 240; + return (value * AUTO_SPACING_DEFAULT_MULTIPLIER) / AUTO_SPACING_LINE_DEFAULT; } - return twipsToPx(value); + return value / AUTO_SPACING_LINE_DEFAULT; }; /** diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index 26e1e8a4d3..f20405092d 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -325,7 +325,7 @@ describe('toFlowBlocks', () => { expect(blocks[0].attrs).toMatchObject({ alignment: 'center', - spacing: { before: 10, after: 6, line: 22, lineRule: 'exact' }, + spacing: { before: 10, after: 6, line: 1.375, lineRule: 'exact' }, indent: { left: 12, firstLine: 24 }, }); }); From 90fab31780af975cc07fe42530425bc5d61bebe7 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 29 Jan 2026 14:36:22 -0300 Subject: [PATCH 2/5] fix: measurement of line height --- .../layout-engine/measuring/dom/src/index.ts | 44 +++++-------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 83d131bacf..71d6730e59 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -391,14 +391,7 @@ function calculateTypographyMetrics( descent = roundValue(resolvedFontSize * 0.2); } - // Calculate base line height using Word's default 1.15 line spacing multiplier. - // Word 2007+ uses 1.15× font size as "single" line spacing, not just ascent+descent. - // The Canvas TextMetrics API doesn't expose lineGap, so we use this multiplier. - // For 12pt (16px) font: 16 * 1.15 = 18.4px - matches Word exactly. - // Also clamp to actual glyph bounds (ascent + descent) to prevent overlap/clipping - // for fonts with unusually tall glyphs that exceed the 1.15 multiplier. - const baseLineHeight = Math.max(resolvedFontSize * WORD_SINGLE_LINE_SPACING_MULTIPLIER, ascent + descent); - const lineHeight = roundValue(resolveLineHeight(spacing, baseLineHeight)); + const lineHeight = resolveLineHeight(spacing, fontSize, ascent + descent); return { ascent, @@ -456,8 +449,8 @@ function calculateEmptyParagraphMetrics( } // Word treats empty paragraphs as a single font-sized line unless line spacing is explicitly set. - const baseLineHeight = Math.max(resolvedFontSize, ascent + descent); - const lineHeight = roundValue(resolveLineHeight(spacing, baseLineHeight)); + const maxLineHeight = Math.max(resolvedFontSize, ascent + descent); + const lineHeight = roundValue(resolveLineHeight(spacing, resolvedFontSize, maxLineHeight)); return { ascent, @@ -3268,28 +3261,13 @@ const appendSegment = ( segments.push({ runIndex, fromChar, toChar, width, x }); }; -const resolveLineHeight = (spacing: ParagraphSpacing | undefined, baseLineHeight: number): number => { - if (!spacing || spacing.line == null || spacing.line <= 0) { - return baseLineHeight; +const resolveLineHeight = (spacing: ParagraphSpacing | undefined, fontSize: number, maxHeight: number = -1): number => { + const computedHeight = fontSize * (spacing?.line ?? WORD_SINGLE_LINE_SPACING_MULTIPLIER); + const lineRule = spacing?.lineRule ?? 'auto'; + if (['atLeast', 'auto'].includes(lineRule)) { + return Math.max(computedHeight, maxHeight, WORD_SINGLE_LINE_SPACING_MULTIPLIER * fontSize); } - - const raw = spacing.line; - const isAuto = spacing.lineRule === 'auto'; - const treatAsMultiplier = (isAuto || spacing.lineRule == null) && raw > 0 && (isAuto || raw <= 10); - - if (treatAsMultiplier) { - return raw * baseLineHeight; - } - - if (spacing.lineRule === 'exact') { - return raw; - } - - if (spacing.lineRule === 'atLeast') { - return Math.max(baseLineHeight, raw); - } - - return Math.max(baseLineHeight, raw); + return computedHeight; }; const sanitizePositive = (value: number | undefined): number => @@ -3356,8 +3334,8 @@ const measureDropCap = ( // Calculate height based on the number of lines the drop cap should span // This uses the base line height calculation from the paragraph's spacing - const baseLineHeight = resolveLineHeight(spacing, run.fontSize * WORD_SINGLE_LINE_SPACING_MULTIPLIER); - const height = roundValue(baseLineHeight * lines); + const lineHeight = resolveLineHeight(spacing, run.fontSize); + const height = roundValue(lineHeight * lines); return { width, From dcd46d37e34ca28348f5993cafbe1d209759e27c Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Thu, 29 Jan 2026 17:05:28 -0300 Subject: [PATCH 3/5] fix: line height computation in tables --- .../pm-adapter/src/attributes/spacing-indent.ts | 5 ++++- .../pm-adapter/src/converters/table-styles.test.ts | 4 ++-- .../pm-adapter/src/converters/table-styles.ts | 10 ++-------- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts index cfa9da9357..60d8fe4859 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts @@ -146,7 +146,10 @@ export const normalizeParagraphSpacing = ( * @param lineRule - Line rule ('auto', 'exact', 'atLeast') * @returns Normalized line spacing value as a multiplier, or undefined */ -const normalizeLineValue = (value: number | undefined, lineRule: ParagraphSpacing['lineRule'] | undefined): number => { +export const normalizeLineValue = ( + value: number | undefined, + lineRule: ParagraphSpacing['lineRule'] | undefined, +): number => { if (value == null) return AUTO_SPACING_DEFAULT_MULTIPLIER; if (value > 0 && value <= MIN_AUTO_LINE_TWIPS) { value = value * AUTO_SPACING_LINE_DEFAULT; diff --git a/packages/layout-engine/pm-adapter/src/converters/table-styles.test.ts b/packages/layout-engine/pm-adapter/src/converters/table-styles.test.ts index a175699666..bd8f58fea9 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table-styles.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table-styles.test.ts @@ -98,7 +98,7 @@ describe('hydrateTableStyleAttrs', () => { expect(result?.paragraphProps?.spacing?.before).toBeCloseTo((120 / 1440) * 96); expect(result?.paragraphProps?.spacing?.after).toBeCloseTo((240 / 1440) * 96); // For 'auto' lineRule, line is in 240ths: 276/240 = 1.15 - expect(result?.paragraphProps?.spacing?.line).toBeCloseTo(1.15); + expect(result?.paragraphProps?.spacing?.line).toBeCloseTo(1.3225); expect(result?.paragraphProps?.spacing?.lineRule).toBe('auto'); }); @@ -184,7 +184,7 @@ describe('hydrateTableStyleAttrs', () => { const resultExact = hydrateTableStyleAttrs(tableExact, { docx: mockDocxExact }); // For 'exact' lineRule, use twipsToPx: (240/1440)*96 = 16 - expect(resultExact?.paragraphProps?.spacing?.line).toBeCloseTo(16); + expect(resultExact?.paragraphProps?.spacing?.line).toBeCloseTo(1); expect(resultExact?.paragraphProps?.spacing?.lineRule).toBe('exact'); const mockDocxAtLeast = { diff --git a/packages/layout-engine/pm-adapter/src/converters/table-styles.ts b/packages/layout-engine/pm-adapter/src/converters/table-styles.ts index d3b3b15c9f..74f7d3774e 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table-styles.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table-styles.ts @@ -4,6 +4,7 @@ import type { PMNode } from '../types.js'; import type { ConverterContext, TableStyleParagraphProps } from '../converter-context.js'; import { hasTableStyleContext } from '../converter-context.js'; import { twipsToPx } from '../utilities.js'; +import { normalizeLineValue } from '../attributes/spacing-indent.js'; export type TableStyleHydration = { borders?: Record; @@ -193,14 +194,7 @@ const extractTableStyleParagraphProps = ( if (before != null) spacing.before = twipsToPx(before); if (after != null) spacing.after = twipsToPx(after); if (line != null) { - // For 'auto' line rule, value is in 240ths of a line (not twips) - // e.g., 240 = single spacing, 480 = double spacing - if (lineRule === 'auto') { - // Convert to multiplier: 240 → 1.0, 276 → 1.15, etc. - spacing.line = line / 240; - } else { - spacing.line = twipsToPx(line); - } + spacing.line = normalizeLineValue(line, lineRule); } if (lineRule) spacing.lineRule = lineRule; From bdde8e5553199c3d4204a018c0d2efc14935af11 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Fri, 30 Jan 2026 10:24:12 -0300 Subject: [PATCH 4/5] fix: explicitly state unit used for line height --- packages/layout-engine/contracts/src/index.ts | 3 +- .../layout-engine/measuring/dom/src/index.ts | 6 +++- .../src/attributes/paragraph.test.ts | 6 ++-- .../src/attributes/spacing-indent.test.ts | 19 ++----------- .../src/attributes/spacing-indent.ts | 28 ++++++------------- .../src/converters/table-styles.test.ts | 3 +- .../pm-adapter/src/converters/table-styles.ts | 4 ++- .../pm-adapter/src/index.test.ts | 2 +- 8 files changed, 29 insertions(+), 42 deletions(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index 95c3afad8b..eaebd6444c 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -945,7 +945,8 @@ export type ExclusionZone = { export type ParagraphSpacing = { before?: number; after?: number; - line?: number; // multiple of font size + line?: number; + lineUnit?: 'px' | 'multiplier'; lineRule?: 'auto' | 'exact' | 'atLeast'; beforeAutospacing?: boolean; afterAutospacing?: boolean; diff --git a/packages/layout-engine/measuring/dom/src/index.ts b/packages/layout-engine/measuring/dom/src/index.ts index 71d6730e59..20f39c3daf 100644 --- a/packages/layout-engine/measuring/dom/src/index.ts +++ b/packages/layout-engine/measuring/dom/src/index.ts @@ -3262,7 +3262,11 @@ const appendSegment = ( }; const resolveLineHeight = (spacing: ParagraphSpacing | undefined, fontSize: number, maxHeight: number = -1): number => { - const computedHeight = fontSize * (spacing?.line ?? WORD_SINGLE_LINE_SPACING_MULTIPLIER); + let computedHeight = spacing?.line ?? WORD_SINGLE_LINE_SPACING_MULTIPLIER; + if (spacing?.lineUnit === 'multiplier') { + computedHeight = computedHeight * fontSize; + } + const lineRule = spacing?.lineRule ?? 'auto'; if (['atLeast', 'auto'].includes(lineRule)) { return Math.max(computedHeight, maxHeight, WORD_SINGLE_LINE_SPACING_MULTIPLIER * fontSize); diff --git a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts index faf24f7255..bd445430d5 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/paragraph.test.ts @@ -96,7 +96,7 @@ describe('computeParagraphAttrs', () => { attrs: { paragraphProperties: { justification: 'center', - spacing: { before: 240, after: 120, line: 2, lineRule: 'exact' }, + spacing: { before: 240, after: 120, line: 210, lineRule: 'exact' }, indent: { left: 720, hanging: 360 }, tabStops: [{ val: 'left', pos: 48 }], }, @@ -108,7 +108,9 @@ describe('computeParagraphAttrs', () => { expect(paragraphAttrs.alignment).toBe('center'); expect(paragraphAttrs.spacing?.before).toBe(twipsToPx(240)); expect(paragraphAttrs.spacing?.after).toBe(twipsToPx(120)); - expect(paragraphAttrs.spacing?.line).toBe(2); + expect(paragraphAttrs.spacing?.line).toBe(twipsToPx(210)); + expect(paragraphAttrs.spacing?.lineRule).toBe('exact'); + expect(paragraphAttrs.spacing?.lineUnit).toBe('px'); expect(paragraphAttrs.indent?.left).toBe(twipsToPx(720)); expect(paragraphAttrs.indent?.hanging).toBe(twipsToPx(360)); expect(paragraphAttrs.tabs?.[0]).toEqual({ val: 'start', pos: 720 }); diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts index 9915ac65b6..629e405e11 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.test.ts @@ -35,17 +35,10 @@ describe('normalizeParagraphSpacing', () => { expect(result?.after).toBe(twipsToPx(360)); }); - it('converts line from twips to multiplier when lineRule is exact', () => { + it('converts line from twips to pixels when lineRule is exact', () => { const spacing = { line: 360, lineRule: 'exact' as const } as ParagraphSpacing; // 24px const result = normalizeParagraphSpacing(spacing, false); - expect(result?.line).toBe(1.5); - expect(result?.lineRule).toBe('exact'); - }); - - it('treats auto line values <= 10 as multipliers', () => { - const spacing = { line: 1.15, lineRule: 'exact' as const } as ParagraphSpacing; - const result = normalizeParagraphSpacing(spacing, false); - expect(result?.line).toBe(1.15); + expect(result?.line).toBeCloseTo(24); expect(result?.lineRule).toBe('exact'); }); @@ -80,12 +73,6 @@ describe('normalizeParagraphSpacing', () => { expect(result?.afterAutospacing).toBe(true); }); - it('treats auto line values <= 10 as raw multipliers', () => { - const spacing = { line: 2, lineRule: 'exact' as const } as ParagraphSpacing; - const result = normalizeParagraphSpacing(spacing, false); - expect(result?.line).toBe(2); - }); - it('converts line to multiplier when lineRule is missing', () => { const spacing = { line: 360 } as ParagraphSpacing; const result = normalizeParagraphSpacing(spacing, false); @@ -96,7 +83,7 @@ describe('normalizeParagraphSpacing', () => { it('returns undefined for empty or invalid inputs', () => { expect(normalizeParagraphSpacing(undefined, false)).toBeUndefined(); expect(normalizeParagraphSpacing(null as never, false)).toBeUndefined(); - expect(normalizeParagraphSpacing({} as ParagraphSpacing, false)).toEqual({ line: 1.15 }); + expect(normalizeParagraphSpacing({} as ParagraphSpacing, false)).toEqual({ line: 1.15, lineUnit: 'multiplier' }); }); it('skips non-numeric values but preserves valid ones', () => { diff --git a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts index 60d8fe4859..7e4f98c9a2 100644 --- a/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts +++ b/packages/layout-engine/pm-adapter/src/attributes/spacing-indent.ts @@ -9,17 +9,6 @@ import type { ParagraphAttrs, ParagraphSpacing } from '@superdoc/contracts'; import type { ParagraphSpacing as OoxmlParagraphSpacing } from '@superdoc/style-engine/ooxml'; import { twipsToPx, pickNumber } from '../utilities.js'; -/** - * Minimum threshold for distinguishing OOXML auto line spacing multipliers - * - * OOXML auto line spacing uses multipliers (e.g., 1.5 for 1.5x line spacing). - * Values above this threshold are assumed to be OOXML "240ths of a line" values. - * - * Rationale: Typical multipliers are 1.0-3.0. The minimum meaningful twips - * value for line spacing is ~240 (12pt font), so 10 provides a safe boundary. - */ -const MIN_AUTO_LINE_TWIPS = 10; - const AUTO_SPACING_DEFAULT_MULTIPLIER = 1.15; const AUTO_SPACING_LINE_DEFAULT = 240; // Default OOXML auto line spacing in twips @@ -112,7 +101,7 @@ export const normalizeParagraphSpacing = ( const lineRule = normalizeLineRule(value.lineRule); const beforeAutospacing = value.beforeAutospacing; const afterAutospacing = value.afterAutospacing; - const line = normalizeLineValue(lineRaw, lineRule); + const { value: line, unit: lineUnit } = normalizeLineValue(lineRaw, lineRule); if (beforeAutospacing) { if (isList) { @@ -131,7 +120,8 @@ export const normalizeParagraphSpacing = ( if (before != null) spacing.before = twipsToPx(before); if (after != null) spacing.after = twipsToPx(after); - if (line != null) spacing.line = line; + spacing.line = line; + spacing.lineUnit = lineUnit; if (lineRule != null) spacing.lineRule = lineRule; if (beforeAutospacing != null) spacing.beforeAutospacing = beforeAutospacing; if (afterAutospacing != null) spacing.afterAutospacing = afterAutospacing; @@ -149,15 +139,15 @@ export const normalizeParagraphSpacing = ( export const normalizeLineValue = ( value: number | undefined, lineRule: ParagraphSpacing['lineRule'] | undefined, -): number => { - if (value == null) return AUTO_SPACING_DEFAULT_MULTIPLIER; - if (value > 0 && value <= MIN_AUTO_LINE_TWIPS) { - value = value * AUTO_SPACING_LINE_DEFAULT; +): { value: number; unit: 'multiplier' | 'px' } => { + if (value == null) return { value: AUTO_SPACING_DEFAULT_MULTIPLIER, unit: 'multiplier' }; + if (lineRule == 'exact' || lineRule == 'atLeast') { + return { value: twipsToPx(value), unit: 'px' }; } if (lineRule === 'auto') { - return (value * AUTO_SPACING_DEFAULT_MULTIPLIER) / AUTO_SPACING_LINE_DEFAULT; + return { value: (value * AUTO_SPACING_DEFAULT_MULTIPLIER) / AUTO_SPACING_LINE_DEFAULT, unit: 'multiplier' }; } - return value / AUTO_SPACING_LINE_DEFAULT; + return { value: value / AUTO_SPACING_LINE_DEFAULT, unit: 'multiplier' }; }; /** diff --git a/packages/layout-engine/pm-adapter/src/converters/table-styles.test.ts b/packages/layout-engine/pm-adapter/src/converters/table-styles.test.ts index bd8f58fea9..74c17587c9 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table-styles.test.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table-styles.test.ts @@ -184,7 +184,8 @@ describe('hydrateTableStyleAttrs', () => { const resultExact = hydrateTableStyleAttrs(tableExact, { docx: mockDocxExact }); // For 'exact' lineRule, use twipsToPx: (240/1440)*96 = 16 - expect(resultExact?.paragraphProps?.spacing?.line).toBeCloseTo(1); + expect(resultExact?.paragraphProps?.spacing?.line).toBeCloseTo(16); + expect(resultExact?.paragraphProps?.spacing?.lineUnit).toBe('px'); expect(resultExact?.paragraphProps?.spacing?.lineRule).toBe('exact'); const mockDocxAtLeast = { diff --git a/packages/layout-engine/pm-adapter/src/converters/table-styles.ts b/packages/layout-engine/pm-adapter/src/converters/table-styles.ts index 74f7d3774e..158b9bc4d7 100644 --- a/packages/layout-engine/pm-adapter/src/converters/table-styles.ts +++ b/packages/layout-engine/pm-adapter/src/converters/table-styles.ts @@ -194,7 +194,9 @@ const extractTableStyleParagraphProps = ( if (before != null) spacing.before = twipsToPx(before); if (after != null) spacing.after = twipsToPx(after); if (line != null) { - spacing.line = normalizeLineValue(line, lineRule); + const { value: normalizedLine, unit: lineUnit } = normalizeLineValue(line, lineRule); + spacing.line = normalizedLine; + spacing.lineUnit = lineUnit; } if (lineRule) spacing.lineRule = lineRule; diff --git a/packages/layout-engine/pm-adapter/src/index.test.ts b/packages/layout-engine/pm-adapter/src/index.test.ts index f20405092d..ce3d536f03 100644 --- a/packages/layout-engine/pm-adapter/src/index.test.ts +++ b/packages/layout-engine/pm-adapter/src/index.test.ts @@ -325,7 +325,7 @@ describe('toFlowBlocks', () => { expect(blocks[0].attrs).toMatchObject({ alignment: 'center', - spacing: { before: 10, after: 6, line: 1.375, lineRule: 'exact' }, + spacing: { before: 10, after: 6, line: 22, lineUnit: 'px', lineRule: 'exact' }, indent: { left: 12, firstLine: 24 }, }); }); From f02d56bfdd77dc89a118c51ef5a1fc15a479086a Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Fri, 30 Jan 2026 11:13:45 -0800 Subject: [PATCH 5/5] chore: add missing lineUnit property to ParagraphSpacing interface --- packages/layout-engine/contracts/src/engines/paragraph.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/engines/paragraph.ts b/packages/layout-engine/contracts/src/engines/paragraph.ts index 936fc36561..7ee61a2d6c 100644 --- a/packages/layout-engine/contracts/src/engines/paragraph.ts +++ b/packages/layout-engine/contracts/src/engines/paragraph.ts @@ -8,7 +8,8 @@ export interface ParagraphSpacing { before: number; // pt after: number; // pt - line: number; // pt or multiplier (depends on lineRule) + line: number; // pt or multiplier (depends on lineUnit) + lineUnit?: 'px' | 'multiplier'; // unit for line spacing value lineRule: 'auto' | 'exact' | 'atLeast'; }