Skip to content
Draft
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
42 changes: 42 additions & 0 deletions packages/layout-engine/layout-engine/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2458,6 +2458,48 @@ describe('layoutDocument', () => {
expect(layout.pages[1].fragments[0].blockId).toBe('p2');
});

it('skips empty sectPr marker paragraph before continuous section break (SD-2735)', () => {
// IT-945 shape: "This is my first section" + empty sectPr-marker +
// continuous break into 2-col section. The marker is logically a
// section-properties container, not a visible paragraph. Rendering it
// at docDefaults height would cost a row per column on page 1.
const blocks: FlowBlock[] = [
{ kind: 'paragraph', id: 'p1', runs: [{ text: 'First section', fontFamily: 'Arial', fontSize: 12 }] },
{
kind: 'paragraph',
id: 'p-marker',
runs: [{ text: '', fontFamily: 'Arial', fontSize: 12 }],
attrs: { sectPrMarker: true },
},
{
kind: 'sectionBreak',
id: 'sb1',
type: 'continuous',
margins: {},
pageSize: { w: 612, h: 792 },
columns: { count: 2, gap: 36 },
attrs: { source: 'sectPr' },
},
{ kind: 'paragraph', id: 'p2', runs: [{ text: 'In 2-col section', fontFamily: 'Arial', fontSize: 12 }] },
];
const measures: Measure[] = [
{ kind: 'paragraph', lines: [makeLine(20)], totalHeight: 20 },
{ kind: 'paragraph', lines: [makeLine(16)], totalHeight: 16 },
{ kind: 'sectionBreak' },
{ kind: 'paragraph', lines: [makeLine(20)], totalHeight: 20 },
];

const layout = layoutDocument(blocks, measures, {
pageSize: { w: 612, h: 792 },
margins: { top: 72, right: 72, bottom: 72, left: 72 },
});

// Both visible paragraphs land on page 1; the marker does not appear.
expect(layout.pages).toHaveLength(1);
const fragmentIds = layout.pages[0].fragments.map((f) => f.blockId);
expect(fragmentIds).toEqual(['p1', 'p2']);
});

