Skip to content

Commit 4fa46c1

Browse files
committed
fix: fixed unbound lineHeight during rendering, resolves #3083 and #3402
1 parent 482d7cd commit 4fa46c1

6 files changed

Lines changed: 217 additions & 15 deletions

File tree

packages/layout/src/text/getAttributedString.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as P from '@react-pdf/primitives';
22
import { Fragment, fromFragments } from '@react-pdf/textkit';
33
import FontStore from '@react-pdf/font';
4+
import { parseFloat } from '@react-pdf/fns';
45

56
import { embedEmojis } from './emoji';
67
import ignoreChars from './ignoreChars';
@@ -82,7 +83,7 @@ const getFragments = (
8283
color,
8384
opacity,
8485
fontSize,
85-
lineHeight,
86+
lineHeight: parseFloat(lineHeight),
8687
direction,
8788
verticalAlign,
8889
backgroundColor,
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, test } from 'vitest';
2+
import * as P from '@react-pdf/primitives';
3+
import FontStore from '@react-pdf/font';
4+
import { parseFloat } from '@react-pdf/fns';
5+
6+
import { loadYoga } from '../../src/yoga';
7+
import resolvePagination from '../../src/steps/resolvePagination';
8+
import resolveDimensions from '../../src/steps/resolveDimensions';
9+
import { SafeDocumentNode, SafeNode, SafeTextNode } from '../../src/types';
10+
11+
const fontStore = new FontStore();
12+
13+
const isText = (node: SafeNode): node is SafeTextNode => node.type === P.Text;
14+
15+
const calcLayout = (node: SafeDocumentNode) =>
16+
resolvePagination(resolveDimensions(node, fontStore), fontStore);
17+
18+
describe('lineHeight + render prop bug (issues #3083, #3402, #2988)', () => {
19+
test('should render dynamic text when lineHeight is set on page', async () => {
20+
const yoga = await loadYoga();
21+
22+
const layout = calcLayout({
23+
type: 'DOCUMENT',
24+
yoga,
25+
props: {},
26+
children: [
27+
{
28+
type: 'PAGE',
29+
props: {},
30+
style: {
31+
width: 100,
32+
height: 200,
33+
fontSize: 9,
34+
lineHeight: 1.5,
35+
},
36+
children: [
37+
{
38+
type: 'TEXT',
39+
style: {},
40+
props: {},
41+
children: [
42+
{
43+
type: 'TEXT_INSTANCE',
44+
value: 'static text',
45+
},
46+
],
47+
},
48+
{
49+
type: 'TEXT',
50+
style: {},
51+
props: {
52+
render: () => 'dynamic text',
53+
},
54+
children: [],
55+
},
56+
],
57+
},
58+
],
59+
});
60+
61+
const page = layout.children[0];
62+
const children = page.children!;
63+
const textNodes = children.filter(isText);
64+
const staticText = textNodes[0];
65+
const dynamicText = textNodes[1];
66+
67+
expect(staticText.lines).toBeDefined();
68+
expect(dynamicText.lines).toBeDefined();
69+
expect(dynamicText.lines!.length).toBeGreaterThan(0);
70+
const firstLine = dynamicText.lines![0];
71+
expect(firstLine.string).toContain('dynamic text');
72+
73+
// lineHeight 1.5 * fontSize 9 = 13.5; double-multiplied would be 121.5
74+
expect(dynamicText.box!.height).toBeLessThan(50);
75+
});
76+
77+
test('should not double-multiply lineHeight on dynamic text nodes', async () => {
78+
const yoga = await loadYoga();
79+
80+
const layout = calcLayout({
81+
type: 'DOCUMENT',
82+
yoga,
83+
props: {},
84+
children: [
85+
{
86+
type: 'PAGE',
87+
props: {},
88+
style: {
89+
width: 100,
90+
height: 200,
91+
fontSize: 10,
92+
lineHeight: 1.5,
93+
},
94+
children: [
95+
{
96+
type: 'TEXT',
97+
style: {},
98+
props: {
99+
render: () => 'hello',
100+
},
101+
children: [],
102+
},
103+
],
104+
},
105+
],
106+
});
107+
108+
const page = layout.children[0];
109+
const dynamicText = page.children!.find(isText)!;
110+
111+
const lineHeight = parseFloat(String(dynamicText.style!.lineHeight));
112+
expect(lineHeight).toBe(15);
113+
});
114+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
3+
4+
import {
5+
Document,
6+
Page,
7+
Text,
8+
View,
9+
StyleSheet,
10+
renderToBuffer,
11+
} from '@react-pdf/renderer';
12+
13+
const styles = StyleSheet.create({
14+
page: {
15+
lineHeight: 1.5,
16+
fontSize: 9,
17+
},
18+
});
19+
20+
const getTextContent = async (pdfBuffer) => {
21+
const document = await getDocument({
22+
data: new Uint8Array(pdfBuffer),
23+
verbosity: 0,
24+
}).promise;
25+
26+
const page = await document.getPage(1);
27+
const content = await page.getTextContent();
28+
29+
return content.items.map((item) => item.str).join(' ');
30+
};
31+
32+
describe('lineHeight + render prop end-to-end (issues #3083, #3402, #2988)', () => {
33+
test('should render dynamic text when lineHeight is set on page', async () => {
34+
const doc = (
35+
<Document>
36+
<Page size="LETTER" style={styles.page}>
37+
<Text>Static text renders fine</Text>
38+
<View fixed>
39+
<Text
40+
render={({ pageNumber, totalPages }) =>
41+
`${pageNumber} / ${totalPages}`
42+
}
43+
/>
44+
</View>
45+
</Page>
46+
</Document>
47+
);
48+
49+
const buffer = await renderToBuffer(doc);
50+
const text = await getTextContent(buffer);
51+
52+
// Both static and dynamic text should be present
53+
expect(text).toContain('Static text renders fine');
54+
expect(text).toContain('1 / 1');
55+
});
56+
57+
test('should render dynamic text without lineHeight (control test)', async () => {
58+
const doc = (
59+
<Document>
60+
<Page size="LETTER" style={{ fontSize: 9 }}>
61+
<Text>Static text renders fine</Text>
62+
<View fixed>
63+
<Text
64+
render={({ pageNumber, totalPages }) =>
65+
`${pageNumber} / ${totalPages}`
66+
}
67+
/>
68+
</View>
69+
</Page>
70+
</Document>
71+
);
72+
73+
const buffer = await renderToBuffer(doc);
74+
const text = await getTextContent(buffer);
75+
76+
expect(text).toContain('Static text renders fine');
77+
expect(text).toContain('1 / 1');
78+
});
79+
});

packages/stylesheet/src/resolve/text.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,17 @@ const transformLineHeight = (
6060

6161
// Percent values: use this number multiplied by the element's font size
6262
const { percent } = matchPercent(lineHeight) || {};
63-
if (percent) return percent * fontSize;
63+
if (percent) return `${percent * fontSize}pt`;
6464

65-
// Unitless values: use this number multiplied by the element's font size
66-
return isNaN(Number(value)) ? lineHeight : lineHeight * fontSize;
65+
// Values with units (e.g. '20px') are already absolute. Non-numeric keyword
66+
// strings (e.g. 'normal') pass through transformUnit unchanged and should
67+
// not have 'pt' appended.
68+
// Note that renderer doesn't support keywords, so this is purely defensive.
69+
if (isNaN(Number(value))) {
70+
return typeof lineHeight === 'number' ? `${lineHeight}pt` : lineHeight;
71+
}
72+
73+
return `${lineHeight * fontSize}pt`;
6774
};
6875

6976
const processLineHeight = <K extends StyleKey>(

packages/stylesheet/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,7 @@ export type TextSafeStyle = TextExpandedStyle & {
346346
fontSize?: number;
347347
fontWeight?: number;
348348
letterSpacing?: number;
349-
lineHeight?: number;
349+
lineHeight?: number | string;
350350
};
351351

352352
// Margins

packages/stylesheet/tests/text.test.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, test } from 'vitest';
2+
import { parseFloat } from '@react-pdf/fns';
23

34
import resolve from '../src/resolve';
45

@@ -130,49 +131,49 @@ describe('resolve stylesheet text', () => {
130131
test('should resolve line height number', () => {
131132
const styles = resolveStyle({ lineHeight: 2 });
132133

133-
expect(styles.lineHeight).toBe(18 * 2);
134+
expect(styles.lineHeight).toBe(`${18 * 2}pt`);
134135
});
135136

136137
test('should resolve number line height with font size', () => {
137138
const styles = resolveStyle({ lineHeight: 2, fontSize: 10 });
138139

139-
expect(styles.lineHeight).toBe(10 * 2);
140+
expect(styles.lineHeight).toBe(`${10 * 2}pt`);
140141
});
141142

142143
test('should resolve string line height', () => {
143144
const styles = resolveStyle({ lineHeight: '2' });
144145

145-
expect(styles.lineHeight).toBe(18 * 2);
146+
expect(styles.lineHeight).toBe(`${18 * 2}pt`);
146147
});
147148

148149
test('should resolve string line height with font-size', () => {
149150
const styles = resolveStyle({ lineHeight: '2', fontSize: 10 });
150151

151-
expect(styles.lineHeight).toBe(10 * 2);
152+
expect(styles.lineHeight).toBe(`${10 * 2}pt`);
152153
});
153154

154155
test('should resolve percentage line height', () => {
155156
const styles = resolveStyle({ lineHeight: '200%' });
156157

157-
expect(styles.lineHeight).toBe(18 * 2);
158+
expect(styles.lineHeight).toBe(`${18 * 2}pt`);
158159
});
159160

160161
test('should resolve percentage line height with font-size', () => {
161162
const styles = resolveStyle({ lineHeight: '200%', fontSize: 10 });
162163

163-
expect(styles.lineHeight).toBe(10 * 2);
164+
expect(styles.lineHeight).toBe(`${10 * 2}pt`);
164165
});
165166

166167
test('should resolve px line height', () => {
167168
const styles = resolveStyle({ lineHeight: '20px' });
168169

169-
expect(styles.lineHeight).toBe(20);
170+
expect(styles.lineHeight).toBe(`${20}pt`);
170171
});
171172

172173
test('should resolve mm line height', () => {
173174
const styles = resolveStyle({ lineHeight: '20mm' });
174175

175-
expect(styles.lineHeight).toBeCloseTo(56.69, 1);
176+
expect(parseFloat(styles.lineHeight)).toBeCloseTo(56.69, 1);
176177
});
177178

178179
test('should resolve font family', () => {
@@ -328,13 +329,13 @@ describe('resolve stylesheet text', () => {
328329
test('should resolve line height rem units', () => {
329330
const styles = resolveStyle({ lineHeight: '2rem' });
330331

331-
expect(styles).toEqual({ lineHeight: 20 });
332+
expect(styles).toEqual({ lineHeight: '20pt' });
332333
});
333334

334335
test('should resolve line height in units', () => {
335336
const styles = resolveStyle({ lineHeight: '0.5in' });
336337

337-
expect(styles).toEqual({ lineHeight: 36 });
338+
expect(styles).toEqual({ lineHeight: '36pt' });
338339
});
339340

340341
test('should resolve string max lines', () => {

0 commit comments

Comments
 (0)