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/tiny-hats-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@react-pdf/layout': minor
'@react-pdf/renderer': minor
'@react-pdf/textkit': minor
'@react-pdf/types': minor
---

Use faster best-fit line breaking for long non-justified text and expose a lineBreakStrategy option.
6 changes: 5 additions & 1 deletion packages/layout/src/svg/layoutText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ const layoutTspan = (fontStore: FontStore) => (node, xOffset) => {
fontStore?.getHyphenationCallback() ||
null;

const layoutOptions = { hyphenationCallback, shrinkWhitespaceFactor };
const layoutOptions = {
hyphenationCallback,
lineBreakStrategy: node.props.lineBreakStrategy,
shrinkWhitespaceFactor,
};
const lines = engine(attributedString, container, layoutOptions).flat();

return Object.assign({}, node, { lines });
Expand Down
1 change: 1 addition & 0 deletions packages/layout/src/text/layoutText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const getContainer = (width, height, node) => {
*/
const getLayoutOptions = (fontStore, node) => ({
hyphenationPenalty: node.props.hyphenationPenalty,
lineBreakStrategy: node.props.lineBreakStrategy,
shrinkWhitespaceFactor: { before: -0.5, after: -0.5 },
hyphenationCallback:
node.props.hyphenationCallback ||
Expand Down
4 changes: 4 additions & 0 deletions packages/layout/src/types/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ interface TextProps extends NodeProps {
* @see https://react-pdf.org/fonts#registerhyphenationcallback
*/
hyphenationCallback?: HyphenationCallback;
/**
* Controls which line-breaking strategy textkit uses.
*/
lineBreakStrategy?: 'auto' | 'best-fit' | 'knuth-plass';
/**
* Specifies the minimum number of lines in a text element that must be shown at the bottom of a page or its container.
* @see https://react-pdf.org/advanced#orphan-&-widow-protection
Expand Down
4 changes: 4 additions & 0 deletions packages/renderer/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,10 @@ declare namespace ReactPDF {
* @see https://react-pdf.org/fonts#registerhyphenationcallback
*/
hyphenationCallback?: HyphenationCallback;
/**
* Controls which line-breaking strategy textkit uses.
*/
lineBreakStrategy?: 'auto' | 'best-fit' | 'knuth-plass';
/**
* Specifies the minimum number of lines in a text element that must be shown at the bottom of a page or its container.
* @see https://react-pdf.org/advanced#orphan-&-widow-protection
Expand Down
4 changes: 3 additions & 1 deletion packages/textkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,13 @@ const result = bidiEngine(attributedString);

### linebreaker

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

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

const linebreakerEngine = linebreaker({
lineBreakStrategy: 'auto',
tolerance: 4,
hyphenationPenalty: 100,
});
Expand Down Expand Up @@ -318,6 +319,7 @@ type LayoutOptions = {
word: string | null,
fallback: (word: string | null) => string[],
) => string[];
lineBreakStrategy?: 'auto' | 'best-fit' | 'knuth-plass';
tolerance?: number;
hyphenationPenalty?: number;
expandCharFactor?: JustificationFactor;
Expand Down
27 changes: 23 additions & 4 deletions packages/textkit/src/engines/linebreaker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { Node } from './types';
const HYPHEN = 0x002d;
const TOLERANCE_STEPS = 5;
const TOLERANCE_LIMIT = 50;
Comment thread
wojtekmaj marked this conversation as resolved.
// Keep ordinary paragraphs on Knuth-Plass, but avoid active-node blowups for long ragged text.
const BEST_FIT_CHARACTER_THRESHOLD = 2000;

const opts = {
width: 3,
Expand Down Expand Up @@ -131,9 +133,23 @@ const getAttributes = (attributedString: AttributedString) => {
return attributedString.runs?.[0]?.attributes || {};
};

const shouldUseBestFit = (
attributedString: AttributedString,
attributes: Attributes,
options: LayoutOptions,
) => {
if (options.lineBreakStrategy === 'best-fit') return true;
if (options.lineBreakStrategy === 'knuth-plass') return false;

return (
attributes.align !== 'justify' &&
attributedString.string.length > BEST_FIT_CHARACTER_THRESHOLD
);
};

/**
* Performs Knuth & Plass line breaking algorithm
* Fallbacks to best fit algorithm if latter not successful
* Performs line breaking using the configured strategy.
* Auto mode uses best-fit for long ragged text, otherwise Knuth-Plass with best-fit fallback.
*
* @param options - Layout options
*/
Expand All @@ -148,11 +164,14 @@ const linebreaker = (options: LayoutOptions) => {

const attributes = getAttributes(attributedString);
const nodes = getNodes(attributedString, attributes, options);
const useBestFit = shouldUseBestFit(attributedString, attributes, options);

let breaks = knuthPlass(nodes, availableWidths, tolerance);
let breaks = useBestFit
? bestFit(nodes, availableWidths)
: knuthPlass(nodes, availableWidths, tolerance);

// Try again with a higher tolerance if the line breaking failed.
while (breaks.length === 0 && tolerance < TOLERANCE_LIMIT) {
while (!useBestFit && breaks.length === 0 && tolerance < TOLERANCE_LIMIT) {
tolerance += TOLERANCE_STEPS;
breaks = knuthPlass(nodes, availableWidths, tolerance);
}
Expand Down
1 change: 1 addition & 0 deletions packages/textkit/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export type LayoutOptions = {
word: string | null,
fallback: (word: string | null) => string[],
) => string[];
lineBreakStrategy?: 'auto' | 'best-fit' | 'knuth-plass';
tolerance?: number;
hyphenationPenalty?: number;
expandCharFactor?: JustificationFactor;
Expand Down
71 changes: 70 additions & 1 deletion packages/textkit/tests/engines/linebreaker.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
import { describe, expect, test } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';

vi.mock('../../src/engines/linebreaker/bestFit', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../../src/engines/linebreaker/bestFit')
>();

return { default: vi.fn(actual.default) };
});

vi.mock('../../src/engines/linebreaker/knuthPlass', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../../src/engines/linebreaker/knuthPlass')
>();
const fn = vi.fn(actual.default);

return { default: Object.assign(fn, actual.default) };
});

import linebreakerFactory from '../../src/engines/linebreaker';
import applyBestFit from '../../src/engines/linebreaker/bestFit';
Expand All @@ -10,6 +29,38 @@ const width = 50;
describe('linebreaker', () => {
const linebreaker = linebreakerFactory({});

beforeEach(() => {
vi.mocked(applyBestFit).mockClear();
vi.mocked(applyKnuthPlass).mockClear();
});

const createLongAttributedString = (attributes = {}) => {
const string = Array(600).fill('word').join(' ');
const indices = Array.from({ length: string.length }, (_, index) => index);

return {
string,
runs: [
{
start: 0,
end: string.length,
attributes: { font: [font], ...attributes },
stringIndices: indices,
glyphIndices: indices,
positions: indices.map(() => ({
xAdvance: 1,
yAdvance: 0,
xOffset: 0,
yOffset: 0,
advanceWidth: 1,
})),
glyphs: [],
},
],
syllables: string.split(/([ ]+)/g).filter(Boolean),
};
};

test('should break lines and adds hyphens only where indicated', () => {
const attributedString = {
string: 'Potentieel broeikasgasemissierapport',
Expand Down Expand Up @@ -296,6 +347,24 @@ describe('linebreaker', () => {
'rapport',
]);
});

test('should use best-fit for long non-justified text in auto mode', () => {
const attributedString = createLongAttributedString();

linebreakerFactory({})(attributedString, [50]);

expect(applyBestFit).toHaveBeenCalledTimes(1);
expect(applyKnuthPlass).not.toHaveBeenCalled();
});

test('should keep Knuth-Plass for long justified text in auto mode', () => {
const attributedString = createLongAttributedString({ align: 'justify' });

linebreakerFactory({})(attributedString, [50]);

expect(applyKnuthPlass).toHaveBeenCalled();
expect(applyBestFit).not.toHaveBeenCalled();
});
});

describe('bestFit', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/types/node.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface TextProps extends BaseProps {
orphans?: number;
render?: DynamicRenderCallback;
hyphenationCallback?: HyphenationCallback;
lineBreakStrategy?: 'auto' | 'best-fit' | 'knuth-plass';
}

interface ViewProps extends BaseProps {
Expand Down
Loading