it('skips empty sectPr marker paragraph before forced section break', () => {
const blocks: FlowBlock[] = [
{ kind: 'paragraph', id: 'p1', runs: [{ text: 'Content', fontFamily: 'Arial', fontSize: 12 }] },
Expand Down
26 changes: 12 additions & 14 deletions packages/layout-engine/layout-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2170,25 +2170,23 @@ export function layoutDocument(blocks: FlowBlock[], measures: Measure[], options

if (isEmpty) {
const isSectPrMarker = paraBlock.attrs?.sectPrMarker === true;
// Check if previous block was pageBreak and next block is sectionBreak
const prevBlock = index > 0 ? blocks[index - 1] : null;
const nextBlock = index < blocks.length - 1 ? blocks[index + 1] : null;

const nextSectionBreak = nextBlock?.kind === 'sectionBreak' ? (nextBlock as SectionBreakBlock) : null;
const nextBreakType =
nextSectionBreak?.type ?? (nextSectionBreak?.attrs?.source === 'sectPr' ? 'nextPage' : undefined);
const nextBreakForcesPage =
nextSectionBreak &&
(nextBreakType === 'nextPage' ||
nextBreakType === 'evenPage' ||
nextBreakType === 'oddPage' ||
nextSectionBreak.attrs?.requirePageBoundary === true);

if (isSectPrMarker && nextBreakForcesPage) {
const nextIsSectionBreak = nextBlock?.kind === 'sectionBreak';

// A section-break marker paragraph (`<w:pPr><w:sectPr/></w:pPr>` with
// no runs) carries only section metadata in Word — it's not a visible
// paragraph. Skip it regardless of break type (nextPage/continuous/
// evenPage/oddPage) so intro spacing matches Word for all section
// transitions. Per ECMA-376 §17.6.17 the sectPr defines where the
// section ends, not a rendered paragraph.
if (isSectPrMarker && nextIsSectionBreak) {
continue;
}

if (prevBlock?.kind === 'pageBreak' && nextBlock?.kind === 'sectionBreak') {
// Pre-sectPrMarker path: empty paragraph sandwiched between an
// explicit page break and a section break — same "pure marker" shape.
if (prevBlock?.kind === 'pageBreak' && nextIsSectionBreak) {
continue;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,4 +359,48 @@ describe('fontMetricsCache', () => {
expect(metrics.ascent).toBeGreaterThan(metrics.descent);
});
});

describe('naturalSingleLine calibration (SD-2735)', () => {
// Per ECMA-376 §17.18.48, spacing multipliers are relative to the font's
// "natural single line height". JSDOM's Canvas implementation cannot read
// the actual ascent/descent from Microsoft's Aptos/Calibri font files, so
// we calibrate these fonts by measured-vs-Word ratio.

it('populates naturalSingleLine for Aptos at 12pt matching Word (~19.5px)', () => {
const metrics = getFontMetrics(ctx, { fontFamily: 'Aptos', fontSize: 16 }, 'browser', defaultFonts);
expect(metrics.naturalSingleLine).toBeDefined();
// 16px × 1.218 = 19.488 — Word renders Aptos 12pt at ~19.5px
expect(metrics.naturalSingleLine!).toBeCloseTo(19.488, 1);
});

it('populates naturalSingleLine for Calibri at 12pt (~19.5px)', () => {
const metrics = getFontMetrics(ctx, { fontFamily: 'Calibri', fontSize: 16 }, 'browser', defaultFonts);
expect(metrics.naturalSingleLine).toBeDefined();
expect(metrics.naturalSingleLine!).toBeCloseTo(19.504, 1);
});

it('falls back to canvas measurement for fonts without calibration', () => {
const metrics = getFontMetrics(ctx, { fontFamily: 'Arial', fontSize: 16 }, 'browser', defaultFonts);
// Arial has no entry in the calibration table; naturalSingleLine may
// still come through from fontBoundingBox* when the canvas provides it.
// Either a canvas-sourced value or undefined is acceptable here.
if (metrics.naturalSingleLine != null) {
expect(metrics.naturalSingleLine).toBeGreaterThan(0);
expect(metrics.naturalSingleLine).toBeLessThan(30);
}
});

it('scales naturalSingleLine linearly with font size for Aptos', () => {
const m12 = getFontMetrics(ctx, { fontFamily: 'Aptos', fontSize: 16 }, 'browser', defaultFonts);
const m24 = getFontMetrics(ctx, { fontFamily: 'Aptos', fontSize: 32 }, 'browser', defaultFonts);
// Calibration is a pure multiplier: doubling fontSize doubles naturalSingle.
expect(m24.naturalSingleLine! / m12.naturalSingleLine!).toBeCloseTo(2, 2);
});

it('matches Aptos Display and Aptos to the same calibration', () => {
const m1 = getFontMetrics(ctx, { fontFamily: 'Aptos', fontSize: 16 }, 'browser', defaultFonts);
const m2 = getFontMetrics(ctx, { fontFamily: 'Aptos Display', fontSize: 16 }, 'browser', defaultFonts);
expect(m1.naturalSingleLine).toBeCloseTo(m2.naturalSingleLine!, 2);
});
});
});
53 changes: 52 additions & 1 deletion packages/layout-engine/measuring/dom/src/fontMetricsCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,49 @@ export type FontInfo = {

/**
* Measured font metrics from the Canvas API.
*
* `ascent`/`descent` are painted bounds from `actualBoundingBox*` (used for
* glyph-clipping guards). `naturalSingleLine` is the font's intrinsic
* single-line height used by `w:lineRule="auto"` — from the calibration
* table when present, else from `fontBoundingBox*` when the browser exposes
* it. Undefined signals "caller pick a fallback".
*/
export type FontMetricsResult = {
ascent: number;
descent: number;
naturalSingleLine?: number;
};

/**
* Natural-single-line multipliers for fonts whose Word-rendered metrics
* differ from what the browser Canvas can measure. The browser does not have
* access to Microsoft's internal font metrics unless the font is bundled,
* so Aptos/Calibri/Cambria must be calibrated against Word's actual output.
*
* Keyed by primary family name lowercased. Add entries when corpus evidence
* shows a systematic gap between Canvas measurement and Word.
*/
const FONT_NATURAL_LINE_CALIBRATION: Record<string, number> = {
aptos: 1.218,
'aptos display': 1.218,
calibri: 1.219,
'calibri light': 1.219,
cambria: 1.219,
};

const primaryFontFamily = (fontFamily: string): string => {
if (typeof fontFamily !== 'string') return '';
const first = fontFamily.split(',')[0] ?? '';
return first
.trim()
.replace(/^['"]|['"]$/g, '')
.toLowerCase();
};

export const getCalibratedNaturalSingleLine = (fontFamily: string, fontSizePx: number): number | undefined => {
const multiplier = FONT_NATURAL_LINE_CALIBRATION[primaryFontFamily(fontFamily)];
if (multiplier == null || !Number.isFinite(fontSizePx) || fontSizePx <= 0) return undefined;
return fontSizePx * multiplier;
};

/**
Expand Down Expand Up @@ -219,7 +258,19 @@ export function getFontMetrics(
descent = fontInfo.fontSize * 0.2;
}

const result: FontMetricsResult = { ascent, descent };
// Prefer explicit Word-calibration; fall back to the browser's
// fontBoundingBox* when it's exposed; otherwise leave undefined.
let naturalSingleLine = getCalibratedNaturalSingleLine(fontInfo.fontFamily, fontInfo.fontSize);
if (
naturalSingleLine == null &&
typeof textMetrics.fontBoundingBoxAscent === 'number' &&
typeof textMetrics.fontBoundingBoxDescent === 'number' &&
textMetrics.fontBoundingBoxAscent > 0
) {
naturalSingleLine = textMetrics.fontBoundingBoxAscent + textMetrics.fontBoundingBoxDescent;
}

const result: FontMetricsResult = { ascent, descent, naturalSingleLine };

// Cache the result (with size limit)
if (fontMetricsCache.size >= MAX_CACHE_SIZE) {
Expand Down
86 changes: 81 additions & 5 deletions packages/layout-engine/measuring/dom/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,9 +1002,13 @@ describe('measureBlock', () => {
};

const measure = expectParagraphMeasure(await measureBlock(block, 400));
// `lineUnit: "multiplier"` applies directly to fontSize.
// (pm-adapter already bakes the OOXML auto 1.15 factor into the multiplier value.)
expect(measure.lines[0].lineHeight).toBeCloseTo(1.5 * fontSize, 1);
// `lineUnit: "multiplier"` now applies to naturalSingleLine per
// ECMA-376 §17.18.48 auto rule `(line/240) × naturalSingle`. For fonts
// without explicit calibration, naturalSingle falls back to
// WORD_SINGLE_LINE_SPACING_MULTIPLIER × fontSize = 1.15 × 16 = 18.4.
// → 1.5 × 18.4 = 27.6.
const baseNaturalSingle = 1.15 * fontSize; // 18.4 fallback for Arial
expect(measure.lines[0].lineHeight).toBeCloseTo(1.5 * baseNaturalSingle, 1);
});

it('applies higher auto multipliers to the baseline line height', async () => {
Expand All @@ -1025,7 +1029,10 @@ describe('measureBlock', () => {
};

const measure = expectParagraphMeasure(await measureBlock(block, 400));
expect(measure.lines[0].lineHeight).toBeCloseTo(2 * fontSize, 1);
// ECMA-376 §17.18.48 auto rule: multiplier × naturalSingle (not fontSize).
// For uncalibrated Arial, naturalSingle falls back to 1.15 × fontSize.
const baseNaturalSingle = 1.15 * fontSize; // 18.4
expect(measure.lines[0].lineHeight).toBeCloseTo(2 * baseNaturalSingle, 1);
});

it('applies large auto values as multipliers', async () => {
Expand All @@ -1045,7 +1052,8 @@ describe('measureBlock', () => {
};

const measure = expectParagraphMeasure(await measureBlock(block, 400));
expect(measure.lines[0].lineHeight).toBeCloseTo(42 * 16, 1);
// 42 × naturalSingle (fallback 1.15 × 16 = 18.4) = 772.8.
expect(measure.lines[0].lineHeight).toBeCloseTo(42 * 1.15 * 16, 1);
});

it('does not clamp line height for very small fonts', async () => {
Expand Down Expand Up @@ -1135,6 +1143,74 @@ describe('measureBlock', () => {
expect(measure.lines[0].lineHeight).toBeCloseTo(baseLineHeight, 1);
});

it('honours auto lineRule with a sub-baseline multiplier (no silent min-clamp) per ECMA-376 §17.18.48', async () => {
// SD-2735 regression: `auto` explicitly has "no predetermined minimum
// or maximum" per spec. A multiplier of 0.5 should tighten lines,
// not be silently inflated to the baseline 1.15 × fontSize as the
// pre-fix code did (it reused the atLeast max-clamp branch for auto).
const fontSize = 16;
const block: FlowBlock = {
kind: 'paragraph',
id: 'tight-spacing',
runs: [
{
text: 'Tight auto spacing',
fontFamily: 'Arial',
fontSize,
},
],
attrs: {
spacing: { line: 0.5, lineUnit: 'multiplier', lineRule: 'auto' },
},
};

const measure = expectParagraphMeasure(await measureBlock(block, 400));
// 0.5 × naturalSingle (= 0.5 × 18.4 = 9.2 for uncalibrated Arial).
// Previously the `auto` path max-clamped this up to 18.4, eating the
// author's explicit half-spacing intent. After the §17.18.48 rule
// fix the multiplier applies cleanly, even if the result is still
// smaller than the font's cap height (caller is responsible for
// glyph-clipping guards elsewhere).
const baseNaturalSingle = 1.15 * fontSize; // 18.4 fallback
expect(measure.lines[0].lineHeight).toBeCloseTo(0.5 * baseNaturalSingle, 1);
});

it('applies multiplier × naturalSingle to empty paragraphs in calibrated fonts (SD-2735)', async () => {
// Caio's third PR-review repro (arial-empty-rows.docx): empty cell
// paragraphs go through `calculateEmptyParagraphMetrics`, which does
// not plumb the font's calibrated `naturalSingleLine` through to
// `resolveLineHeight`. For uncalibrated fonts the bug hides because
// naturalSingle ≈ WORD_SINGLE_LINE_SPACING_MULTIPLIER × fontSize
// (18.4 px at 16 px) matches the fallback floor inside resolveLineHeight.
// For Aptos (calibrated to ~19.5 px at 16 px), the gap surfaces:
// empty rows measure 1.15 × fontSize instead of the calibrated value,
// so empty cells render shorter than text-bearing cells in the same
// table. The fix is symmetric with `calculateTypographyMetrics`:
// populate naturalSingle from the calibration table when fontInfo is
// present, and forward it to resolveLineHeight.
const fontSize = 16; // 12 pt
const block: FlowBlock = {
kind: 'paragraph',
id: 'empty-aptos',
runs: [
{
text: '',
fontFamily: 'Aptos',
fontSize,
},
],
attrs: {
spacing: { line: 1.5, lineUnit: 'multiplier', lineRule: 'auto' },
},
};

const measure = expectParagraphMeasure(await measureBlock(block, 400));
// Aptos calibrated naturalSingle ≈ 19.488 px (see fontMetricsCache.test.ts).
// 1.5 × 19.488 ≈ 29.2.
const aptosNaturalSingle = 19.488;
expect(measure.lines[0].lineHeight).toBeCloseTo(1.5 * aptosNaturalSingle, 0);
});

it('ensures line height is never smaller than glyph bounds to prevent clipping', async () => {
// This test verifies the clamp: Math.max(fontSize * 1.15, ascent + descent)
// For any font, line height must be >= ascent + descent to prevent glyph overlap
Expand Down
Loading
Loading