Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/layout-engine/contracts/src/engines/paragraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
1 change: 1 addition & 0 deletions packages/layout-engine/contracts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,7 @@ export type ParagraphSpacing = {
before?: number;
after?: number;
line?: number;
lineUnit?: 'px' | 'multiplier';
lineRule?: 'auto' | 'exact' | 'atLeast';
beforeAutospacing?: boolean;
afterAutospacing?: boolean;
Expand Down
44 changes: 13 additions & 31 deletions packages/layout-engine/measuring/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Comment thread
luccas-harbour marked this conversation as resolved.

if (spacing.lineRule === 'atLeast') {
return Math.max(baseLineHeight, raw);
}

return Math.max(baseLineHeight, raw);
return computedHeight;
};

const sanitizePositive = (value: number | undefined): number =>
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }],
},
Expand All @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -108,38 +101,53 @@ 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;
Comment thread
caio-pizzol marked this conversation as resolved.
}
}
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;

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' };
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion packages/layout-engine/pm-adapter/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
});
Expand Down