Skip to content

Commit e0db9bf

Browse files
authored
fix: hyphenation (#3267)
1 parent 4a960ff commit e0db9bf

File tree

12 files changed

+270
-179
lines changed

12 files changed

+270
-179
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import svg from './svg';
1919
import svgTransform from './svg-transform';
2020
import transformOrigin from './transform-origin';
2121
import forms from './forms';
22+
import softHyphens from './soft-hyphens';
2223

2324
const EXAMPLES = [
2425
duplicatedImages,
@@ -42,6 +43,7 @@ const EXAMPLES = [
4243
transformOrigin,
4344
imageStressTest,
4445
forms,
46+
softHyphens,
4547
];
4648

4749
export default EXAMPLES;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React from 'react';
2+
import { Document, Page, Font, Text, StyleSheet } from '@react-pdf/renderer';
3+
4+
const shy = '\u00ad';
5+
6+
Font.register({
7+
family: 'Oswald',
8+
src: 'https://fonts.gstatic.com/s/oswald/v13/Y_TKV6o8WovbUd3m_X9aAA.ttf',
9+
});
10+
11+
const styles = StyleSheet.create({
12+
body: {
13+
padding: 20,
14+
},
15+
text: {
16+
fontFamily: 'Oswald',
17+
fontSize: 20,
18+
width: 100,
19+
border: '1px solid red',
20+
},
21+
});
22+
23+
const SoftHyphens = () => (
24+
<Document>
25+
<Page style={styles.body}>
26+
<Text
27+
style={styles.text}
28+
>{`Potentieel broeikas${shy}gas${shy}emissie${shy}rapport`}</Text>
29+
</Page>
30+
</Document>
31+
);
32+
33+
export default {
34+
id: 'soft-hyphens',
35+
name: 'Soft Hyphens',
36+
description: '',
37+
Document: SoftHyphens,
38+
};

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe('text layoutText', () => {
9696
const lines = layoutText(node, 50, 100, fontStore);
9797

9898
expect(lines[0].string).toEqual('really-');
99-
expect(lines[1].string).toEqual('long');
99+
expect(lines[1].string).toEqual('long-');
100100
expect(lines[2].string).toEqual('text');
101101
expect(hyphenationCallback).toHaveBeenCalledWith(
102102
'reallylongtext',
18.8 KB
Loading

packages/renderer/tests/text.test.jsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,33 @@ describe('text', () => {
126126

127127
expect(image).toMatchImageSnapshot();
128128
});
129+
130+
test('should hyphenate text with soft hyphen', async () => {
131+
const shy = '\u00ad';
132+
133+
const style = {
134+
text: {
135+
fontFamily: 'Oswald',
136+
fontSize: 20,
137+
width: 100,
138+
border: '1px solid red',
139+
},
140+
};
141+
142+
const image = await renderToImage(
143+
<Document>
144+
<Page style={{ padding: 20 }}>
145+
<Text
146+
style={style.text}
147+
>{`Potentieel broeikas${shy}gas${shy}emissie${shy}rapport`}</Text>
148+
149+
<Text
150+
style={style.text}
151+
>{`Potentieel broeikas${shy}gasemissie${shy}rapport`}</Text>
152+
</Page>
153+
</Document>,
154+
);
155+
156+
expect(image).toMatchImageSnapshot();
157+
});
129158
});

packages/textkit/src/engines/fontSubstitution/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,21 @@ const pickFontFromFontStack = (
1010
fontStack: Font[],
1111
lastFont?: Font,
1212
) => {
13+
if (IGNORED_CODE_POINTS.includes(codePoint)) return lastFont;
14+
1315
const fontStackWithFallback = [...fontStack, lastFont];
16+
1417
for (let i = 0; i < fontStackWithFallback.length; i += 1) {
1518
const font = fontStackWithFallback[i];
1619
if (
17-
!IGNORED_CODE_POINTS.includes(codePoint) &&
1820
font &&
1921
font.hasGlyphForCodePoint &&
2022
font.hasGlyphForCodePoint(codePoint)
2123
) {
2224
return font;
2325
}
2426
}
27+
2528
return fontStack.at(-1);
2629
};
2730

@@ -52,6 +55,7 @@ const fontSubstitution =
5255
for (let j = 0; j < chars.length; j += 1) {
5356
const char = chars[j];
5457
const codePoint = char.codePointAt(0);
58+
5559
// If the default font does not have a glyph and the fallback font does, we use it
5660
const font = pickFontFromFontStack(
5761
codePoint,

packages/textkit/src/engines/linebreaker/index.ts

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@ import insertGlyph from '../../attributedString/insertGlyph';
55
import advanceWidthBetween from '../../attributedString/advanceWidthBetween';
66
import { AttributedString, Attributes, LayoutOptions } from '../../types';
77
import { Node } from './types';
8-
import generateGlyphs from '../../layout/generateGlyphs';
98

10-
const SOFT_HYPHEN = '\u00AD';
11-
const HYPHEN_CODE_POINT = 0x002d;
9+
const HYPHEN = 0x002d;
1210
const TOLERANCE_STEPS = 5;
1311
const TOLERANCE_LIMIT = 50;
1412

@@ -47,49 +45,23 @@ const breakLines = (
4745
end = prevNode.end;
4846

4947
line = slice(start, end, attributedString);
50-
if (node.width > 0) {
51-
// A non-zero-width penalty indicates an additional hyphen should be inserted
52-
line = insertGlyph(line.string.length, HYPHEN_CODE_POINT, line);
53-
}
48+
49+
line = insertGlyph(line.string.length, HYPHEN, line);
5450
} else {
5551
end = node.end;
5652
line = slice(start, end, attributedString);
5753
}
5854

5955
start = end;
6056

61-
return [...acc, removeSoftHyphens(line)];
57+
return [...acc, line];
6258
}, []);
6359

64-
const lastLine = slice(
65-
start,
66-
attributedString.string.length,
67-
attributedString,
68-
);
69-
lines.push(removeSoftHyphens(lastLine));
60+
lines.push(slice(start, attributedString.string.length, attributedString));
7061

7162
return lines;
7263
};
7364

74-
/**
75-
* Remove all soft hyphen characters from the line.
76-
* Soft hyphens are not relevant anymore after line breaking, and will only
77-
* disrupt the rendering later down the line if left in the text.
78-
*
79-
* @param line
80-
*/
81-
const removeSoftHyphens = (line: AttributedString): AttributedString => {
82-
const modifiedLine = {
83-
...line,
84-
string: line.string.split(SOFT_HYPHEN).join(''),
85-
};
86-
87-
return {
88-
...modifiedLine,
89-
...generateGlyphs()(modifiedLine),
90-
};
91-
};
92-
9365
/**
9466
* Return Knuth & Plass nodes based on line and previously calculated syllables
9567
*
@@ -106,7 +78,6 @@ const getNodes = (
10678
let start = 0;
10779

10880
const hyphenWidth = 5;
109-
const softHyphen = '\u00ad';
11081

11182
const { syllables } = attributedString;
11283

@@ -136,8 +107,7 @@ const getNodes = (
136107

137108
if (syllables[index + 1] && hyphenated) {
138109
// Add penalty node. Penalty nodes are used to represent hyphenation points.
139-
const penaltyWidth = s.endsWith(softHyphen) ? hyphenWidth : 0;
140-
acc.push(knuthPlass.penalty(penaltyWidth, hyphenPenalty, 1));
110+
acc.push(knuthPlass.penalty(hyphenWidth, hyphenPenalty, 1));
141111
}
142112
}
143113

packages/textkit/src/engines/wordHyphenation/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const hyphenator = hyphen(pattern);
1010
* @returns Word parts
1111
*/
1212
const splitHyphen = (word: string) => {
13-
return word.split(new RegExp(`(?<=${SOFT_HYPHEN})`));
13+
return word.split(SOFT_HYPHEN);
1414
};
1515

1616
const cache: Record<string, string[]> = {};

packages/textkit/src/layout/wrapWords.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,26 @@ import fromFragments from '../attributedString/fromFragments';
22
import { Engines } from '../engines';
33
import { AttributedString, LayoutOptions } from '../types';
44

5+
const SOFT_HYPHEN = '\u00ad';
6+
57
/**
68
* Default word hyphenation engine used when no one provided.
79
* Does not perform word hyphenation at all
810
*
911
* @param word
1012
* @returns Same word
1113
*/
12-
const defaultHyphenationEngine = (word: string) => [word];
14+
const defaultHyphenate = (word: string) => [word];
15+
16+
/**
17+
* Remove soft hyphens from word
18+
*
19+
* @param word
20+
* @returns Word without soft hyphens
21+
*/
22+
const removeSoftHyphens = (word: string) => {
23+
return word.replaceAll(SOFT_HYPHEN, '');
24+
};
1325

1426
/**
1527
* Wrap words of attribute string
@@ -29,27 +41,40 @@ const wrapWords = (
2941
const syllables = [];
3042
const fragments = [];
3143

32-
const builtinHyphenateWord =
33-
engines.wordHyphenation?.() || defaultHyphenationEngine;
34-
const hyphenateWord = options.hyphenationCallback || builtinHyphenateWord;
44+
console.log('>>>> attributedString', attributedString);
45+
46+
const builtinHyphenate = engines.wordHyphenation?.() || defaultHyphenate;
47+
48+
const hyphenate = options.hyphenationCallback || builtinHyphenate;
49+
50+
let offset = 0;
3551

3652
for (let i = 0; i < attributedString.runs.length; i += 1) {
3753
let string = '';
54+
3855
const run = attributedString.runs[i];
56+
3957
const words = attributedString.string
4058
.slice(run.start, run.end)
4159
.split(/([ ]+)/g)
4260
.filter(Boolean);
4361

4462
for (let j = 0; j < words.length; j += 1) {
4563
const word = words[j];
46-
const parts = hyphenateWord(word, builtinHyphenateWord);
64+
const parts = hyphenate(word, builtinHyphenate).map(removeSoftHyphens);
4765

4866
syllables.push(...parts);
4967
string += parts.join('');
5068
}
5169

52-
fragments.push({ ...run, string });
70+
// Modify run start and end based on removed soft hyphens.
71+
const runOffset = run.end - run.start - string.length;
72+
const start = run.start - offset;
73+
const end = run.end - offset - runOffset;
74+
75+
fragments.push({ ...run, start, end, string });
76+
77+
offset += runOffset;
5378
}
5479

5580
const result: AttributedString = { ...fromFragments(fragments), syllables };

packages/textkit/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export type Paragraph = AttributedString[];
129129
export type LayoutOptions = {
130130
hyphenationCallback?: (
131131
word: string | null,
132-
originalHyphenationCallback: (word: string | null) => string[],
132+
fallback: (word: string | null) => string[],
133133
) => string[];
134134
tolerance?: number;
135135
hyphenationPenalty?: number;

0 commit comments

Comments
 (0)