From afa73c4b3a6b89d8d6766b2855fc89ac606731e1 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Tue, 12 May 2026 13:31:04 +0200 Subject: [PATCH 1/2] feat(textkit): use best-fit line breaking for long text --- .changeset/tiny-hats-brake.md | 8 +++ packages/layout/src/svg/layoutText.ts | 6 ++- packages/layout/src/text/layoutText.ts | 1 + packages/layout/src/types/text.ts | 4 ++ packages/renderer/index.d.ts | 4 ++ packages/textkit/README.md | 4 +- .../textkit/src/engines/linebreaker/index.ts | 22 +++++++- packages/textkit/src/types.ts | 1 + .../textkit/tests/engines/linebreaker.test.ts | 52 +++++++++++++++++++ packages/types/node.d.ts | 1 + 10 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 .changeset/tiny-hats-brake.md diff --git a/.changeset/tiny-hats-brake.md b/.changeset/tiny-hats-brake.md new file mode 100644 index 000000000..f279adcb2 --- /dev/null +++ b/.changeset/tiny-hats-brake.md @@ -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. diff --git a/packages/layout/src/svg/layoutText.ts b/packages/layout/src/svg/layoutText.ts index 3ae9f10d7..9a5610355 100644 --- a/packages/layout/src/svg/layoutText.ts +++ b/packages/layout/src/svg/layoutText.ts @@ -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 }); diff --git a/packages/layout/src/text/layoutText.ts b/packages/layout/src/text/layoutText.ts index e87235b67..e5ff390f8 100644 --- a/packages/layout/src/text/layoutText.ts +++ b/packages/layout/src/text/layoutText.ts @@ -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 || diff --git a/packages/layout/src/types/text.ts b/packages/layout/src/types/text.ts index 6f2256e0d..c0ffc01b2 100644 --- a/packages/layout/src/types/text.ts +++ b/packages/layout/src/types/text.ts @@ -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 diff --git a/packages/renderer/index.d.ts b/packages/renderer/index.d.ts index 1aa2dc804..c14a25038 100644 --- a/packages/renderer/index.d.ts +++ b/packages/renderer/index.d.ts @@ -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 diff --git a/packages/textkit/README.md b/packages/textkit/README.md index 6abbcabf0..9b507a5dc 100644 --- a/packages/textkit/README.md +++ b/packages/textkit/README.md @@ -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 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. ```js import { linebreaker } from '@react-pdf/textkit'; const linebreakerEngine = linebreaker({ + lineBreakStrategy: 'auto', tolerance: 4, hyphenationPenalty: 100, }); @@ -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; diff --git a/packages/textkit/src/engines/linebreaker/index.ts b/packages/textkit/src/engines/linebreaker/index.ts index db55a1c6a..ba98ae7be 100644 --- a/packages/textkit/src/engines/linebreaker/index.ts +++ b/packages/textkit/src/engines/linebreaker/index.ts @@ -9,6 +9,7 @@ import { Node } from './types'; const HYPHEN = 0x002d; const TOLERANCE_STEPS = 5; const TOLERANCE_LIMIT = 50; +const BEST_FIT_THRESHOLD = 2000; const opts = { width: 3, @@ -131,6 +132,20 @@ 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_THRESHOLD + ); +}; + /** * Performs Knuth & Plass line breaking algorithm * Fallbacks to best fit algorithm if latter not successful @@ -148,11 +163,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); } diff --git a/packages/textkit/src/types.ts b/packages/textkit/src/types.ts index fa31bf753..2c1542fed 100644 --- a/packages/textkit/src/types.ts +++ b/packages/textkit/src/types.ts @@ -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; diff --git a/packages/textkit/tests/engines/linebreaker.test.ts b/packages/textkit/tests/engines/linebreaker.test.ts index 76522cc45..5b02575d3 100644 --- a/packages/textkit/tests/engines/linebreaker.test.ts +++ b/packages/textkit/tests/engines/linebreaker.test.ts @@ -10,6 +10,33 @@ const width = 50; describe('linebreaker', () => { const linebreaker = linebreakerFactory({}); + 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', @@ -296,6 +323,31 @@ describe('linebreaker', () => { 'rapport', ]); }); + + test('should use best-fit for long non-justified text in auto mode', () => { + const attributedString = createLongAttributedString(); + const result = linebreakerFactory({})(attributedString, [50]); + const bestFitResult = linebreakerFactory({ lineBreakStrategy: 'best-fit' })( + attributedString, + [50], + ); + + expect(result.map((line) => line.string)).toEqual( + bestFitResult.map((line) => line.string), + ); + }); + + test('should keep Knuth-Plass for long justified text in auto mode', () => { + const attributedString = createLongAttributedString({ align: 'justify' }); + const result = linebreakerFactory({})(attributedString, [50]); + const knuthPlassResult = linebreakerFactory({ + lineBreakStrategy: 'knuth-plass', + })(attributedString, [50]); + + expect(result.map((line) => line.string)).toEqual( + knuthPlassResult.map((line) => line.string), + ); + }); }); describe('bestFit', () => { diff --git a/packages/types/node.d.ts b/packages/types/node.d.ts index 96ecbf866..035c223f3 100644 --- a/packages/types/node.d.ts +++ b/packages/types/node.d.ts @@ -24,6 +24,7 @@ interface TextProps extends BaseProps { orphans?: number; render?: DynamicRenderCallback; hyphenationCallback?: HyphenationCallback; + lineBreakStrategy?: 'auto' | 'best-fit' | 'knuth-plass'; } interface ViewProps extends BaseProps { From 8fc992ea32b6d985e6f327300ef38c69fdceb8e0 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Tue, 12 May 2026 14:11:55 +0200 Subject: [PATCH 2/2] test(textkit): assert long-text line break strategy --- packages/textkit/README.md | 2 +- .../textkit/src/engines/linebreaker/index.ts | 9 ++-- .../textkit/tests/engines/linebreaker.test.ts | 49 +++++++++++++------ 3 files changed, 39 insertions(+), 21 deletions(-) diff --git a/packages/textkit/README.md b/packages/textkit/README.md index 9b507a5dc..eb311c21f 100644 --- a/packages/textkit/README.md +++ b/packages/textkit/README.md @@ -97,7 +97,7 @@ 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. In `auto` mode, long non-justified text uses best-fit line breaking to avoid expensive global line optimization. +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'; diff --git a/packages/textkit/src/engines/linebreaker/index.ts b/packages/textkit/src/engines/linebreaker/index.ts index ba98ae7be..e0cb1bcfe 100644 --- a/packages/textkit/src/engines/linebreaker/index.ts +++ b/packages/textkit/src/engines/linebreaker/index.ts @@ -9,7 +9,8 @@ import { Node } from './types'; const HYPHEN = 0x002d; const TOLERANCE_STEPS = 5; const TOLERANCE_LIMIT = 50; -const BEST_FIT_THRESHOLD = 2000; +// 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, @@ -142,13 +143,13 @@ const shouldUseBestFit = ( return ( attributes.align !== 'justify' && - attributedString.string.length > BEST_FIT_THRESHOLD + 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 */ diff --git a/packages/textkit/tests/engines/linebreaker.test.ts b/packages/textkit/tests/engines/linebreaker.test.ts index 5b02575d3..7ea026ed0 100644 --- a/packages/textkit/tests/engines/linebreaker.test.ts +++ b/packages/textkit/tests/engines/linebreaker.test.ts @@ -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'; @@ -10,6 +29,11 @@ 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); @@ -326,27 +350,20 @@ describe('linebreaker', () => { test('should use best-fit for long non-justified text in auto mode', () => { const attributedString = createLongAttributedString(); - const result = linebreakerFactory({})(attributedString, [50]); - const bestFitResult = linebreakerFactory({ lineBreakStrategy: 'best-fit' })( - attributedString, - [50], - ); - expect(result.map((line) => line.string)).toEqual( - bestFitResult.map((line) => line.string), - ); + 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' }); - const result = linebreakerFactory({})(attributedString, [50]); - const knuthPlassResult = linebreakerFactory({ - lineBreakStrategy: 'knuth-plass', - })(attributedString, [50]); - expect(result.map((line) => line.string)).toEqual( - knuthPlassResult.map((line) => line.string), - ); + linebreakerFactory({})(attributedString, [50]); + + expect(applyKnuthPlass).toHaveBeenCalled(); + expect(applyBestFit).not.toHaveBeenCalled(); }); });