Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/cjk-line-box-and-typo-metrics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-pdf/textkit": patch
---

fix(textkit): align line-box height and font metrics with browser layout
4 changes: 3 additions & 1 deletion packages/textkit/src/run/ascent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Run } from '../types';
import scale from './scale';
import resolveTypoMetrics from './typoMetrics';

/**
* Get run ascent
Expand All @@ -10,7 +11,8 @@ import scale from './scale';
const ascent = (run: Run) => {
const { font, attachment } = run.attributes;
const attachmentHeight = attachment?.height || 0;
const fontAscent = typeof font === 'string' ? 0 : font?.[0]?.ascent || 0;
const fontAscent =
typeof font === 'string' ? 0 : resolveTypoMetrics(font?.[0]).ascent;

return Math.max(attachmentHeight, fontAscent * scale(run));
};
Expand Down
4 changes: 3 additions & 1 deletion packages/textkit/src/run/descent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Run } from '../types';
import scale from './scale';
import resolveTypoMetrics from './typoMetrics';

/**
* Get run descent
Expand All @@ -9,7 +10,8 @@ import scale from './scale';
*/
const descent = (run: Run) => {
const font = run.attributes?.font;
const fontDescent = typeof font === 'string' ? 0 : font?.[0]?.descent || 0;
const fontDescent =
typeof font === 'string' ? 0 : resolveTypoMetrics(font?.[0]).descent;

return scale(run) * fontDescent;
};
Expand Down
3 changes: 2 additions & 1 deletion packages/textkit/src/run/height.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import lineGap from './lineGap';
*/
const height = (run: Run) => {
const lineHeight = run.attributes?.lineHeight;
return lineHeight || lineGap(run) + ascent(run) - descent(run);
const intrinsic = lineGap(run) + ascent(run) - descent(run);
return Math.max(lineHeight || 0, intrinsic);
};

export default height;
6 changes: 4 additions & 2 deletions packages/textkit/src/run/lineGap.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Run } from '../types';
import scale from './scale';
import resolveTypoMetrics from './typoMetrics';

/**
* Get run lineGap
Expand All @@ -9,8 +10,9 @@ import scale from './scale';
*/
const lineGap = (run: Run) => {
const font = run.attributes?.font;
const lineGap = typeof font === 'string' ? 0 : font?.[0]?.lineGap || 0;
return lineGap * scale(run);
const fontLineGap =
typeof font === 'string' ? 0 : resolveTypoMetrics(font?.[0]).lineGap;
return fontLineGap * scale(run);
};

export default lineGap;
46 changes: 46 additions & 0 deletions packages/textkit/src/run/typoMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Font } from '../types';

type Metrics = {
ascent: number;
descent: number;
lineGap: number;
};

/**
* Prefer OS/2 typographic metrics over hhea, falling back when absent.
*
* @param font - Font
* @returns Metrics
*/
const resolveTypoMetrics = (font: Font | undefined): Metrics => {
const os2 = font?.['OS/2'] as
| {
typoAscender?: number;
typoDescender?: number;
typoLineGap?: number;
}
| undefined;

if (
!os2 ||
typeof os2.typoAscender !== 'number' ||
typeof os2.typoDescender !== 'number'
) {
return {
ascent: font?.ascent || 0,
descent: font?.descent || 0,
lineGap: font?.lineGap || 0,
};
}

return {
ascent: os2.typoAscender,
descent: os2.typoDescender,
lineGap:
typeof os2.typoLineGap === 'number'
? os2.typoLineGap
: font?.lineGap || 0,
};
};

export default resolveTypoMetrics;
20 changes: 20 additions & 0 deletions packages/textkit/tests/run/ascent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,24 @@ describe('run ascent operator', () => {

expect(ascent(run)).toBe(70);
});

test('should prefer OS/2 typoAscender over hhea ascent', () => {
const run = {
start: 0,
end: 0,
attributes: {
fontSize: 12,
font: [
{
ascent: 1160,
unitsPerEm: 1000,
'OS/2': { typoAscender: 880, typoDescender: -120 },
} as unknown as Font,
],
},
};

// 880 (typoAscender) * 12 / 1000, not 1160 * 12 / 1000.
expect(ascent(run)).toBe((880 * 12) / 1000);
});
});
19 changes: 19 additions & 0 deletions packages/textkit/tests/run/descent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,23 @@ describe('run descent operator', () => {

expect(descent(run)).toBe(-(10 * 12) / 2);
});

