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 hyphenateCharacter. Lines also break at Unicode line break opportunities, such as after a hyphen-minus or between CJK characters, without adding a hyphen
184 changes: 181 additions & 3 deletions apps/examples/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,130 @@ 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 (overflows)</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}>
keep-all breaks only at spaces and punctuation
</Text>

<View style={{ flexDirection: 'row' }}>
<View style={styles.smallBox}>
<Text style={styles.label}>wordBreak: keep-all</Text>
<Text style={[styles.japaneseText, { wordBreak: 'keep-all' }]}>
グレートブリテン および 北アイルランド 連合王国、という言葉
</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}>
normal wraps after slashes, break-all wraps at any letter
</Text>

<View style={{ flexDirection: 'row' }}>
<View style={styles.wideBox}>
<Text style={styles.label}>wordBreak: normal</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</Text>
<Text style={[styles.englishText, { wordBreak: 'break-all' }]}>
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,
};
4 changes: 2 additions & 2 deletions apps/site/components/text-lab/line-break-lab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export function LineBreakLab() {
>
K&amp;P{' '}
{justify
? (knuth?.length ?? 0)
? knuth?.length ?? 0
: Math.round(score(knuth, width)).toLocaleString('en-US')}
</b>{' '}
·{' '}
Expand All @@ -234,7 +234,7 @@ export function LineBreakLab() {
>
greedy{' '}
{justify
? (greedy?.length ?? 0)
? greedy?.length ?? 0
: Math.round(score(greedy, width)).toLocaleString('en-US')}
</b>
</span>
Expand Down
3 changes: 3 additions & 0 deletions packages/layout/src/text/layoutText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,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
92 changes: 92 additions & 0 deletions packages/layout/tests/text/layoutText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,98 @@ describe('text layoutText', () => {
);
});

test('should not hyphenate when hyphens style is "none"', async () => {
const text = 'really\u00adlongtext';
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(hyphenationCallback).not.toHaveBeenCalled();
expect(lines.map((line) => line.string)).toEqual(['reallylongtext']);
});

test('should hyphenate only at soft hyphens when hyphens style is "manual"', async () => {
const text = 'really\u00adlongtext';
const hyphens = ['really', 'long', 'text'];
const hyphenationCallback = vi.fn().mockReturnValue(hyphens);

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

expect(hyphenationCallback).not.toHaveBeenCalled();
expect(lines.map((line) => line.string)).toEqual(['really-', 'longtext']);
});

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' });
const lines = layoutText(node, 15, 100, fontStore);

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

const allChars = lines.map((line) => line.string).join('');
expect(allChars).toBe('Hello');
});

test('should suppress hyphenation when `hyphenationPenalty` is set to `Infinity`', () => {
const text = 'Lorem ipsum dolor sit amet consectetur adipiscing elit';

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading