Skip to content

Commit 67ab670

Browse files
committed
feat: support text-wrap to control orphans in paragraph
- `pretty`: linebreaker keeps the last whitespace from being a break point so the final line carries at least two words together. - `nowrap`: keep text on a single line. Useful for short labels or table-cell-like content where breaking the text would be wrong. - `balance`: to equalize line lengths for titles
1 parent d41a820 commit 67ab670

12 files changed

Lines changed: 403 additions & 8 deletions

File tree

.changeset/dirty-chairs-drop.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@react-pdf/vite-example": minor
3+
"@react-pdf/stylesheet": minor
4+
"@react-pdf/textkit": minor
5+
"@react-pdf/layout": minor
6+
---
7+
8+
feat: support `text-wrap` style on Text with `pretty`, `balance`, and `nowrap` values.
9+
`pretty` avoids single-word last lines (word-level orphan control).
10+
`balance` equalizes line lengths for short headings (capped at 10 lines).
11+
`nowrap` keeps the entire paragraph on a single line.

packages/examples/vite/src/examples/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import math from './math';
2626
import mermaid from './mermaid';
2727
import passwordProtection from './password-protection';
2828
import softHyphens from './soft-hyphens';
29+
import textWrap from './text-wrap';
2930

3031
const EXAMPLES = [
3132
scripts,
@@ -56,6 +57,7 @@ const EXAMPLES = [
5657
mermaid,
5758
passwordProtection,
5859
softHyphens,
60+
textWrap,
5961
];
6062

6163
export default EXAMPLES;
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import React from 'react';
2+
import { Document, Page, View, Text, StyleSheet } from '@react-pdf/renderer';
3+
4+
const styles = StyleSheet.create({
5+
page: {
6+
backgroundColor: '#fafafa',
7+
padding: 40,
8+
},
9+
title: {
10+
fontSize: 18,
11+
fontWeight: 'bold',
12+
color: '#1a1a1a',
13+
},
14+
subtitle: {
15+
fontSize: 9,
16+
color: '#888',
17+
marginBottom: 20,
18+
},
19+
card: {
20+
backgroundColor: '#ffffff',
21+
borderRadius: 5,
22+
padding: 12,
23+
borderWidth: 1,
24+
borderColor: '#e8e8e8',
25+
marginBottom: 12,
26+
width: 254, // body width 230 + horizontal padding 24
27+
overflow: 'hidden',
28+
},
29+
cardLabel: {
30+
fontSize: 8,
31+
color: '#999',
32+
textTransform: 'uppercase',
33+
letterSpacing: 0.5,
34+
marginBottom: 6,
35+
},
36+
body: {
37+
fontSize: 11,
38+
color: '#333',
39+
lineHeight: 1.5,
40+
width: 230,
41+
},
42+
pretty: {
43+
textWrap: 'pretty',
44+
},
45+
balance: {
46+
textWrap: 'balance',
47+
},
48+
nowrap: {
49+
textWrap: 'nowrap',
50+
},
51+
note: {
52+
fontSize: 9,
53+
color: '#666',
54+
marginTop: 16,
55+
lineHeight: 1.5,
56+
},
57+
});
58+
59+
const PARAGRAPH =
60+
'Lorem ipsum dolor sit amet consectetur adipisicing elit. ' +
61+
'Voluptatem aut cum eum id quos est.';
62+
63+
// Disable hyphenation so the line-break differences are not muddled by
64+
// mid-word splits introduced by the default hyphenation engine.
65+
const noHyphenate = (word: string) => [word];
66+
67+
const TextWrap = () => (
68+
<Document>
69+
<Page size="A4" style={styles.page}>
70+
<Text style={styles.title}>text-wrap</Text>
71+
<Text style={styles.subtitle}>
72+
Same paragraph, same width, different textWrap values
73+
</Text>
74+
75+
<View style={styles.card}>
76+
<Text style={styles.cardLabel}>textWrap: wrap (default)</Text>
77+
<Text style={styles.body} hyphenationCallback={noHyphenate}>
78+
{PARAGRAPH}
79+
</Text>
80+
</View>
81+
82+
<View style={styles.card}>
83+
<Text style={styles.cardLabel}>textWrap: pretty</Text>
84+
<Text
85+
style={[styles.body, styles.pretty]}
86+
hyphenationCallback={noHyphenate}
87+
>
88+
{PARAGRAPH}
89+
</Text>
90+
</View>
91+
92+
<View style={styles.card}>
93+
<Text style={styles.cardLabel}>textWrap: balance</Text>
94+
<Text
95+
style={[styles.body, styles.balance]}
96+
hyphenationCallback={noHyphenate}
97+
>
98+
{PARAGRAPH}
99+
</Text>
100+
</View>
101+
102+
<View style={styles.card}>
103+
<Text style={styles.cardLabel}>textWrap: nowrap (overflow hidden)</Text>
104+
<Text
105+
style={[styles.body, styles.nowrap]}
106+
hyphenationCallback={noHyphenate}
107+
>
108+
{PARAGRAPH}
109+
</Text>
110+
</View>
111+
112+
<Text style={styles.note}>
113+
Following the CSS Text Module Level 4 specification, textWrap accepts
114+
wrap (default), pretty (avoid orphans on the last line), balance
115+
(equalize line lengths, capped at 10 lines), and nowrap (never break the
116+
line). Pair nowrap with overflow: hidden to clip text that exceeds the
117+
container width.
118+
</Text>
119+
</Page>
120+
</Document>
121+
);
122+
123+
export default {
124+
id: 'text-wrap',
125+
name: 'Text Wrap',
126+
description:
127+
'Line wrapping control via textWrap (wrap / pretty / balance / nowrap)',
128+
Document: TextWrap,
129+
};

packages/layout/src/text/layoutText.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const getMaxLines = (node) => node.style?.maxLines;
2828

2929
const getTextOverflow = (node) => node.style?.textOverflow;
3030

31+
const getTextWrap = (node) => node.style?.textWrap;
32+
3133
/**
3234
* Get layout container for specific text node
3335
*
@@ -63,6 +65,7 @@ const getLayoutOptions = (fontStore, node) => ({
6365
node.props.hyphenationCallback ||
6466
fontStore?.getHyphenationCallback() ||
6567
null,
68+
textWrap: getTextWrap(node),
6669
});
6770

6871
/**

packages/layout/tests/text/layoutText.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,40 @@ describe('text layoutText', () => {
103103
expect.any(Function),
104104
);
105105
});
106+
107+
test('Should keep at least two words on the last line with textWrap: pretty', () => {
108+
const text = 'alpha beta gamma delta epsilon zeta eta theta iota kappa';
109+
const countWords = (s: string) =>
110+
s.trim().split(/\s+/).filter(Boolean).length;
111+
112+
const node = createTextNode(text, { textWrap: 'pretty' });
113+
const lines = layoutText(node, 100, 1000, fontStore);
114+
115+
expect(lines.length).toBeGreaterThan(1);
116+
const lastLine = lines[lines.length - 1];
117+
expect(countWords(lastLine.string)).toBeGreaterThanOrEqual(2);
118+
});
119+
120+
test('Should keep the whole paragraph on a single line with textWrap: nowrap', () => {
121+
const text = 'alpha beta gamma delta epsilon zeta eta theta iota kappa';
122+
123+
const node = createTextNode(text, { textWrap: 'nowrap' });
124+
const lines = layoutText(node, 100, 1000, fontStore);
125+
126+
expect(lines).toHaveLength(1);
127+
expect(lines[0].string.trim()).toBe(text);
128+
});
129+
130+
test('Should preserve line count with textWrap: balance', () => {
131+
const text =
132+
'A short headline that should balance evenly across multiple lines';
133+
134+
const naturalNode = createTextNode(text);
135+
const balancedNode = createTextNode(text, { textWrap: 'balance' });
136+
137+
const naturalLines = layoutText(naturalNode, 220, 1000, fontStore);
138+
const balancedLines = layoutText(balancedNode, 220, 1000, fontStore);
139+
140+
expect(balancedLines).toHaveLength(naturalLines.length);
141+
});
106142
});

packages/stylesheet/src/resolve/text.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ const handlers = {
9393
textIndent: processNoopValue<'textIndent'>,
9494
textOverflow: processNoopValue<'textOverflow'>,
9595
textTransform: processNoopValue<'textTransform'>,
96+
textWrap: processNoopValue<'textWrap'>,
9697
verticalAlign: processNoopValue<'verticalAlign'>,
9798
};
9899

packages/stylesheet/src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,8 @@ export type TextTransform =
321321

322322
export type VerticalAlign = 'sub' | 'super';
323323

324+
export type TextWrap = 'wrap' | 'nowrap' | 'pretty' | 'balance';
325+
324326
export type TextStyle = {
325327
direction?: 'ltr' | 'rtl';
326328
fontSize?: number | string;
@@ -337,6 +339,7 @@ export type TextStyle = {
337339
textIndent?: any; // ?
338340
textOverflow?: 'ellipsis';
339341
textTransform?: TextTransform;
342+
textWrap?: TextWrap;
340343
verticalAlign?: VerticalAlign;
341344
};
342345

packages/stylesheet/tests/text.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,24 @@ describe('resolve stylesheet text', () => {
241241
expect(styles).toEqual({ textTransform: 'capitalize' });
242242
});
243243

244+
test('should resolve text wrap', () => {
245+
const styles = resolveStyle({ textWrap: 'pretty' });
246+
247+
expect(styles).toEqual({ textWrap: 'pretty' });
248+
});
249+
250+
test('should resolve text wrap nowrap', () => {
251+
const styles = resolveStyle({ textWrap: 'nowrap' });
252+
253+
expect(styles).toEqual({ textWrap: 'nowrap' });
254+
});
255+
256+
test('should resolve text wrap balance', () => {
257+
const styles = resolveStyle({ textWrap: 'balance' });
258+
259+
expect(styles).toEqual({ textWrap: 'balance' });
260+
});
261+
244262
test('should resolve text vertical align', () => {
245263
const styles = resolveStyle({ verticalAlign: 'sub' });
246264

packages/textkit/src/engines/linebreaker/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,19 @@ const getNodes = (
116116
return acc;
117117
}, []);
118118

119+
// CSS text-wrap: pretty — forbid breaking at the last whitespace so the
120+
// final line keeps at least the last two words together (avoid-orphans).
121+
// Inserting an infinity penalty before the trailing glue makes K&P's
122+
// `precedesBox` check fail at that position.
123+
if (options.textWrap === 'pretty') {
124+
for (let i = result.length - 1; i >= 0; i -= 1) {
125+
if (result[i].type === 'glue') {
126+
result.splice(i, 0, knuthPlass.penalty(0, knuthPlass.infinity, 0));
127+
break;
128+
}
129+
}
130+
}
131+
119132
// Add mandatory final glue
120133
result.push(knuthPlass.glue(0, start, start, knuthPlass.infinity, 0));
121134
result.push(knuthPlass.penalty(0, -knuthPlass.infinity, 1));

packages/textkit/src/layout/layoutParagraph.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,39 @@ const layoutLines = (
7373

7474
type layoutParagraphEngines = Pick<Engines, 'linebreaker'>;
7575

76+
// Mirror Firefox's `text-wrap: balance` cap. Above this, the spec lets us fall
77+
// back to plain wrap to avoid pathological binary-search cost on body copy.
78+
const BALANCE_LINE_LIMIT = 10;
79+
// Width precision (pt) for the balance binary search.
80+
const BALANCE_PRECISION = 1;
81+
82+
/**
83+
* Find the smallest container width that still yields the same number of
84+
* wrapped lines as the natural width. Equalizes line lengths for
85+
* `text-wrap: balance` (titles, short headings).
86+
*/
87+
const computeBalancedWidth = (
88+
linebreak: ReturnType<Engines['linebreaker']>,
89+
paragraph: AttributedString,
90+
width: number,
91+
naturalLineCount: number,
92+
): number => {
93+
let lo = 0;
94+
let hi = width;
95+
96+
while (hi - lo > BALANCE_PRECISION) {
97+
const mid = (lo + hi) / 2;
98+
const lines = linebreak(paragraph, [mid]);
99+
if (lines.length === naturalLineCount) {
100+
hi = mid;
101+
} else {
102+
lo = mid;
103+
}
104+
}
105+
106+
return hi;
107+
};
108+
76109
/**
77110
* Performs line breaking and layout
78111
*
@@ -92,11 +125,37 @@ const layoutParagraph = (
92125
const height = stringHeight(paragraph);
93126
const indent = paragraph.runs?.[0]?.attributes?.indent || 0;
94127
const rects = generateLineRects(container, height);
128+
const linebreak = engines.linebreaker(options);
129+
130+
let availableWidths: number[];
131+
132+
if (options.textWrap === 'nowrap') {
133+
availableWidths = [Infinity];
134+
} else if (options.textWrap === 'balance') {
135+
const naturalWidths = rects.map((r) => r.width);
136+
const naturalLines = linebreak(paragraph, naturalWidths);
137+
138+
if (
139+
naturalLines.length > 1 &&
140+
naturalLines.length <= BALANCE_LINE_LIMIT
141+
) {
142+
const balanced = computeBalancedWidth(
143+
linebreak,
144+
paragraph,
145+
naturalWidths[0],
146+
naturalLines.length,
147+
);
148+
availableWidths = [balanced];
149+
} else {
150+
availableWidths = naturalWidths;
151+
availableWidths.unshift(availableWidths[0] - indent);
152+
}
153+
} else {
154+
availableWidths = rects.map((r) => r.width);
155+
availableWidths.unshift(availableWidths[0] - indent);
156+
}
95157

96-
const availableWidths = rects.map((r) => r.width);
97-
availableWidths.unshift(availableWidths[0] - indent);
98-
99-
const lines = engines.linebreaker(options)(paragraph, availableWidths);
158+
const lines = linebreak(paragraph, availableWidths);
100159

101160
return layoutLines(rects, lines, indent);
102161
};

0 commit comments

Comments
 (0)