Skip to content

Commit afa73c4

Browse files
committed
feat(textkit): use best-fit line breaking for long text
1 parent d41a820 commit afa73c4

10 files changed

Lines changed: 99 additions & 4 deletions

File tree

.changeset/tiny-hats-brake.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@react-pdf/layout': minor
3+
'@react-pdf/renderer': minor
4+
'@react-pdf/textkit': minor
5+
'@react-pdf/types': minor
6+
---
7+
8+
Use faster best-fit line breaking for long non-justified text and expose a lineBreakStrategy option.

packages/layout/src/svg/layoutText.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,11 @@ const layoutTspan = (fontStore: FontStore) => (node, xOffset) => {
124124
fontStore?.getHyphenationCallback() ||
125125
null;
126126

127-
const layoutOptions = { hyphenationCallback, shrinkWhitespaceFactor };
127+
const layoutOptions = {
128+
hyphenationCallback,
129+
lineBreakStrategy: node.props.lineBreakStrategy,
130+
shrinkWhitespaceFactor,
131+
};
128132
const lines = engine(attributedString, container, layoutOptions).flat();
129133

130134
return Object.assign({}, node, { lines });

packages/layout/src/text/layoutText.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const getContainer = (width, height, node) => {
5858
*/
5959
const getLayoutOptions = (fontStore, node) => ({
6060
hyphenationPenalty: node.props.hyphenationPenalty,
61+
lineBreakStrategy: node.props.lineBreakStrategy,
6162
shrinkWhitespaceFactor: { before: -0.5, after: -0.5 },
6263
hyphenationCallback:
6364
node.props.hyphenationCallback ||

packages/layout/src/types/text.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ interface TextProps extends NodeProps {
2121
* @see https://react-pdf.org/fonts#registerhyphenationcallback
2222
*/
2323
hyphenationCallback?: HyphenationCallback;
24+
/**
25+
* Controls which line-breaking strategy textkit uses.
26+
*/
27+
lineBreakStrategy?: 'auto' | 'best-fit' | 'knuth-plass';
2428
/**
2529
* Specifies the minimum number of lines in a text element that must be shown at the bottom of a page or its container.
2630
* @see https://react-pdf.org/advanced#orphan-&-widow-protection

packages/renderer/index.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,10 @@ declare namespace ReactPDF {
254254
* @see https://react-pdf.org/fonts#registerhyphenationcallback
255255
*/
256256
hyphenationCallback?: HyphenationCallback;
257+
/**
258+
* Controls which line-breaking strategy textkit uses.
259+
*/
260+
lineBreakStrategy?: 'auto' | 'best-fit' | 'knuth-plass';
257261
/**
258262
* Specifies the minimum number of lines in a text element that must be shown at the bottom of a page or its container.
259263
* @see https://react-pdf.org/advanced#orphan-&-widow-protection

packages/textkit/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,13 @@ const result = bidiEngine(attributedString);
9797

9898
### linebreaker
9999

100-
Performs line breaking using the Knuth-Plass algorithm with fallback to best-fit. Handles hyphenation points and produces optimal line breaks.
100+
Performs line breaking using the Knuth-Plass algorithm with fallback to best-fit. Handles hyphenation points and produces optimal line breaks. In `auto` mode, long non-justified text uses best-fit line breaking to avoid expensive global line optimization.
101101

102102
```js
103103
import { linebreaker } from '@react-pdf/textkit';
104104

105105
const linebreakerEngine = linebreaker({
106+
lineBreakStrategy: 'auto',
106107
tolerance: 4,
107108
hyphenationPenalty: 100,
108109
});
@@ -318,6 +319,7 @@ type LayoutOptions = {
318319
word: string | null,
319320
fallback: (word: string | null) => string[],
320321
) => string[];
322+
lineBreakStrategy?: 'auto' | 'best-fit' | 'knuth-plass';
321323
tolerance?: number;
322324
hyphenationPenalty?: number;
323325
expandCharFactor?: JustificationFactor;

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Node } from './types';
99
const HYPHEN = 0x002d;
1010
const TOLERANCE_STEPS = 5;
1111
const TOLERANCE_LIMIT = 50;
12+
const BEST_FIT_THRESHOLD = 2000;
1213

1314
const opts = {
1415
width: 3,
@@ -131,6 +132,20 @@ const getAttributes = (attributedString: AttributedString) => {
131132
return attributedString.runs?.[0]?.attributes || {};
132133
};
133134

135+
const shouldUseBestFit = (
136+
attributedString: AttributedString,
137+
attributes: Attributes,
138+
options: LayoutOptions,
139+
) => {
140+
if (options.lineBreakStrategy === 'best-fit') return true;
141+
if (options.lineBreakStrategy === 'knuth-plass') return false;
142+
143+
return (
144+
attributes.align !== 'justify' &&
145+
attributedString.string.length > BEST_FIT_THRESHOLD
146+
);
147+
};
148+
134149
/**
135150
* Performs Knuth & Plass line breaking algorithm
136151
* Fallbacks to best fit algorithm if latter not successful
@@ -148,11 +163,14 @@ const linebreaker = (options: LayoutOptions) => {
148163

149164
const attributes = getAttributes(attributedString);
150165
const nodes = getNodes(attributedString, attributes, options);
166+
const useBestFit = shouldUseBestFit(attributedString, attributes, options);
151167

152-
let breaks = knuthPlass(nodes, availableWidths, tolerance);
168+
let breaks = useBestFit
169+
? bestFit(nodes, availableWidths)
170+
: knuthPlass(nodes, availableWidths, tolerance);
153171

154172
// Try again with a higher tolerance if the line breaking failed.
155-
while (breaks.length === 0 && tolerance < TOLERANCE_LIMIT) {
173+
while (!useBestFit && breaks.length === 0 && tolerance < TOLERANCE_LIMIT) {
156174
tolerance += TOLERANCE_STEPS;
157175
breaks = knuthPlass(nodes, availableWidths, tolerance);
158176
}

packages/textkit/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export type LayoutOptions = {
134134
word: string | null,
135135
fallback: (word: string | null) => string[],
136136
) => string[];
137+
lineBreakStrategy?: 'auto' | 'best-fit' | 'knuth-plass';
137138
tolerance?: number;
138139
hyphenationPenalty?: number;
139140
expandCharFactor?: JustificationFactor;

packages/textkit/tests/engines/linebreaker.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,33 @@ const width = 50;
1010
describe('linebreaker', () => {
1111
const linebreaker = linebreakerFactory({});
1212

13+
const createLongAttributedString = (attributes = {}) => {
14+
const string = Array(600).fill('word').join(' ');
15+
const indices = Array.from({ length: string.length }, (_, index) => index);
16+
17+
return {
18+
string,
19+
runs: [
20+
{
21+
start: 0,
22+
end: string.length,
23+
attributes: { font: [font], ...attributes },
24+
stringIndices: indices,
25+
glyphIndices: indices,
26+
positions: indices.map(() => ({
27+
xAdvance: 1,
28+
yAdvance: 0,
29+
xOffset: 0,
30+
yOffset: 0,
31+
advanceWidth: 1,
32+
})),
33+
glyphs: [],
34+
},
35+
],
36+
syllables: string.split(/([ ]+)/g).filter(Boolean),
37+
};
38+
};
39+
1340
test('should break lines and adds hyphens only where indicated', () => {
1441
const attributedString = {
1542
string: 'Potentieel broeikasgasemissierapport',
@@ -296,6 +323,31 @@ describe('linebreaker', () => {
296323
'rapport',
297324
]);
298325
});
326+
327+
test('should use best-fit for long non-justified text in auto mode', () => {
328+
const attributedString = createLongAttributedString();
329+
const result = linebreakerFactory({})(attributedString, [50]);
330+
const bestFitResult = linebreakerFactory({ lineBreakStrategy: 'best-fit' })(
331+
attributedString,
332+
[50],
333+
);
334+
335+
expect(result.map((line) => line.string)).toEqual(
336+
bestFitResult.map((line) => line.string),
337+
);
338+
});
339+
340+
test('should keep Knuth-Plass for long justified text in auto mode', () => {
341+
const attributedString = createLongAttributedString({ align: 'justify' });
342+
const result = linebreakerFactory({})(attributedString, [50]);
343+
const knuthPlassResult = linebreakerFactory({
344+
lineBreakStrategy: 'knuth-plass',
345+
})(attributedString, [50]);
346+
347+
expect(result.map((line) => line.string)).toEqual(
348+
knuthPlassResult.map((line) => line.string),
349+
);
350+
});
299351
});
300352

301353
describe('bestFit', () => {

packages/types/node.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface TextProps extends BaseProps {
2424
orphans?: number;
2525
render?: DynamicRenderCallback;
2626
hyphenationCallback?: HyphenationCallback;
27+
lineBreakStrategy?: 'auto' | 'best-fit' | 'knuth-plass';
2728
}
2829

2930
interface ViewProps extends BaseProps {

0 commit comments

Comments
 (0)