Skip to content

Commit 1d0641e

Browse files
committed
feat: CSS-compatible workBreak, hyphens and hyphenateCharacter
1 parent 143af59 commit 1d0641e

13 files changed

Lines changed: 1614 additions & 9 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: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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+
subtitleJP: {
35+
fontSize: 10,
36+
marginBottom: 5,
37+
color: '#666',
38+
fontFamily: 'NotoSansJP',
39+
},
40+
row: {
41+
flexDirection: 'row',
42+
marginBottom: 10,
43+
},
44+
box: {
45+
width: 100,
46+
marginRight: 8,
47+
padding: 5,
48+
border: '1px solid #ccc',
49+
},
50+
boxWide: {
51+
width: 300,
52+
marginRight: 8,
53+
padding: 5,
54+
border: '1px solid #ccc',
55+
},
56+
label: {
57+
fontSize: 7,
58+
color: '#999',
59+
marginBottom: 3,
60+
fontFamily: 'Helvetica',
61+
},
62+
englishText: {
63+
fontFamily: 'Oswald',
64+
fontSize: 12,
65+
},
66+
japaneseText: {
67+
fontFamily: 'NotoSansJP',
68+
fontSize: 12,
69+
},
70+
usageText: {
71+
fontSize: 9,
72+
fontFamily: 'Helvetica',
73+
marginBottom: 4,
74+
},
75+
});
76+
77+
const HyphensControl = () => (
78+
<Document>
79+
<Page style={styles.page}>
80+
{/* English Hyphen Examples */}
81+
<View style={styles.section}>
82+
<Text style={styles.title}>1. Hyphen Control (English)</Text>
83+
<Text style={styles.subtitle}>
84+
Long word in narrow container - control hyphen character
85+
</Text>
86+
87+
<View style={styles.row}>
88+
<View style={styles.box}>
89+
<Text style={styles.label}>Default (hyphen)</Text>
90+
<Text style={styles.englishText}>
91+
Potentieelbroeikasgasemissierapport
92+
</Text>
93+
</View>
94+
95+
<View style={styles.box}>
96+
<Text style={styles.label}>hyphens: none</Text>
97+
<Text style={[styles.englishText, { hyphens: 'none' }]}>
98+
Potentieelbroeikasgasemissierapport
99+
</Text>
100+
</View>
101+
102+
<View style={styles.box}>
103+
<Text style={styles.label}>hyphenateCharacter: ...</Text>
104+
<Text style={[styles.englishText, { hyphenateCharacter: '...' }]}>
105+
Potentieelbroeikasgasemissierapport
106+
</Text>
107+
</View>
108+
</View>
109+
</View>
110+
111+
{/* Japanese without word-break */}
112+
<View style={styles.section}>
113+
<Text style={styles.title}>2. CJK Text - word-break</Text>
114+
<Text style={styles.subtitleJP}>
115+
Problem: &quot;グレートブリテン&quot; alone on a line due to
116+
script-based run splitting
117+
</Text>
118+
119+
<View style={styles.row}>
120+
<View style={styles.box}>
121+
<Text style={styles.label}>wordBreak: keep-all (problem)</Text>
122+
<Text
123+
style={[
124+
styles.japaneseText,
125+
{ wordBreak: 'keep-all', hyphens: 'none' },
126+
]}
127+
>
128+
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
129+
</Text>
130+
</View>
131+
132+
<View style={styles.box}>
133+
<Text style={styles.label}>
134+
wordBreak: normal (CJK characters break at any position)
135+
</Text>
136+
<Text style={[styles.japaneseText, { wordBreak: 'normal' }]}>
137+
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
138+
</Text>
139+
</View>
140+
</View>
141+
</View>
142+
143+
{/* Mixed content */}
144+
<View style={styles.section}>
145+
<Text style={styles.title}>4. Mixed Content (Japanese + English)</Text>
146+
<Text style={styles.subtitle}>
147+
CJK breaks anywhere, Latin only at hyphenation points
148+
</Text>
149+
150+
<View style={styles.row}>
151+
<View style={styles.boxWide}>
152+
<Text style={styles.label}>wordBreak: normal</Text>
153+
<Text style={[styles.japaneseText, { wordBreak: 'normal' }]}>
154+
This is a long and Honorificabilitudinitatibus
155+
califragilisticexpialidocious
156+
Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu
157+
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
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' }]}>
164+
This is a long and Honorificabilitudinitatibus
165+
califragilisticexpialidocious
166+
Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu
167+
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
168+
</Text>
169+
</View>
170+
</View>
171+
</View>
172+
173+
{/* URL example */}
174+
<View style={styles.section}>
175+
<Text style={styles.title}>5. Long URLs</Text>
176+
<Text style={styles.subtitle}>
177+
break-all allows URLs to wrap at any character
178+
</Text>
179+
180+
<View style={styles.row}>
181+
<View style={styles.boxWide}>
182+
<Text style={styles.label}>wordBreak: normal (overflow)</Text>
183+
<Text style={[styles.englishText, { wordBreak: 'normal' }]}>
184+
https://example.com/very/very/loooong/path/to/resource
185+
</Text>
186+
</View>
187+
188+
<View style={styles.boxWide}>
189+
<Text style={styles.label}>
190+
wordBreak: break-all (wraps), hyphens: none
191+
</Text>
192+
<Text
193+
style={[
194+
styles.englishText,
195+
{ wordBreak: 'break-all', hyphens: 'none' },
196+
]}
197+
>
198+
https://example.com/very/very/loooong/path/to/resource
199+
</Text>
200+
</View>
201+
</View>
202+
</View>
203+
</Page>
204+
</Document>
205+
);
206+
207+
export default {
208+
id: 'hyphens-control',
209+
name: 'Hyphens Control',
210+
description: 'Demonstrates hyphens, hyphenateCharacter, and wordBreak CSS properties',
211+
Document: HyphensControl,
212+
};

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import fontFamilyFallback from './font-family-fallback';
66
import fontWeight from './font-weight';
77
import goTo from './go-to';
88
import imageBackground from './image-background';
9+
import hyphensControl from './hyphens-control';
910
import imageStressTest from './image-stress-test';
1011
import JpgOrientation from './jpg-orientation';
1112
import knobs from './knobs';
@@ -35,6 +36,7 @@ const EXAMPLES = [
3536
fontFamilyFallback,
3637
fontWeight,
3738
goTo,
39+
hyphensControl,
3840
JpgOrientation,
3941
knobs,
4042
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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,55 @@ 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+
});
106157
});

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)