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
11 changes: 11 additions & 0 deletions .changeset/dirty-chairs-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@react-pdf/vite-example": minor
"@react-pdf/stylesheet": minor
"@react-pdf/textkit": minor
"@react-pdf/layout": minor
---

feat: support `text-wrap` style on Text with `pretty`, `balance`, and `nowrap` values.
`pretty` avoids single-word last lines (word-level orphan control).
`balance` equalizes line lengths for short headings (capped at 10 lines).
`nowrap` keeps the entire paragraph on a single line.
2 changes: 2 additions & 0 deletions packages/examples/vite/src/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import math from './math';
import mermaid from './mermaid';
import passwordProtection from './password-protection';
import softHyphens from './soft-hyphens';
import textWrap from './text-wrap';

const EXAMPLES = [
scripts,
Expand Down Expand Up @@ -56,6 +57,7 @@ const EXAMPLES = [
mermaid,
passwordProtection,
softHyphens,
textWrap,
];

export default EXAMPLES;
129 changes: 129 additions & 0 deletions packages/examples/vite/src/examples/text-wrap/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React from 'react';
import { Document, Page, View, Text, StyleSheet } from '@react-pdf/renderer';

const styles = StyleSheet.create({
page: {
backgroundColor: '#fafafa',
padding: 40,
},
title: {
fontSize: 18,
fontWeight: 'bold',
color: '#1a1a1a',
},
subtitle: {
fontSize: 9,
color: '#888',
marginBottom: 20,
},
card: {
backgroundColor: '#ffffff',
borderRadius: 5,
padding: 12,
borderWidth: 1,
borderColor: '#e8e8e8',
marginBottom: 12,
width: 254, // body width 230 + horizontal padding 24
overflow: 'hidden',
},
cardLabel: {
fontSize: 8,
color: '#999',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
},
body: {
fontSize: 11,
color: '#333',
lineHeight: 1.5,
width: 230,
},
pretty: {
textWrap: 'pretty',
},
balance: {
textWrap: 'balance',
},
nowrap: {
textWrap: 'nowrap',
},
note: {
fontSize: 9,
color: '#666',
marginTop: 16,
lineHeight: 1.5,
},
});

const PARAGRAPH =
'Lorem ipsum dolor sit amet consectetur adipisicing elit. ' +
'Voluptatem aut cum eum id quos est.';

// Disable hyphenation so the line-break differences are not muddled by
// mid-word splits introduced by the default hyphenation engine.
const noHyphenate = (word: string) => [word];

const TextWrap = () => (
<Document>
<Page size="A4" style={styles.page}>
<Text style={styles.title}>text-wrap</Text>
<Text style={styles.subtitle}>
Same paragraph, same width, different textWrap values
</Text>

<View style={styles.card}>
<Text style={styles.cardLabel}>textWrap: wrap (default)</Text>
<Text style={styles.body} hyphenationCallback={noHyphenate}>
{PARAGRAPH}
</Text>
</View>

<View style={styles.card}>
<Text style={styles.cardLabel}>textWrap: pretty</Text>
<Text
style={[styles.body, styles.pretty]}
hyphenationCallback={noHyphenate}
>
{PARAGRAPH}
</Text>
</View>

<View style={styles.card}>
<Text style={styles.cardLabel}>textWrap: balance</Text>
<Text
style={[styles.body, styles.balance]}
hyphenationCallback={noHyphenate}
>
{PARAGRAPH}
</Text>
</View>

<View style={styles.card}>
<Text style={styles.cardLabel}>textWrap: nowrap (overflow hidden)</Text>
<Text
style={[styles.body, styles.nowrap]}
hyphenationCallback={noHyphenate}
>
{PARAGRAPH}
</Text>
</View>

<Text style={styles.note}>
Following the CSS Text Module Level 4 specification, textWrap accepts
wrap (default), pretty (avoid orphans on the last line), balance
(equalize line lengths, capped at 10 lines), and nowrap (never break the
line). Pair nowrap with overflow: hidden to clip text that exceeds the
container width.
</Text>
</Page>
</Document>
);

export default {
id: 'text-wrap',
name: 'Text Wrap',
description:
'Line wrapping control via textWrap (wrap / pretty / balance / nowrap)',
Document: TextWrap,
};
3 changes: 3 additions & 0 deletions packages/layout/src/text/layoutText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const getMaxLines = (node) => node.style?.maxLines;

const getTextOverflow = (node) => node.style?.textOverflow;

const getTextWrap = (node) => node.style?.textWrap;

/**
* Get layout container for specific text node
*
Expand Down Expand Up @@ -63,6 +65,7 @@ const getLayoutOptions = (fontStore, node) => ({
node.props.hyphenationCallback ||
fontStore?.getHyphenationCallback() ||
null,
textWrap: getTextWrap(node),
});

/**
Expand Down
36 changes: 36 additions & 0 deletions packages/layout/tests/text/layoutText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,40 @@ describe('text layoutText', () => {
expect.any(Function),
);
});

test('Should keep at least two words on the last line with textWrap: pretty', () => {
const text = 'alpha beta gamma delta epsilon zeta eta theta iota kappa';
const countWords = (s: string) =>
s.trim().split(/\s+/).filter(Boolean).length;

const node = createTextNode(text, { textWrap: 'pretty' });
const lines = layoutText(node, 100, 1000, fontStore);

expect(lines.length).toBeGreaterThan(1);
const lastLine = lines[lines.length - 1];
expect(countWords(lastLine.string)).toBeGreaterThanOrEqual(2);
});

test('Should keep the whole paragraph on a single line with textWrap: nowrap', () => {
const text = 'alpha beta gamma delta epsilon zeta eta theta iota kappa';

const node = createTextNode(text, { textWrap: 'nowrap' });
const lines = layoutText(node, 100, 1000, fontStore);

expect(lines).toHaveLength(1);
expect(lines[0].string.trim()).toBe(text);
});

test('Should preserve line count with textWrap: balance', () => {
const text =
'A short headline that should balance evenly across multiple lines';

const naturalNode = createTextNode(text);
const balancedNode = createTextNode(text, { textWrap: 'balance' });

const naturalLines = layoutText(naturalNode, 220, 1000, fontStore);
const balancedLines = layoutText(balancedNode, 220, 1000, fontStore);

expect(balancedLines).toHaveLength(naturalLines.length);
});
});
1 change: 1 addition & 0 deletions packages/stylesheet/src/resolve/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const handlers = {
textIndent: processNoopValue<'textIndent'>,
textOverflow: processNoopValue<'textOverflow'>,
textTransform: processNoopValue<'textTransform'>,
textWrap: processNoopValue<'textWrap'>,
verticalAlign: processNoopValue<'verticalAlign'>,
};

Expand Down
3 changes: 3 additions & 0 deletions packages/stylesheet/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ export type TextTransform =

export type VerticalAlign = 'sub' | 'super';

export type TextWrap = 'wrap' | 'nowrap' | 'pretty' | 'balance';

export type TextStyle = {
direction?: 'ltr' | 'rtl';
fontSize?: number | string;
Expand All @@ -337,6 +339,7 @@ export type TextStyle = {
textIndent?: any; // ?
textOverflow?: 'ellipsis';
textTransform?: TextTransform;
textWrap?: TextWrap;
verticalAlign?: VerticalAlign;
};

Expand Down
18 changes: 18 additions & 0 deletions packages/stylesheet/tests/text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,24 @@ describe('resolve stylesheet text', () => {
expect(styles).toEqual({ textTransform: 'capitalize' });
});

test('should resolve text wrap', () => {
const styles = resolveStyle({ textWrap: 'pretty' });

expect(styles).toEqual({ textWrap: 'pretty' });
});

test('should resolve text wrap nowrap', () => {
const styles = resolveStyle({ textWrap: 'nowrap' });

expect(styles).toEqual({ textWrap: 'nowrap' });
});

test('should resolve text wrap balance', () => {
const styles = resolveStyle({ textWrap: 'balance' });

expect(styles).toEqual({ textWrap: 'balance' });
});

test('should resolve text vertical align', () => {
const styles = resolveStyle({ verticalAlign: 'sub' });

Expand Down
13 changes: 13 additions & 0 deletions packages/textkit/src/engines/linebreaker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ const getNodes = (
return acc;
}, []);

// CSS text-wrap: pretty — forbid breaking at the last whitespace so the
// final line keeps at least the last two words together (avoid-orphans).
// Inserting an infinity penalty before the trailing glue makes K&P's
// `precedesBox` check fail at that position.
if (options.textWrap === 'pretty') {
for (let i = result.length - 1; i >= 0; i -= 1) {
if (result[i].type === 'glue') {
result.splice(i, 0, knuthPlass.penalty(0, knuthPlass.infinity, 0));
break;
}
}
}

// Add mandatory final glue
result.push(knuthPlass.glue(0, start, start, knuthPlass.infinity, 0));
result.push(knuthPlass.penalty(0, -knuthPlass.infinity, 1));
Expand Down
67 changes: 63 additions & 4 deletions packages/textkit/src/layout/layoutParagraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,39 @@ const layoutLines = (

type layoutParagraphEngines = Pick<Engines, 'linebreaker'>;

// Mirror Firefox's `text-wrap: balance` cap. Above this, the spec lets us fall
// back to plain wrap to avoid pathological binary-search cost on body copy.
const BALANCE_LINE_LIMIT = 10;
// Width precision (pt) for the balance binary search.
const BALANCE_PRECISION = 1;

/**
* Find the smallest container width that still yields the same number of
* wrapped lines as the natural width. Equalizes line lengths for
* `text-wrap: balance` (titles, short headings).
*/
const computeBalancedWidth = (
linebreak: ReturnType<Engines['linebreaker']>,
paragraph: AttributedString,
width: number,
naturalLineCount: number,
): number => {
let lo = 0;
let hi = width;

while (hi - lo > BALANCE_PRECISION) {
const mid = (lo + hi) / 2;
const lines = linebreak(paragraph, [mid]);
if (lines.length === naturalLineCount) {
hi = mid;
} else {
lo = mid;
}
}

return hi;
};

/**
* Performs line breaking and layout
*
Expand All @@ -92,11 +125,37 @@ const layoutParagraph = (
const height = stringHeight(paragraph);
const indent = paragraph.runs?.[0]?.attributes?.indent || 0;
const rects = generateLineRects(container, height);
const linebreak = engines.linebreaker(options);

let availableWidths: number[];

if (options.textWrap === 'nowrap') {
availableWidths = [Infinity];
} else if (options.textWrap === 'balance') {
const naturalWidths = rects.map((r) => r.width);
const naturalLines = linebreak(paragraph, naturalWidths);

if (
naturalLines.length > 1 &&
naturalLines.length <= BALANCE_LINE_LIMIT
) {
const balanced = computeBalancedWidth(
linebreak,
paragraph,
naturalWidths[0],
naturalLines.length,
);
availableWidths = [balanced];
} else {
availableWidths = naturalWidths;
availableWidths.unshift(availableWidths[0] - indent);
}
} else {
availableWidths = rects.map((r) => r.width);
availableWidths.unshift(availableWidths[0] - indent);
}

const availableWidths = rects.map((r) => r.width);
availableWidths.unshift(availableWidths[0] - indent);

const lines = engines.linebreaker(options)(paragraph, availableWidths);
const lines = linebreak(paragraph, availableWidths);

return layoutLines(rects, lines, indent);
};
Expand Down
3 changes: 3 additions & 0 deletions packages/textkit/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ export type LayoutOptions = {
shrinkCharFactor?: JustificationFactor;
expandWhitespaceFactor?: JustificationFactor;
shrinkWhitespaceFactor?: JustificationFactor;
textWrap?: TextWrap;
};

export type TextWrap = 'wrap' | 'nowrap' | 'pretty' | 'balance';

export type { Font } from '@react-pdf/font';
Loading
Loading