Skip to content

Commit 724bddf

Browse files
committed
feat: CSS-compatible workBreak, hyphens and hyphenateCharacter
1 parent d41a820 commit 724bddf

12 files changed

Lines changed: 688 additions & 287 deletions

File tree

.changeset/wicked-rockets-check.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
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+
Add CSS-compatible text layout properties: wordBreak, hyphens and hyphenateCharacterord

packages/examples/vite/src/examples/soft-hyphens/index.tsx

Lines changed: 194 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@ import {
88
StyleSheet,
99
} from '@react-pdf/renderer';
1010

11-
const shy = '\u00ad';
11+
const shy = '­';
1212

1313
Font.register({
1414
family: 'Oswald',
1515
src: 'https://fonts.gstatic.com/s/oswald/v13/Y_TKV6o8WovbUd3m_X9aAA.ttf',
1616
});
1717

18+
Font.register({
19+
family: 'NotoSansJP',
20+
src: 'https://fonts.gstatic.com/s/notosansjp/v52/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj75s.ttf',
21+
});
22+
1823
const styles = StyleSheet.create({
1924
page: {
2025
padding: 40,
@@ -74,6 +79,60 @@ const styles = StyleSheet.create({
7479
borderRadius: 3,
7580
padding: 8,
7681
},
82+
// Page 2 (hyphens-control) styles
83+
page2: {
84+
padding: 30,
85+
backgroundColor: '#fafafa',
86+
},
87+
sectionTitle: {
88+
fontFamily: 'Oswald',
89+
fontSize: 12,
90+
fontWeight: 'bold',
91+
color: '#1a1a1a',
92+
marginBottom: 2,
93+
},
94+
sectionSubtitle: {
95+
fontSize: 9,
96+
color: '#888',
97+
marginBottom: 6,
98+
},
99+
sectionSubtitleJP: {
100+
fontFamily: 'NotoSansJP',
101+
fontSize: 9,
102+
color: '#888',
103+
marginBottom: 6,
104+
},
105+
controlSection: {
106+
marginBottom: 10,
107+
},
108+
smallBox: {
109+
backgroundColor: '#ffffff',
110+
borderRadius: 3,
111+
borderWidth: 1,
112+
borderColor: '#e8e8e8',
113+
padding: 6,
114+
width: 95,
115+
marginRight: 8,
116+
},
117+
wideBox: {
118+
backgroundColor: '#ffffff',
119+
borderRadius: 3,
120+
borderWidth: 1,
121+
borderColor: '#e8e8e8',
122+
padding: 6,
123+
width: 240,
124+
marginRight: 8,
125+
},
126+
englishText: {
127+
fontFamily: 'Oswald',
128+
fontSize: 11,
129+
color: '#1a1a1a',
130+
},
131+
japaneseText: {
132+
fontFamily: 'NotoSansJP',
133+
fontSize: 11,
134+
color: '#1a1a1a',
135+
},
77136
});
78137

79138
const dutchWord = `Potentieel broeikas${shy}gas${shy}emissie${shy}rapport`;
@@ -84,6 +143,7 @@ const widths = [80, 120, 180];
84143

85144
const SoftHyphens = () => (
86145
<Document>
146+
{/* Page 1: Soft hyphen (U+00AD) auto break demonstration */}
87147
<Page style={styles.page}>
88148
<Text style={styles.title}>Soft Hyphens</Text>
89149
<Text style={styles.subtitle}>
@@ -159,12 +219,143 @@ const SoftHyphens = () => (
159219
</View>
160220
</View>
161221
</Page>
222+
223+
{/* Page 2: hyphens / hyphenateCharacter / wordBreak CSS controls */}
224+
<Page style={styles.page2}>
225+
<Text style={styles.title}>Hyphen & Word Break Controls</Text>
226+
<Text style={[styles.subtitle, { marginBottom: 12 }]}>
227+
Demonstrating hyphens, hyphenateCharacter, and wordBreak CSS properties
228+
</Text>
229+
230+
<View style={styles.controlSection}>
231+
<Text style={styles.sectionTitle}>1. Hyphen Control (English)</Text>
232+
<Text style={styles.sectionSubtitle}>
233+
Long word in narrow container — control hyphen character
234+
</Text>
235+
236+
<View style={{ flexDirection: 'row' }}>
237+
<View style={styles.smallBox}>
238+
<Text style={styles.label}>Default (hyphen)</Text>
239+
<Text style={styles.englishText}>
240+
Potentieelbroeikasgasemissierapport
241+
</Text>
242+
</View>
243+
244+
<View style={styles.smallBox}>
245+
<Text style={styles.label}>hyphens: none</Text>
246+
<Text style={[styles.englishText, { hyphens: 'none' }]}>
247+
Potentieelbroeikasgasemissierapport
248+
</Text>
249+
</View>
250+
251+
<View style={styles.smallBox}>
252+
<Text style={styles.label}>hyphenateCharacter: ...</Text>
253+
<Text style={[styles.englishText, { hyphenateCharacter: '...' }]}>
254+
Potentieelbroeikasgasemissierapport
255+
</Text>
256+
</View>
257+
</View>
258+
</View>
259+
260+
<View style={styles.controlSection}>
261+
<Text style={styles.sectionTitle}>2. CJK Text — wordBreak</Text>
262+
<Text style={styles.sectionSubtitleJP}>
263+
Problem: &quot;グレートブリテン&quot; alone on a line due to
264+
script-based run splitting
265+
</Text>
266+
267+
<View style={{ flexDirection: 'row' }}>
268+
<View style={styles.smallBox}>
269+
<Text style={styles.label}>wordBreak: keep-all (problem)</Text>
270+
<Text
271+
style={[
272+
styles.japaneseText,
273+
{ wordBreak: 'keep-all', hyphens: 'none' },
274+
]}
275+
>
276+
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
277+
</Text>
278+
</View>
279+
280+
<View style={styles.smallBox}>
281+
<Text style={styles.label}>
282+
wordBreak: normal (CJK breaks anywhere)
283+
</Text>
284+
<Text style={[styles.japaneseText, { wordBreak: 'normal' }]}>
285+
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
286+
</Text>
287+
</View>
288+
</View>
289+
</View>
290+
291+
<View style={styles.controlSection}>
292+
<Text style={styles.sectionTitle}>
293+
3. Mixed Content (Japanese + English)
294+
</Text>
295+
<Text style={styles.sectionSubtitle}>
296+
CJK breaks anywhere, Latin only at hyphenation points
297+
</Text>
298+
299+
<View style={{ flexDirection: 'row' }}>
300+
<View style={styles.wideBox}>
301+
<Text style={styles.label}>wordBreak: normal</Text>
302+
<Text style={[styles.japaneseText, { wordBreak: 'normal' }]}>
303+
This is a long and Honorificabilitudinitatibus
304+
califragilisticexpialidocious
305+
Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu
306+
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
307+
</Text>
308+
</View>
309+
310+
<View style={styles.wideBox}>
311+
<Text style={styles.label}>wordBreak: break-all</Text>
312+
<Text style={[styles.japaneseText, { wordBreak: 'break-all' }]}>
313+
This is a long and Honorificabilitudinitatibus
314+
califragilisticexpialidocious
315+
Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu
316+
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
317+
</Text>
318+
</View>
319+
</View>
320+
</View>
321+
322+
<View style={styles.controlSection}>
323+
<Text style={styles.sectionTitle}>4. Long URLs</Text>
324+
<Text style={styles.sectionSubtitle}>
325+
break-all allows URLs to wrap at any character
326+
</Text>
327+
328+
<View style={{ flexDirection: 'row' }}>
329+
<View style={styles.wideBox}>
330+
<Text style={styles.label}>wordBreak: normal (overflow)</Text>
331+
<Text style={[styles.englishText, { wordBreak: 'normal' }]}>
332+
https://example.com/very/very/loooong/path/to/resource
333+
</Text>
334+
</View>
335+
336+
<View style={styles.wideBox}>
337+
<Text style={styles.label}>
338+
wordBreak: break-all, hyphens: none
339+
</Text>
340+
<Text
341+
style={[
342+
styles.englishText,
343+
{ wordBreak: 'break-all', hyphens: 'none' },
344+
]}
345+
>
346+
https://example.com/very/very/loooong/path/to/resource
347+
</Text>
348+
</View>
349+
</View>
350+
</View>
351+
</Page>
162352
</Document>
163353
);
164354

165355
export default {
166356
id: 'soft-hyphens',
167-
name: 'Soft Hyphens',
168-
description: '',
357+
name: 'Hyphenation',
358+
description:
359+
'Soft hyphen (U+00AD) auto-break and the hyphens / hyphenateCharacter / wordBreak CSS controls',
169360
Document: SoftHyphens,
170361
};

packages/layout/src/text/layoutText.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ const getLayoutOptions = (fontStore, node) => ({
6363
node.props.hyphenationCallback ||
6464
fontStore?.getHyphenationCallback() ||
6565
null,
66+
hyphens: node.style?.hyphens,
67+
hyphenateCharacter: node.style?.hyphenateCharacter,
68+
wordBreak: node.style?.wordBreak,
6669
});
6770

6871
/**

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,84 @@ describe('text layoutText', () => {
103103
expect.any(Function),
104104
);
105105
});
106+
107+
test('should not add hyphens when hyphens style is "none"', async () => {
108+
const text = 'reallylongtext';
109+
const hyphens = ['really­', 'long', 'text'];
110+
const hyphenationCallback = vi.fn().mockReturnValue(hyphens);
111+
112+
const node = createTextNode(
113+
text,
114+
{ hyphens: 'none' },
115+
{ hyphenationCallback },
116+
);
117+
const lines = layoutText(node, 50, 100, fontStore);
118+
119+
expect(lines[0].string).toEqual('really');
120+
expect(lines[1].string).toEqual('long');
121+
expect(lines[2].string).toEqual('text');
122+
});
123+
124+
test('should use custom hyphenate character when hyphenateCharacter is set', async () => {
125+
const text = 'reallylongtext';
126+
const hyphens = ['really­', 'long', 'text'];
127+
const hyphenationCallback = vi.fn().mockReturnValue(hyphens);
128+
129+
const node = createTextNode(
130+
text,
131+
{ hyphenateCharacter: '・' },
132+
{ hyphenationCallback },
133+
);
134+
const lines = layoutText(node, 50, 100, fontStore);
135+
136+
expect(lines[0].string).toEqual('really・');
137+
expect(lines[1].string).toEqual('long・');
138+
expect(lines[2].string).toEqual('text');
139+
});
140+
141+
test('should not add hyphens when hyphenateCharacter is empty string', async () => {
142+
const text = 'reallylongtext';
143+
const hyphens = ['really­', 'long', 'text'];
144+
const hyphenationCallback = vi.fn().mockReturnValue(hyphens);
145+
146+
const node = createTextNode(
147+
text,
148+
{ hyphenateCharacter: '' },
149+
{ hyphenationCallback },
150+
);
151+
const lines = layoutText(node, 50, 100, fontStore);
152+
153+
expect(lines[0].string).toEqual('really');
154+
expect(lines[1].string).toEqual('long');
155+
expect(lines[2].string).toEqual('text');
156+
});
157+
158+
test('should keep CJK text together with wordBreak: keep-all', async () => {
159+
const text = '東京都区';
160+
const hyphenationCallback = vi.fn().mockImplementation((word) => [...word]);
161+
162+
const node = createTextNode(
163+
text,
164+
{ wordBreak: 'keep-all' },
165+
{ hyphenationCallback },
166+
);
167+
const lines = layoutText(node, 300, 100, fontStore);
168+
169+
expect(lines).toHaveLength(1);
170+
expect(lines[0].string).toBe('東京都区');
171+
});
172+
173+
test('should break all characters with wordBreak: break-all', async () => {
174+
const text = 'Hello';
175+
const node = createTextNode(text, {
176+
wordBreak: 'break-all',
177+
hyphens: 'none',
178+
});
179+
const lines = layoutText(node, 15, 100, fontStore);
180+
181+
expect(lines.length).toBeGreaterThan(1);
182+
183+
const allChars = lines.map((line) => line.string).join('');
184+
expect(allChars).toBe('Hello');
185+
});
106186
});

packages/stylesheet/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,12 +321,19 @@ export type TextTransform =
321321

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

324+
export type Hyphens = 'none' | 'manual' | 'auto';
325+
326+
export type WordBreak = 'normal' | 'break-all' | 'keep-all';
327+
324328
export type TextStyle = {
325329
direction?: 'ltr' | 'rtl';
326330
fontSize?: number | string;
327331
fontFamily?: string | string[];
328332
fontStyle?: FontStyle;
329333
fontWeight?: FontWeight;
334+
hyphens?: Hyphens;
335+
hyphenateCharacter?: string;
336+
wordBreak?: WordBreak;
330337
letterSpacing?: number | string;
331338
lineHeight?: number | string;
332339
maxLines?: number | string;

packages/textkit/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@react-pdf/fns": "3.1.3",
2929
"bidi-js": "^1.0.2",
3030
"hyphen": "^1.6.4",
31+
"linebreak": "^1.1.0",
3132
"unicode-properties": "^1.4.1"
3233
},
3334
"devDependencies": {

0 commit comments

Comments
 (0)