Skip to content

Commit 4874827

Browse files
committed
feat: CSS-compatible workBreak, hyphens and hyphenateChracter
1 parent 16cf5bd commit 4874827

13 files changed

Lines changed: 1471 additions & 6 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
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
import React from 'react';
2+
import { Document, Page, Font, Text, View, StyleSheet } from '@react-pdf/renderer';
3+
4+
// Register fonts
5+
Font.register({
6+
family: 'Oswald',
7+
src: 'https://fonts.gstatic.com/s/oswald/v13/Y_TKV6o8WovbUd3m_X9aAA.ttf',
8+
});
9+
10+
Font.register({
11+
family: 'NotoSansJP',
12+
src: 'https://fonts.gstatic.com/s/notosansjp/v52/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj75s.ttf',
13+
});
14+
15+
const styles = StyleSheet.create({
16+
page: {
17+
padding: 30,
18+
},
19+
section: {
20+
marginBottom: 20,
21+
},
22+
title: {
23+
fontSize: 14,
24+
fontWeight: 'bold',
25+
marginBottom: 8,
26+
fontFamily: 'Helvetica-Bold',
27+
},
28+
subtitle: {
29+
fontSize: 10,
30+
marginBottom: 5,
31+
color: '#666',
32+
fontFamily: 'Helvetica',
33+
},
34+
row: {
35+
flexDirection: 'row',
36+
marginBottom: 10,
37+
},
38+
box: {
39+
width: 80,
40+
marginRight: 8,
41+
padding: 5,
42+
border: '1px solid #ccc',
43+
},
44+
boxWide: {
45+
width: 100,
46+
marginRight: 8,
47+
padding: 5,
48+
border: '1px solid #ccc',
49+
},
50+
label: {
51+
fontSize: 7,
52+
color: '#999',
53+
marginBottom: 3,
54+
fontFamily: 'Helvetica',
55+
},
56+
englishText: {
57+
fontFamily: 'Oswald',
58+
fontSize: 12,
59+
},
60+
japaneseText: {
61+
fontFamily: 'NotoSansJP',
62+
fontSize: 12,
63+
},
64+
usageText: {
65+
fontSize: 9,
66+
fontFamily: 'Helvetica',
67+
marginBottom: 4,
68+
},
69+
});
70+
71+
const HyphensControl = () => (
72+
<Document>
73+
<Page style={styles.page}>
74+
{/* English Hyphen Examples */}
75+
<View style={styles.section}>
76+
<Text style={styles.title}>1. Hyphen Control (English)</Text>
77+
<Text style={styles.subtitle}>
78+
Long word in narrow container - control hyphen character
79+
</Text>
80+
81+
<View style={styles.row}>
82+
<View style={styles.box}>
83+
<Text style={styles.label}>Default (hyphen)</Text>
84+
<Text style={styles.englishText}>
85+
Potentieelbroeikasgasemissierapport
86+
</Text>
87+
</View>
88+
89+
<View style={styles.box}>
90+
<Text style={styles.label}>hyphens: none</Text>
91+
<Text style={[styles.englishText, { hyphens: 'none' }]}>
92+
Potentieelbroeikasgasemissierapport
93+
</Text>
94+
</View>
95+
96+
<View style={styles.box}>
97+
<Text style={styles.label}>hyphenateCharacter: ...</Text>
98+
<Text style={[styles.englishText, { hyphenateCharacter: '...' }]}>
99+
Potentieelbroeikasgasemissierapport
100+
</Text>
101+
</View>
102+
</View>
103+
</View>
104+
105+
{/* Japanese without word-break */}
106+
<View style={styles.section}>
107+
<Text style={styles.title}>2. Japanese Text - Without word-break</Text>
108+
<Text style={styles.subtitle}>
109+
Problem: &quot;い&quot; alone on a line due to script-based run splitting
110+
</Text>
111+
112+
<View style={styles.row}>
113+
<View style={styles.box}>
114+
<Text style={styles.label}>keep-all (problem)</Text>
115+
<Text style={[styles.japaneseText, { wordBreak: 'keep-all', hyphens: 'none' }]}>
116+
本当に長いテキスト
117+
</Text>
118+
</View>
119+
</View>
120+
</View>
121+
122+
{/* Japanese with word-break: normal */}
123+
<View style={styles.section}>
124+
<Text style={styles.title}>3. Japanese Text - With word-break: normal</Text>
125+
<Text style={styles.subtitle}>
126+
Solution: CJK characters break at any position, no hyphens
127+
</Text>
128+
129+
<View style={styles.row}>
130+
<View style={styles.box}>
131+
<Text style={styles.label}>wordBreak: normal</Text>
132+
<Text style={[styles.japaneseText, { wordBreak: 'normal', hyphens: 'none' }]}>
133+
本当に長いテキスト
134+
</Text>
135+
</View>
136+
137+
<View style={styles.boxWide}>
138+
<Text style={styles.label}>Longer text</Text>
139+
<Text style={[styles.japaneseText, { wordBreak: 'normal', hyphens: 'none' }]}>
140+
これは本当に長いテキストです。日本語は自然に折り返されます。
141+
</Text>
142+
</View>
143+
</View>
144+
</View>
145+
146+
{/* Mixed content */}
147+
<View style={styles.section}>
148+
<Text style={styles.title}>4. Mixed Content (Japanese + English)</Text>
149+
<Text style={styles.subtitle}>
150+
CJK breaks anywhere, Latin only at hyphenation points
151+
</Text>
152+
153+
<View style={styles.row}>
154+
<View style={styles.boxWide}>
155+
<Text style={styles.label}>wordBreak: normal</Text>
156+
<Text style={[styles.japaneseText, { wordBreak: 'normal', hyphens: 'none' }]}>
157+
Hello世界!これはテストです。
158+
</Text>
159+
</View>
160+
161+
<View style={styles.boxWide}>
162+
<Text style={styles.label}>wordBreak: break-all</Text>
163+
<Text style={[styles.japaneseText, { wordBreak: 'break-all', hyphens: 'none' }]}>
164+
Hello世界!これはテストです。
165+
</Text>
166+
</View>
167+
</View>
168+
</View>
169+
170+
{/* URL example */}
171+
<View style={styles.section}>
172+
<Text style={styles.title}>5. Long URLs</Text>
173+
<Text style={styles.subtitle}>
174+
break-all allows URLs to wrap at any character
175+
</Text>
176+
177+
<View style={styles.row}>
178+
<View style={styles.boxWide}>
179+
<Text style={styles.label}>normal (overflow)</Text>
180+
<Text style={[styles.englishText, { wordBreak: 'normal', fontSize: 8 }]}>
181+
https://example.com/very/long/path/to/resource
182+
</Text>
183+
</View>
184+
185+
<View style={styles.boxWide}>
186+
<Text style={styles.label}>break-all (wraps)</Text>
187+
<Text style={[styles.englishText, { wordBreak: 'break-all', hyphens: 'none', fontSize: 8 }]}>
188+
https://example.com/very/long/path/to/resource
189+
</Text>
190+
</View>
191+
</View>
192+
</View>
193+
194+
{/* Usage Reference */}
195+
<View style={styles.section}>
196+
<Text style={styles.title}>CSS Properties Reference</Text>
197+
<Text style={styles.usageText}>
198+
hyphens: none | manual | auto - Control hyphen insertion
199+
</Text>
200+
<Text style={styles.usageText}>
201+
hyphenateCharacter: string - Custom character (empty = no hyphen)
202+
</Text>
203+
<Text style={styles.usageText}>
204+
wordBreak: normal | break-all | keep-all - Control line break behavior
205+
</Text>
206+
<Text style={[styles.usageText, { marginTop: 5, color: '#666' }]}>
207+
Tip: For Japanese, use wordBreak: normal (default). CJK automatically
208+
breaks at any character without hyphens.
209+
</Text>
210+
</View>
211+
</Page>
212+
</Document>
213+
);
214+
215+
export default {
216+
id: 'hyphens-control',
217+
name: 'Hyphens Control',
218+
description: 'Demonstrates hyphens, hyphenateCharacter, and wordBreak CSS properties',
219+
Document: HyphensControl,
220+
};

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import fontFamilyFallback from './font-family-fallback';
55
import fontWeight from './font-weight';
66
import fractals from './fractals';
77
import goTo from './go-to';
8+
import hyphensControl from './hyphens-control';
89
import imageStressTest from './image-stress-test';
910
import JpgOrientation from './jpg-orientation';
1011
import knobs from './knobs';
@@ -29,6 +30,7 @@ const EXAMPLES = [
2930
fontWeight,
3031
fractals,
3132
goTo,
33+
hyphensControl,
3234
JpgOrientation,
3335
knobs,
3436
link,

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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,43 @@ 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(text, { hyphens: 'none' }, { hyphenationCallback });
113+
const lines = layoutText(node, 50, 100, fontStore);
114+
115+
expect(lines[0].string).toEqual('really');
116+
expect(lines[1].string).toEqual('long');
117+
expect(lines[2].string).toEqual('text');
118+
});
119+
120+
test('should use custom hyphenate character when hyphenateCharacter is set', async () => {
121+
const text = 'reallylongtext';
122+
const hyphens = ['really­', 'long', 'text'];
123+
const hyphenationCallback = vi.fn().mockReturnValue(hyphens);
124+
125+
const node = createTextNode(text, { hyphenateCharacter: '・' }, { hyphenationCallback });
126+
const lines = layoutText(node, 50, 100, fontStore);
127+
128+
expect(lines[0].string).toEqual('really・');
129+
expect(lines[1].string).toEqual('long・');
130+
expect(lines[2].string).toEqual('text');
131+
});
132+
133+
test('should not add hyphens when hyphenateCharacter is empty string', async () => {
134+
const text = 'reallylongtext';
135+
const hyphens = ['really­', 'long', 'text'];
136+
const hyphenationCallback = vi.fn().mockReturnValue(hyphens);
137+
138+
const node = createTextNode(text, { hyphenateCharacter: '' }, { hyphenationCallback });
139+
const lines = layoutText(node, 50, 100, fontStore);
140+
141+
expect(lines[0].string).toEqual('really');
142+
expect(lines[1].string).toEqual('long');
143+
expect(lines[2].string).toEqual('text');
144+
});
106145
});

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.2",
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)