From ec6bb7c4341508544bb30f19a2a55de127667601 Mon Sep 17 00:00:00 2001 From: Ralf Sternberg Date: Sat, 28 Feb 2026 17:52:18 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=A5=20Use=20font=20vertical=20metrics?= =?UTF-8?q?=20for=20line=20height?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The line height calculation used a hardcoded 1.2× multiplier on top of ascent/descent, ignoring the font's own `sTypoLineGap`. This produced spacing that didn't respect font design intent. Include `lineGap` in the text height calculation and change the default `lineHeight` multiplier from `1.2` to `1`. Line spacing now follows the font's vertical metrics directly. The previous behavior can be restored by adjusting the `lineHeight` property (e.g., `lineHeight: 1.2`). Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 12 +++++++----- README.md | 2 +- examples/anchors.ts | 1 + src/api/text.ts | 5 ++++- src/layout/layout-columns.test.ts | 20 ++++++++++---------- src/layout/layout-text.test.ts | 16 ++++++++-------- src/layout/layout.test.ts | 16 ++++++++-------- src/text.test.ts | 2 +- src/text.ts | 6 ++---- 9 files changed, 42 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70fda98..b93b4c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,11 +54,13 @@ ### Breaking -- Text height is now based on the OS/2 typographic metrics - (`sTypoAscender` / `sTypoDescender`) instead of the hhea table values. - This results in tighter line spacing for fonts whose hhea values - include extra spacing that was effectively double-counted with the - `lineHeight` multiplier. +- The text height is now correctly based on the OS/2 typographic metrics + (`sTypoAscender` / `sTypoDescender` / `sTypoLineGap`) instead of the + hhea table values. +- The default `lineHeight` multiplier has changed from `1.2` to `1`. + Together with the switch from hhea to OS/2 typographic metrics, these + changes make line spacing follow the font's own vertical metrics + instead of applying a fixed CSS-style multiplier. ## [0.5.6] - 2025-01-19 diff --git a/README.md b/README.md index d878d83..1a4495d 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ text spans. The following text properties are supported: `900`. The literal values `normal` and `bold` are also supported. - `fontSize`: The font size in pt. - `lineHeight`: The line height as a multiple of the font size (default: - `1.2`). + `1`). - `color`: The text [color](#colors). - `link`: Renders the text as a link to the given target. Can be a URL or an [anchor](#anchors) reference. diff --git a/examples/anchors.ts b/examples/anchors.ts index dc9512b..61fdc0d 100644 --- a/examples/anchors.ts +++ b/examples/anchors.ts @@ -20,6 +20,7 @@ const def: DocumentDefinition = { margin: { x: '20mm', y: '0.5cm' }, defaultStyle: { fontSize: 12, + lineHeight: 1.1, }, header: columns([text('PDF Maker'), text('Anchors', { textAlign: 'right', width: 'auto' })], { margin: { x: '20mm', top: '1cm' }, diff --git a/src/api/text.ts b/src/api/text.ts index 72c6539..4efa65b 100644 --- a/src/api/text.ts +++ b/src/api/text.ts @@ -83,7 +83,10 @@ export type TextProps = { fontSize?: number; /** - * The line height as a multiple of the font size. Defaults to `1.2`. + * The line height as a factor of the text height. The text height is + * derived from the font's vertical metrics (ascent, descent, and line + * gap). Values greater than `1` increase the spacing between lines, + * values less than `1` decrease it. Defaults to `1`. */ lineHeight?: number; diff --git a/src/layout/layout-columns.test.ts b/src/layout/layout-columns.test.ts index 960dbdc..9ec9c26 100644 --- a/src/layout/layout-columns.test.ts +++ b/src/layout/layout-columns.test.ts @@ -123,11 +123,11 @@ describe('layout-columns', () => { expect(frame).toEqual({ children: [ - expect.objectContaining({ x: 20 + 5, y: 30 + 7, width: 200 - 5 - 6, height: 12 }), - expect.objectContaining({ x: 20 + 200 + 5, y: 30 + 7, width: 200 - 5 - 6, height: 12 }), + expect.objectContaining({ x: 20 + 5, y: 30 + 7, width: 200 - 5 - 6, height: 10 }), + expect.objectContaining({ x: 20 + 200 + 5, y: 30 + 7, width: 200 - 5 - 6, height: 10 }), ], width: box.width, - height: 12 + 7 + 8, + height: 10 + 7 + 8, }); }); @@ -145,8 +145,8 @@ describe('layout-columns', () => { expect(frame).toEqual({ children: [ expect.objectContaining({ x: 20 + 5, y: 30 + 7, width: 89, height: 25 }), - expect.objectContaining({ x: 20 + 100 + 5, y: 30 + 7, width: 150 - 5 - 6, height: 12 }), - expect.objectContaining({ x: 20 + 250 + 5, y: 30 + 7, width: 150 - 5 - 6, height: 12 }), + expect.objectContaining({ x: 20 + 100 + 5, y: 30 + 7, width: 150 - 5 - 6, height: 10 }), + expect.objectContaining({ x: 20 + 250 + 5, y: 30 + 7, width: 150 - 5 - 6, height: 10 }), ], width: box.width, height: 25 + 7 + 8, @@ -165,11 +165,11 @@ describe('layout-columns', () => { expect(frame).toEqual({ children: [ - expect.objectContaining({ x: 20 + 5, y: 30 + 7, width: 200 - 5 - 6, height: 12 }), - expect.objectContaining({ x: 20 + 200 + 5, y: 30 + 7, width: 200 - 5 - 6, height: 12 }), + expect.objectContaining({ x: 20 + 5, y: 30 + 7, width: 200 - 5 - 6, height: 10 }), + expect.objectContaining({ x: 20 + 200 + 5, y: 30 + 7, width: 200 - 5 - 6, height: 10 }), ], width: box.width, - height: 27, + height: 25, }); }); @@ -186,8 +186,8 @@ describe('layout-columns', () => { expect(frame).toEqual({ children: [ expect.objectContaining({ y: box.y, height: 100 }), - expect.objectContaining({ y: box.y + (100 - 12) / 2, height: 12 }), - expect.objectContaining({ y: box.y + 100 - 12, height: 12 }), + expect.objectContaining({ y: box.y + (100 - 10) / 2, height: 10 }), + expect.objectContaining({ y: box.y + 100 - 10, height: 10 }), ], width: box.width, height: 100, diff --git a/src/layout/layout-text.test.ts b/src/layout/layout-text.test.ts index 9364fd5..de70e84 100644 --- a/src/layout/layout-text.test.ts +++ b/src/layout/layout-text.test.ts @@ -32,7 +32,7 @@ describe('layout-text', () => { const { frame } = await layoutTextContent(block, box, ctx); - expect(frame).toEqual(expect.objectContaining({ width: box.width, height: 12 })); + expect(frame).toEqual(expect.objectContaining({ width: box.width, height: 10 })); }); it('creates frame with intrinsic width for block with autoWidth', async () => { @@ -41,7 +41,7 @@ describe('layout-text', () => { const { frame } = await layoutTextContent(block, box, ctx); - expect(frame).toEqual(expect.objectContaining({ width: 30, height: 12 })); + expect(frame).toEqual(expect.objectContaining({ width: 30, height: 10 })); }); it('does not include padding in frame height', async () => { @@ -51,7 +51,7 @@ describe('layout-text', () => { const { frame } = await layoutTextContent(block, box, ctx); - expect(frame.height).toEqual(12); + expect(frame.height).toEqual(10); }); it('includes text baseline', async () => { @@ -64,7 +64,7 @@ describe('layout-text', () => { type: 'text', rows: [ { - ...{ x: 20, y: 30, width: 90, height: 12, baseline: 9 }, + ...{ x: 20, y: 30, width: 90, height: 10, baseline: 8 }, segments: [expect.objectContaining({ font: defaultFont, fontSize: 10 })], }, ], @@ -88,7 +88,7 @@ describe('layout-text', () => { type: 'text', rows: [ { - ...{ x: 20, y: 30, width: 270, height: 18, baseline: 13.5 }, + ...{ x: 20, y: 30, width: 270, height: 15, baseline: 12 }, segments: [ expect.objectContaining({ font: defaultFont, fontSize: 5 }), expect.objectContaining({ font: defaultFont, fontSize: 10 }), @@ -109,7 +109,7 @@ describe('layout-text', () => { expect(frame.objects).toEqual([ { type: 'text', rows: [expect.objectContaining({ x: 20, y: 30 })] }, - { type: 'link', x: 20, y: 30 + 1, width: 30, height: 10, url: 'test-link' }, + { type: 'link', x: 20, y: 30, width: 30, height: 10, url: 'test-link' }, ]); }); @@ -125,7 +125,7 @@ describe('layout-text', () => { expect(frame.objects).toEqual([ { type: 'text', rows: [expect.objectContaining({ x: 20, y: 30 })] }, - { type: 'link', x: 20, y: 30 + 1, width: 70, height: 10, url: 'test-link' }, + { type: 'link', x: 20, y: 30, width: 70, height: 10, url: 'test-link' }, ]); }); @@ -189,7 +189,7 @@ describe('layout-text', () => { expect.objectContaining({ x: margin.right + (box.width - 30) / 2, width: 30, - height: 12, + height: 10, }), ], }, diff --git a/src/layout/layout.test.ts b/src/layout/layout.test.ts index 96cfd1f..f95636b 100644 --- a/src/layout/layout.test.ts +++ b/src/layout/layout.test.ts @@ -39,8 +39,8 @@ describe('layout', () => { const pages = await layoutPages(def, ctx); expect(pages[0].content.children).toEqual([ - expect.objectContaining({ height: 14 * 1.2 }), - expect.objectContaining({ height: 14 * 1.2 }), + expect.objectContaining({ height: 14 }), + expect.objectContaining({ height: 14 }), ]); }); @@ -120,7 +120,7 @@ describe('layout', () => { x: 20, y: 20, width: pageWidth - 40, - height: 12, + height: 10, }), ); expect(pages[0].footer).toBeUndefined(); @@ -141,9 +141,9 @@ describe('layout', () => { expect(pages[0].footer).toEqual( expect.objectContaining({ x: 20, - y: pageHeight - 20 - 12, + y: pageHeight - 20 - 10, width: pageWidth - 40, - height: 12, + height: 10, }), ); }); @@ -165,15 +165,15 @@ describe('layout', () => { x: 20, y: 20, width: pageWidth - 40, - height: 12, + height: 10, }), ); expect(pages[0].footer).toEqual( expect.objectContaining({ x: 20, - y: pageHeight - 20 - 12, + y: pageHeight - 20 - 10, width: pageWidth - 40, - height: 12, + height: 10, }), ); }); diff --git a/src/text.test.ts b/src/text.test.ts index 1fa5e74..98287ef 100644 --- a/src/text.test.ts +++ b/src/text.test.ts @@ -43,7 +43,7 @@ describe('text', () => { width: 3 * 18, height: 18, fontSize: 18, - lineHeight: 1.2, + lineHeight: 1, font: normalFont, }), ]); diff --git a/src/text.ts b/src/text.ts index 9124976..bf7e46f 100644 --- a/src/text.ts +++ b/src/text.ts @@ -8,7 +8,7 @@ import type { Color } from './read/read-color.ts'; import { scriptToOpenTypeTag, segmentByScript } from './script-detection.ts'; const defaultFontSize = 18; -const defaultLineHeight = 1.2; +const defaultLineHeight = 1.0; export type TextSegment = { type: 'text' | 'whitespace' | 'newline'; @@ -280,9 +280,7 @@ function getGlyphRunWidth(glyphs: ShapedGlyph[], fontSize: number): number { } function getTextHeight(font: PDFFont, fontSize: number): number { - const ascent = font.ascent; - const descent = font.descent; - const height = ascent - descent; + const height = font.ascent - font.descent + font.lineGap; return (height * fontSize) / 1000; }