Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/wicked-rockets-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@react-pdf/vite-example": minor
"@react-pdf/stylesheet": minor
"@react-pdf/textkit": minor
"@react-pdf/layout": minor
---

Add CSS-compatible text layout properties: wordBreak, hyphens and hyphenateCharacterord
197 changes: 194 additions & 3 deletions packages/examples/vite/src/examples/soft-hyphens/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ import {
StyleSheet,
} from '@react-pdf/renderer';

const shy = '\u00ad';
const shy = '­';

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

Font.register({
family: 'NotoSansJP',
src: 'https://fonts.gstatic.com/s/notosansjp/v52/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj75s.ttf',
});

const styles = StyleSheet.create({
page: {
padding: 40,
Expand Down Expand Up @@ -74,6 +79,60 @@ const styles = StyleSheet.create({
borderRadius: 3,
padding: 8,
},
// Page 2 (hyphens-control) styles
page2: {
padding: 30,
backgroundColor: '#fafafa',
},
sectionTitle: {
fontFamily: 'Oswald',
fontSize: 12,
fontWeight: 'bold',
color: '#1a1a1a',
marginBottom: 2,
},
sectionSubtitle: {
fontSize: 9,
color: '#888',
marginBottom: 6,
},
sectionSubtitleJP: {
fontFamily: 'NotoSansJP',
fontSize: 9,
color: '#888',
marginBottom: 6,
},
controlSection: {
marginBottom: 10,
},
smallBox: {
backgroundColor: '#ffffff',
borderRadius: 3,
borderWidth: 1,
borderColor: '#e8e8e8',
padding: 6,
width: 95,
marginRight: 8,
},
wideBox: {
backgroundColor: '#ffffff',
borderRadius: 3,
borderWidth: 1,
borderColor: '#e8e8e8',
padding: 6,
width: 240,
marginRight: 8,
},
englishText: {
fontFamily: 'Oswald',
fontSize: 11,
color: '#1a1a1a',
},
japaneseText: {
fontFamily: 'NotoSansJP',
fontSize: 11,
color: '#1a1a1a',
},
});

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

const SoftHyphens = () => (
<Document>
{/* Page 1: Soft hyphen (U+00AD) auto break demonstration */}
<Page style={styles.page}>
<Text style={styles.title}>Soft Hyphens</Text>
<Text style={styles.subtitle}>
Expand Down Expand Up @@ -159,12 +219,143 @@ const SoftHyphens = () => (
</View>
</View>
</Page>

{/* Page 2: hyphens / hyphenateCharacter / wordBreak CSS controls */}
<Page style={styles.page2}>
<Text style={styles.title}>Hyphen & Word Break Controls</Text>
<Text style={[styles.subtitle, { marginBottom: 12 }]}>
Demonstrating hyphens, hyphenateCharacter, and wordBreak CSS properties
</Text>

<View style={styles.controlSection}>
<Text style={styles.sectionTitle}>1. Hyphen Control (English)</Text>
<Text style={styles.sectionSubtitle}>
Long word in narrow container — control hyphen character
</Text>

<View style={{ flexDirection: 'row' }}>
<View style={styles.smallBox}>
<Text style={styles.label}>Default (hyphen)</Text>
<Text style={styles.englishText}>
Potentieelbroeikasgasemissierapport
</Text>
</View>

<View style={styles.smallBox}>
<Text style={styles.label}>hyphens: none</Text>
<Text style={[styles.englishText, { hyphens: 'none' }]}>
Potentieelbroeikasgasemissierapport
</Text>
</View>

<View style={styles.smallBox}>
<Text style={styles.label}>hyphenateCharacter: ...</Text>
<Text style={[styles.englishText, { hyphenateCharacter: '...' }]}>
Potentieelbroeikasgasemissierapport
</Text>
</View>
</View>
</View>

<View style={styles.controlSection}>
<Text style={styles.sectionTitle}>2. CJK Text — wordBreak</Text>
<Text style={styles.sectionSubtitleJP}>
Problem: &quot;グレートブリテン&quot; alone on a line due to
script-based run splitting
</Text>

<View style={{ flexDirection: 'row' }}>
<View style={styles.smallBox}>
<Text style={styles.label}>wordBreak: keep-all (problem)</Text>
<Text
style={[
styles.japaneseText,
{ wordBreak: 'keep-all', hyphens: 'none' },
]}
>
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
</Text>
</View>

<View style={styles.smallBox}>
<Text style={styles.label}>
wordBreak: normal (CJK breaks anywhere)
</Text>
<Text style={[styles.japaneseText, { wordBreak: 'normal' }]}>
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
</Text>
</View>
</View>
</View>

<View style={styles.controlSection}>
<Text style={styles.sectionTitle}>
3. Mixed Content (Japanese + English)
</Text>
<Text style={styles.sectionSubtitle}>
CJK breaks anywhere, Latin only at hyphenation points
</Text>

<View style={{ flexDirection: 'row' }}>
<View style={styles.wideBox}>
<Text style={styles.label}>wordBreak: normal</Text>
<Text style={[styles.japaneseText, { wordBreak: 'normal' }]}>
This is a long and Honorificabilitudinitatibus
califragilisticexpialidocious
Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
</Text>
</View>

<View style={styles.wideBox}>
<Text style={styles.label}>wordBreak: break-all</Text>
<Text style={[styles.japaneseText, { wordBreak: 'break-all' }]}>
This is a long and Honorificabilitudinitatibus
califragilisticexpialidocious
Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu
グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉
</Text>
</View>
</View>
</View>

<View style={styles.controlSection}>
<Text style={styles.sectionTitle}>4. Long URLs</Text>
<Text style={styles.sectionSubtitle}>
break-all allows URLs to wrap at any character
</Text>

<View style={{ flexDirection: 'row' }}>
<View style={styles.wideBox}>
<Text style={styles.label}>wordBreak: normal (overflow)</Text>
<Text style={[styles.englishText, { wordBreak: 'normal' }]}>
https://example.com/very/very/loooong/path/to/resource
</Text>
</View>

<View style={styles.wideBox}>
<Text style={styles.label}>
wordBreak: break-all, hyphens: none
</Text>
<Text
style={[
styles.englishText,
{ wordBreak: 'break-all', hyphens: 'none' },
]}
>
https://example.com/very/very/loooong/path/to/resource
</Text>
</View>
</View>
</View>
</Page>
</Document>
);

export default {
id: 'soft-hyphens',
name: 'Soft Hyphens',
description: '',
name: 'Hyphenation',
description:
'Soft hyphen (U+00AD) auto-break and the hyphens / hyphenateCharacter / wordBreak CSS controls',
Document: SoftHyphens,
};
3 changes: 3 additions & 0 deletions packages/layout/src/text/layoutText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ const getLayoutOptions = (fontStore, node) => ({
node.props.hyphenationCallback ||
fontStore?.getHyphenationCallback() ||
null,
hyphens: node.style?.hyphens,
hyphenateCharacter: node.style?.hyphenateCharacter,
wordBreak: node.style?.wordBreak,
});

/**
Expand Down
80 changes: 80 additions & 0 deletions packages/layout/tests/text/layoutText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,84 @@ describe('text layoutText', () => {
expect.any(Function),
);
});

test('should not add hyphens when hyphens style is "none"', async () => {
const text = 'reallylongtext';
const hyphens = ['really­', 'long', 'text'];
const hyphenationCallback = vi.fn().mockReturnValue(hyphens);

const node = createTextNode(
text,
{ hyphens: 'none' },
{ hyphenationCallback },
);
const lines = layoutText(node, 50, 100, fontStore);

expect(lines[0].string).toEqual('really');
expect(lines[1].string).toEqual('long');
expect(lines[2].string).toEqual('text');
});

test('should use custom hyphenate character when hyphenateCharacter is set', async () => {
const text = 'reallylongtext';
const hyphens = ['really­', 'long', 'text'];
const hyphenationCallback = vi.fn().mockReturnValue(hyphens);

const node = createTextNode(
text,
{ hyphenateCharacter: '・' },
{ hyphenationCallback },
);
const lines = layoutText(node, 50, 100, fontStore);

expect(lines[0].string).toEqual('really・');
expect(lines[1].string).toEqual('long・');
expect(lines[2].string).toEqual('text');
});

test('should not add hyphens when hyphenateCharacter is empty string', async () => {
const text = 'reallylongtext';
const hyphens = ['really­', 'long', 'text'];
const hyphenationCallback = vi.fn().mockReturnValue(hyphens);

const node = createTextNode(
text,
{ hyphenateCharacter: '' },
{ hyphenationCallback },
);
const lines = layoutText(node, 50, 100, fontStore);

expect(lines[0].string).toEqual('really');
expect(lines[1].string).toEqual('long');
expect(lines[2].string).toEqual('text');
});

test('should keep CJK text together with wordBreak: keep-all', async () => {
const text = '東京都区';
const hyphenationCallback = vi.fn().mockImplementation((word) => [...word]);

const node = createTextNode(
text,
{ wordBreak: 'keep-all' },
{ hyphenationCallback },
);
const lines = layoutText(node, 300, 100, fontStore);

expect(lines).toHaveLength(1);
expect(lines[0].string).toBe('東京都区');
});

test('should break all characters with wordBreak: break-all', async () => {
const text = 'Hello';
const node = createTextNode(text, {
wordBreak: 'break-all',
hyphens: 'none',
});
const lines = layoutText(node, 15, 100, fontStore);

expect(lines.length).toBeGreaterThan(1);

const allChars = lines.map((line) => line.string).join('');
expect(allChars).toBe('Hello');
});
});
7 changes: 7 additions & 0 deletions packages/stylesheet/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,12 +321,19 @@ export type TextTransform =

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

export type Hyphens = 'none' | 'manual' | 'auto';

export type WordBreak = 'normal' | 'break-all' | 'keep-all';

export type TextStyle = {
direction?: 'ltr' | 'rtl';
fontSize?: number | string;
fontFamily?: string | string[];
fontStyle?: FontStyle;
fontWeight?: FontWeight;
hyphens?: Hyphens;
hyphenateCharacter?: string;
wordBreak?: WordBreak;
letterSpacing?: number | string;
lineHeight?: number | string;
maxLines?: number | string;
Expand Down
1 change: 1 addition & 0 deletions packages/textkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@react-pdf/fns": "3.1.3",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"linebreak": "^1.1.0",
"unicode-properties": "^1.4.1"
},
"devDependencies": {
Expand Down
Loading
Loading