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'; } diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index fc0e485a6d..eaebd6444c 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -946,6 +946,7 @@ export type ParagraphSpacing = { before?: number; after?: number; 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 83d131bacf..20f39c3daf 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,17 @@ 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 => { + let computedHeight = spacing?.line ?? WORD_SINGLE_LINE_SPACING_MULTIPLIER; + if (spacing?.lineUnit === 'multiplier') { + computedHeight = computedHeight * 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; + const lineRule = spacing?.lineRule ?? 'auto'; + if (['atLeast', 'auto'].includes(lineRule)) { + return Math.max(computedHeight, maxHeight, WORD_SINGLE_LINE_SPACING_MULTIPLIER * fontSize); } - - if (spacing.lineRule === 'atLeast') { - return Math.max(baseLineHeight, raw); - } - - return Math.max(baseLineHeight, raw); + return computedHeight; }; const sanitizePositive = (value: number | undefined): number => @@ -3356,8 +3338,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, 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..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: 'auto' }, + 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 2a2a60991a..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 @@ -30,49 +30,65 @@ 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 pixels 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).toBeCloseTo(24); 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); - expect(result?.line).toBe(1.15); - expect(result?.lineRule).toBe('auto'); - }); - 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('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, lineUnit: 'multiplier' }); }); 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..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,16 +9,9 @@ import type { ParagraphAttrs, ParagraphSpacing } from '@superdoc/contracts'; import type { ParagraphSpacing as OoxmlParagraphSpacing } from '@superdoc/style-engine/ooxml'; import { twipsToPx, pickNumber } from '../utilities.js'; -/** - * Maximum line spacing multiplier for auto line spacing. - * - * 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 MAX_AUTO_LINE_MULTIPLIER = 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,19 +101,27 @@ export const normalizeParagraphSpacing = ( const lineRule = normalizeLineRule(value.lineRule); const beforeAutospacing = value.beforeAutospacing; const afterAutospacing = value.afterAutospacing; + const { value: line, unit: lineUnit } = 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; + spacing.line = line; + spacing.lineUnit = lineUnit; if (lineRule != null) spacing.lineRule = lineRule; if (beforeAutospacing != null) spacing.beforeAutospacing = beforeAutospacing; if (afterAutospacing != null) spacing.afterAutospacing = afterAutospacing; @@ -128,18 +129,25 @@ export const normalizeParagraphSpacing = ( return Object.keys(spacing).length > 0 ? spacing : undefined; }; -const normalizeLineValue = ( +/** + * 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 + */ +export const normalizeLineValue = ( value: number | undefined, lineRule: ParagraphSpacing['lineRule'] | undefined, -): number | undefined => { - if (value == null) return undefined; +): { 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') { - if (value > 0 && value <= MAX_AUTO_LINE_MULTIPLIER) { - return value; - } - return value / 240; + return { value: (value * AUTO_SPACING_DEFAULT_MULTIPLIER) / AUTO_SPACING_LINE_DEFAULT, unit: 'multiplier' }; } - return twipsToPx(value); + 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 a175699666..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 @@ -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'); }); @@ -185,6 +185,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?.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 d3b3b15c9f..158b9bc4d7 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,9 @@ 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); - } + 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 26e1e8a4d3..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: 22, lineRule: 'exact' }, + spacing: { before: 10, after: 6, line: 22, lineUnit: 'px', lineRule: 'exact' }, indent: { left: 12, firstLine: 24 }, }); });