test('should prefer OS/2 typoDescender over hhea descent', () => {
const run = {
start: 0,
end: 0,
attributes: {
fontSize: 12,
font: [
{
descent: -288,
unitsPerEm: 1000,
'OS/2': { typoAscender: 880, typoDescender: -120 },
} as unknown as Font,
],
},
};

expect(descent(run)).toBe((-120 * 12) / 1000);
});
});
58 changes: 58 additions & 0 deletions packages/textkit/tests/run/height.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,62 @@ describe('run height operator', () => {

expect(height(run)).toBe(((15 + 10 + 2) * 12) / 2);
});

test('should return user lineHeight when it exceeds the intrinsic height', () => {
const run = {
start: 0,
end: 0,
attributes: {
fontSize: 12,
lineHeight: 200,
font: [{ descent: -10, ascent: 15, lineGap: 2, unitsPerEm: 2 } as Font],
},
};

// intrinsic = (15 + 10 + 2) * 12 / 2 = 162; user lineHeight = 200 wins.
expect(height(run)).toBe(200);
});

test('should clamp user lineHeight up to the intrinsic height (CSS line-box rule)', () => {
const run = {
start: 0,
end: 0,
attributes: {
fontSize: 12,
lineHeight: 5,
font: [{ descent: -10, ascent: 15, lineGap: 2, unitsPerEm: 2 } as Font],
},
};

// intrinsic = 162 > user lineHeight = 5; line-box must grow.
expect(height(run)).toBe(((15 + 10 + 2) * 12) / 2);
});

test('should use OS/2 typo metrics when computing the intrinsic height', () => {
const run = {
start: 0,
end: 0,
attributes: {
fontSize: 12,
font: [
{
// hhea inflated (typical Source Han Sans behaviour).
ascent: 1160,
descent: -288,
lineGap: 0,
unitsPerEm: 1000,
'OS/2': {
typoAscender: 880,
typoDescender: -120,
typoLineGap: 0,
},
} as unknown as Font,
],
},
};

// typo intrinsic = (880 - (-120) + 0) * 12 / 1000 = 12;
// hhea intrinsic would have been (1160 - (-288)) * 12 / 1000 = 17.376.
expect(height(run)).toBe(12);
});
});
23 changes: 23 additions & 0 deletions packages/textkit/tests/run/lineGap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,27 @@ describe('run lineGap operator', () => {

expect(lineGap(run)).toBe((10 * 12) / 2);
});

test('should prefer OS/2 typoLineGap over hhea lineGap', () => {
const run = {
start: 0,
end: 0,
attributes: {
fontSize: 12,
font: [
{
lineGap: 200,
unitsPerEm: 1000,
'OS/2': {
typoAscender: 800,
typoDescender: -200,
typoLineGap: 0,
},
} as unknown as Font,
],
},
};

expect(lineGap(run)).toBe(0);
});
});
76 changes: 76 additions & 0 deletions packages/textkit/tests/run/typoMetrics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, test } from 'vitest';

import resolveTypoMetrics from '../../src/run/typoMetrics';
import { Font } from '../../src/types';

describe('run typoMetrics helper', () => {
test('returns zeroed metrics for an undefined font', () => {
expect(resolveTypoMetrics(undefined)).toEqual({
ascent: 0,
descent: 0,
lineGap: 0,
});
});

test('falls back to hhea metrics when no OS/2 table is present', () => {
const font = { ascent: 800, descent: -200, lineGap: 0 } as Font;

expect(resolveTypoMetrics(font)).toEqual({
ascent: 800,
descent: -200,
lineGap: 0,
});
});

test('falls back to hhea when OS/2 lacks numeric typo metrics', () => {
const font = {
ascent: 800,
descent: -200,
lineGap: 0,
'OS/2': {} as never,
} as Font;

expect(resolveTypoMetrics(font)).toEqual({
ascent: 800,
descent: -200,
lineGap: 0,
});
});

test('prefers OS/2 typo metrics when fully populated', () => {
const font = {
ascent: 1160,
descent: -288,
lineGap: 0,
'OS/2': {
typoAscender: 880,
typoDescender: -120,
typoLineGap: 0,
} as never,
} as Font;

expect(resolveTypoMetrics(font)).toEqual({
ascent: 880,
descent: -120,
lineGap: 0,
});
});

test('falls back to hhea lineGap when OS/2 typoLineGap is missing', () => {
const font = {
ascent: 1000,
descent: -200,
lineGap: 50,
'OS/2': {
typoAscender: 800,
typoDescender: -150,
} as never,
} as Font;

expect(resolveTypoMetrics(font)).toEqual({
ascent: 800,
descent: -150,
lineGap: 50,
});
});
});