diff --git a/.changeset/wicked-rockets-check.md b/.changeset/wicked-rockets-check.md new file mode 100644 index 000000000..ce24bedaa --- /dev/null +++ b/.changeset/wicked-rockets-check.md @@ -0,0 +1,8 @@ +--- +'@react-pdf/vite-example': minor +'@react-pdf/stylesheet': minor +'@react-pdf/textkit': minor +'@react-pdf/layout': minor +--- + +Add CSS-compatible text layout properties: wordBreak, hyphens and hyphenateCharacter. Lines also break at Unicode line break opportunities, such as after a hyphen-minus or between CJK characters, without adding a hyphen diff --git a/apps/examples/src/examples/soft-hyphens/index.tsx b/apps/examples/src/examples/soft-hyphens/index.tsx index b9c8166b0..a853a9760 100644 --- a/apps/examples/src/examples/soft-hyphens/index.tsx +++ b/apps/examples/src/examples/soft-hyphens/index.tsx @@ -8,13 +8,18 @@ import { StyleSheet, } from '@react-pdf/renderer'; -const shy = '\u00ad'; +const shy = '­'; Font.register({ family: 'Oswald', src: 'https://fonts.gstatic.com/s/oswald/v13/Y_TKV6o8WovbUd3m_X9aAA.ttf', }); +Font.register({ + family: 'NotoSansJP', + src: 'https://fonts.gstatic.com/s/notosansjp/v52/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj75s.ttf', +}); + const styles = StyleSheet.create({ page: { padding: 40, @@ -74,6 +79,60 @@ const styles = StyleSheet.create({ borderRadius: 3, padding: 8, }, + // Page 2 (hyphens-control) styles + page2: { + padding: 30, + backgroundColor: '#fafafa', + }, + sectionTitle: { + fontFamily: 'Oswald', + fontSize: 12, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 2, + }, + sectionSubtitle: { + fontSize: 9, + color: '#888', + marginBottom: 6, + }, + sectionSubtitleJP: { + fontFamily: 'NotoSansJP', + fontSize: 9, + color: '#888', + marginBottom: 6, + }, + controlSection: { + marginBottom: 10, + }, + smallBox: { + backgroundColor: '#ffffff', + borderRadius: 3, + borderWidth: 1, + borderColor: '#e8e8e8', + padding: 6, + width: 95, + marginRight: 8, + }, + wideBox: { + backgroundColor: '#ffffff', + borderRadius: 3, + borderWidth: 1, + borderColor: '#e8e8e8', + padding: 6, + width: 240, + marginRight: 8, + }, + englishText: { + fontFamily: 'Oswald', + fontSize: 11, + color: '#1a1a1a', + }, + japaneseText: { + fontFamily: 'NotoSansJP', + fontSize: 11, + color: '#1a1a1a', + }, }); const dutchWord = `Potentieel broeikas${shy}gas${shy}emissie${shy}rapport`; @@ -84,6 +143,7 @@ const widths = [80, 120, 180]; const SoftHyphens = () => ( + {/* Page 1: Soft hyphen (U+00AD) auto break demonstration */} Soft Hyphens @@ -159,12 +219,130 @@ const SoftHyphens = () => ( + + {/* Page 2: hyphens / hyphenateCharacter / wordBreak CSS controls */} + + Hyphen & Word Break Controls + + Demonstrating hyphens, hyphenateCharacter, and wordBreak CSS properties + + + + 1. Hyphen Control (English) + + Long word in narrow container — control hyphen character + + + + + Default (hyphen) + + Potentieelbroeikasgasemissierapport + + + + + hyphens: none (overflows) + + Potentieelbroeikasgasemissierapport + + + + + hyphenateCharacter: ... + + Potentieelbroeikasgasemissierapport + + + + + + + 2. CJK Text — wordBreak + + keep-all breaks only at spaces and punctuation + + + + + wordBreak: keep-all + + グレートブリテン および 北アイルランド 連合王国、という言葉 + + + + + + wordBreak: normal (CJK breaks anywhere) + + + グレートブリテン および 北アイルランド 連合王国、という言葉 + + + + + + + + 3. Mixed Content (Japanese + English) + + + CJK breaks anywhere, Latin only at hyphenation points + + + + + wordBreak: normal + + This is a long and Honorificabilitudinitatibus + califragilisticexpialidocious + Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu + グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉 + + + + + wordBreak: break-all + + This is a long and Honorificabilitudinitatibus + califragilisticexpialidocious + Taumatawhakatangihangakoauauotamateaturipukakapikimaungahoronukupokaiwhenuakitanatahu + グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉 + + + + + + + 4. Long URLs + + normal wraps after slashes, break-all wraps at any letter + + + + + wordBreak: normal + + https://example.com/very/very/loooong/path/to/resource + + + + + wordBreak: break-all + + https://example.com/very/very/loooong/path/to/resource + + + + + ); export default { id: 'soft-hyphens', - name: 'Soft Hyphens', - description: '', + name: 'Hyphenation', + description: + 'Soft hyphen (U+00AD) auto-break and the hyphens / hyphenateCharacter / wordBreak CSS controls', Document: SoftHyphens, }; diff --git a/apps/site/components/text-lab/line-break-lab.tsx b/apps/site/components/text-lab/line-break-lab.tsx index 0452bd769..770eab0d0 100644 --- a/apps/site/components/text-lab/line-break-lab.tsx +++ b/apps/site/components/text-lab/line-break-lab.tsx @@ -223,7 +223,7 @@ export function LineBreakLab() { > K&P{' '} {justify - ? (knuth?.length ?? 0) + ? knuth?.length ?? 0 : Math.round(score(knuth, width)).toLocaleString('en-US')} {' '} ·{' '} @@ -234,7 +234,7 @@ export function LineBreakLab() { > greedy{' '} {justify - ? (greedy?.length ?? 0) + ? greedy?.length ?? 0 : Math.round(score(greedy, width)).toLocaleString('en-US')} diff --git a/packages/layout/src/text/layoutText.ts b/packages/layout/src/text/layoutText.ts index 816f82da1..ec95d2fe8 100644 --- a/packages/layout/src/text/layoutText.ts +++ b/packages/layout/src/text/layoutText.ts @@ -92,6 +92,9 @@ const getLayoutOptions = (fontStore, node) => ({ node.props.hyphenationCallback || fontStore?.getHyphenationCallback() || null, + hyphens: node.style?.hyphens, + hyphenateCharacter: node.style?.hyphenateCharacter, + wordBreak: node.style?.wordBreak, }); /** diff --git a/packages/layout/tests/text/layoutText.test.ts b/packages/layout/tests/text/layoutText.test.ts index 357b64d18..81424c2e2 100644 --- a/packages/layout/tests/text/layoutText.test.ts +++ b/packages/layout/tests/text/layoutText.test.ts @@ -104,6 +104,98 @@ describe('text layoutText', () => { ); }); + test('should not hyphenate when hyphens style is "none"', async () => { + const text = 'really\u00adlongtext'; + const hyphens = ['really', 'long', 'text']; + const hyphenationCallback = vi.fn().mockReturnValue(hyphens); + + const node = createTextNode( + text, + { hyphens: 'none' }, + { hyphenationCallback }, + ); + const lines = layoutText(node, 50, 100, fontStore); + + expect(hyphenationCallback).not.toHaveBeenCalled(); + expect(lines.map((line) => line.string)).toEqual(['reallylongtext']); + }); + + test('should hyphenate only at soft hyphens when hyphens style is "manual"', async () => { + const text = 'really\u00adlongtext'; + const hyphens = ['really', 'long', 'text']; + const hyphenationCallback = vi.fn().mockReturnValue(hyphens); + + const node = createTextNode( + text, + { hyphens: 'manual' }, + { hyphenationCallback }, + ); + const lines = layoutText(node, 50, 100, fontStore); + + expect(hyphenationCallback).not.toHaveBeenCalled(); + expect(lines.map((line) => line.string)).toEqual(['really-', 'longtext']); + }); + + test('should use custom hyphenate character when hyphenateCharacter is set', async () => { + const text = 'reallylongtext'; + const hyphens = ['really­', 'long', 'text']; + const hyphenationCallback = vi.fn().mockReturnValue(hyphens); + + const node = createTextNode( + text, + { hyphenateCharacter: '・' }, + { hyphenationCallback }, + ); + const lines = layoutText(node, 50, 100, fontStore); + + expect(lines[0].string).toEqual('really・'); + expect(lines[1].string).toEqual('long・'); + expect(lines[2].string).toEqual('text'); + }); + + test('should not add hyphens when hyphenateCharacter is empty string', async () => { + const text = 'reallylongtext'; + const hyphens = ['really­', 'long', 'text']; + const hyphenationCallback = vi.fn().mockReturnValue(hyphens); + + const node = createTextNode( + text, + { hyphenateCharacter: '' }, + { hyphenationCallback }, + ); + const lines = layoutText(node, 50, 100, fontStore); + + expect(lines[0].string).toEqual('really'); + expect(lines[1].string).toEqual('long'); + expect(lines[2].string).toEqual('text'); + }); + + test('should keep CJK text together with wordBreak: keep-all', async () => { + const text = '東京都区'; + const hyphenationCallback = vi.fn().mockImplementation((word) => [...word]); + + const node = createTextNode( + text, + { wordBreak: 'keep-all' }, + { hyphenationCallback }, + ); + const lines = layoutText(node, 300, 100, fontStore); + + expect(lines).toHaveLength(1); + expect(lines[0].string).toBe('東京都区'); + }); + + test('should break all characters with wordBreak: break-all', async () => { + const text = 'Hello'; + const node = createTextNode(text, { wordBreak: 'break-all' }); + const lines = layoutText(node, 15, 100, fontStore); + + expect(lines.length).toBeGreaterThan(1); + + const allChars = lines.map((line) => line.string).join(''); + expect(allChars).toBe('Hello'); + }); + test('should suppress hyphenation when `hyphenationPenalty` is set to `Infinity`', () => { const text = 'Lorem ipsum dolor sit amet consectetur adipiscing elit'; diff --git a/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-break-anywhere-with-word-break-break-all-1-snap.png b/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-break-anywhere-with-word-break-break-all-1-snap.png new file mode 100644 index 000000000..2f52d029d Binary files /dev/null and b/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-break-anywhere-with-word-break-break-all-1-snap.png differ diff --git a/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-control-hyphens-and-hyphenate-character-1-snap.png b/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-control-hyphens-and-hyphenate-character-1-snap.png new file mode 100644 index 000000000..73995cda8 Binary files /dev/null and b/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-control-hyphens-and-hyphenate-character-1-snap.png differ diff --git a/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-keep-cjk-words-together-with-word-break-keep-all-1-snap.png b/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-keep-cjk-words-together-with-word-break-keep-all-1-snap.png new file mode 100644 index 000000000..c322c86e7 Binary files /dev/null and b/packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-keep-cjk-words-together-with-word-break-keep-all-1-snap.png differ diff --git a/packages/renderer/tests/text.test.jsx b/packages/renderer/tests/text.test.jsx index 074bc5d8c..f2ed36c40 100644 --- a/packages/renderer/tests/text.test.jsx +++ b/packages/renderer/tests/text.test.jsx @@ -192,4 +192,75 @@ describe('text', () => { expect(image).toMatchImageSnapshot(); }); + + test('should control hyphens and hyphenateCharacter', async () => { + const shy = '­'; + const word = `Potentieel broeikas${shy}gas${shy}emissie${shy}rapport`; + + const style = { + fontFamily: 'Oswald', + fontSize: 16, + width: 90, + marginBottom: 10, + border: '1px solid red', + }; + + const image = await renderToImage( + + + {word} + {word} + {word} + + , + ); + + expect(image).toMatchImageSnapshot(); + }); + + test('should keep CJK words together with wordBreak keep-all', async () => { + const text = 'グレートブリテン および 北アイルランド 連合王国'; + + const style = { + fontFamily: 'NotoSansJP', + fontSize: 12, + width: 100, + marginBottom: 10, + border: '1px solid #ccc', + }; + + const image = await renderToImage( + + + {text} + {text} + + , + ); + + expect(image).toMatchImageSnapshot(); + }); + + test('should break anywhere with wordBreak break-all', async () => { + const url = 'https://example.com/very/very/loooong/path/to/resource'; + + const style = { + fontFamily: 'Oswald', + fontSize: 12, + width: 150, + marginBottom: 10, + border: '1px solid red', + }; + + const image = await renderToImage( + + + {url} + {url} + + , + ); + + expect(image).toMatchImageSnapshot(); + }); }); diff --git a/packages/stylesheet/src/types.ts b/packages/stylesheet/src/types.ts index cdcf5c22e..109d7f3fc 100644 --- a/packages/stylesheet/src/types.ts +++ b/packages/stylesheet/src/types.ts @@ -353,6 +353,10 @@ export type TextTransform = export type VerticalAlign = 'sub' | 'super'; +export type Hyphens = 'none' | 'manual' | 'auto'; + +export type WordBreak = 'normal' | 'break-all' | 'keep-all'; + export type FontFeatureSetting = | 'liga' | 'dlig' @@ -396,6 +400,9 @@ export type TextStyle = { fontStyle?: FontStyle; fontWeight?: FontWeight; fontFeatureSettings?: FontFeatureSettings; + hyphens?: Hyphens; + hyphenateCharacter?: string; + wordBreak?: WordBreak; letterSpacing?: number | string; lineHeight?: number | string; maxLines?: number | string; diff --git a/packages/textkit/package.json b/packages/textkit/package.json index 239d04b8b..1278e7c76 100644 --- a/packages/textkit/package.json +++ b/packages/textkit/package.json @@ -28,6 +28,7 @@ "@react-pdf/fns": "3.1.3", "@react-pdf/hyphenate": "^0.1.0", "bidi-js": "^1.0.2", + "linebreak": "^1.1.0", "unicode-properties": "^1.4.1" }, "devDependencies": { diff --git a/packages/textkit/src/engines/linebreaker/index.ts b/packages/textkit/src/engines/linebreaker/index.ts index 57f15b39a..61424b9d1 100644 --- a/packages/textkit/src/engines/linebreaker/index.ts +++ b/packages/textkit/src/engines/linebreaker/index.ts @@ -16,22 +16,59 @@ const opts = { shrink: 9, }; +/** + * Get the hyphen character code point(s) based on options + * + * @param options - Layout options + * @returns Array of code points for the hyphen character, or null if no hyphen should be inserted + */ +const getHyphenCodePoints = (options: LayoutOptions): number[] | null => { + // If hyphens is 'none', don't insert any hyphen character + if (options.hyphens === 'none') { + return null; + } + + // If hyphenateCharacter is explicitly set + if (options.hyphenateCharacter !== undefined) { + // Empty string means no hyphen + if (options.hyphenateCharacter === '') { + return null; + } + // Convert custom character to code points + const codePoints: number[] = []; + for (const char of options.hyphenateCharacter) { + const codePoint = char.codePointAt(0); + if (codePoint !== undefined) { + codePoints.push(codePoint); + } + } + return codePoints.length > 0 ? codePoints : null; + } + + // Default: use standard hyphen + return [HYPHEN]; +}; + /** * Slice attributed string to many lines * * @param attributedString - Attributed string * @param nodes * @param breaks + * @param options - Layout options * @returns Attributed strings */ const breakLines = ( attributedString: AttributedString, nodes: Node[], breaks: number[], + options: LayoutOptions, ) => { let start = 0; let end = null; + const hyphenCodePoints = getHyphenCodePoints(options); + const lines: AttributedString[] = []; for (const breakPoint of breaks) { @@ -48,7 +85,12 @@ const breakLines = ( line = slice(start, end, attributedString); - line = insertGlyph(line.string.length, HYPHEN, line); + // Only hyphenation breaks are flagged; soft wrap breaks show nothing. + if (node.flagged && hyphenCodePoints !== null) { + for (const codePoint of hyphenCodePoints) { + line = insertGlyph(line.string.length, codePoint, line); + } + } } else { end = node.end; line = slice(start, end, attributedString); @@ -79,9 +121,9 @@ const getNodes = ( ): Node[] => { let start = 0; - const hyphenWidth = 5; + const hyphenWidth = getHyphenCodePoints(options) === null ? 0 : 5; - const { syllables } = attributedString; + const { syllables, syllableBreaks } = attributedString; const hyphenPenalty = options.hyphenationPenalty || (align === 'justify' ? 100 : 600); @@ -101,15 +143,21 @@ const getNodes = ( // Add glue node. Glue nodes are used to fill the space between words. acc.push(knuthPlass.glue(width, start, end, stretch, shrink)); } else { - const hyphenated = syllables[index + 1] !== ' '; + const next = syllables[index + 1]; + const breakable = next !== undefined && next.trim() !== ''; + const hyphenated = + breakable && (syllableBreaks?.[index] ?? 'hyphen') === 'hyphen'; const end = start + s.length; // Add box node. Box nodes are used to represent words. acc.push(knuthPlass.box(width, start, end, hyphenated)); - if (syllables[index + 1] && hyphenated) { - // Add penalty node. Penalty nodes are used to represent hyphenation points. + // Add penalty node. Penalty nodes are used to represent breaks inside + // words. Only hyphenation breaks cost a hyphen. + if (hyphenated) { acc.push(knuthPlass.penalty(hyphenWidth, hyphenPenalty, 1)); + } else if (breakable) { + acc.push(knuthPlass.penalty(0, 0, 0)); } } @@ -163,7 +211,7 @@ const linebreaker = (options: LayoutOptions) => { breaks = bestFit(nodes, availableWidths); } - return breakLines(attributedString, nodes, breaks.slice(1)); + return breakLines(attributedString, nodes, breaks.slice(1), options); }; }; diff --git a/packages/textkit/src/layout/wrapWords.ts b/packages/textkit/src/layout/wrapWords.ts index 0339736a9..494dfa405 100644 --- a/packages/textkit/src/layout/wrapWords.ts +++ b/packages/textkit/src/layout/wrapWords.ts @@ -1,9 +1,16 @@ +import LineBreaker from 'linebreak'; + import fromFragments from '../attributedString/fromFragments'; import { Engines } from '../engines'; -import { AttributedString, LayoutOptions } from '../types'; +import { AttributedString, LayoutOptions, SyllableBreak } from '../types'; const SOFT_HYPHEN = '\u00ad'; +// break-all and keep-all act on letters and digits. A combining mark belongs +// to the letter before it, so nothing may break before one. +const LETTER = /^[\p{L}\p{N}\p{M}]/u; +const LETTER_START = /^[\p{L}\p{N}]/u; + /** * Default word hyphenation engine used when no one provided. * Does not perform word hyphenation at all @@ -23,6 +30,38 @@ const removeSoftHyphens = (word: string) => { return word.replaceAll(SOFT_HYPHEN, ''); }; +const firstChar = (text: string) => /^[\s\S]/u.exec(text)![0]; + +const lastChar = (text: string) => /[\s\S]$/u.exec(text)![0]; + +const pairBreaks = new Map(); + +/** + * Whether UAX #14 allows a line break between two characters + * + * @param before - Character before the break + * @param after - Character after the break + * @returns Whether a break is allowed + */ +const canBreakBetween = (before: string, after: string) => { + const key = `${before}\u0000${after}`; + let result = pairBreaks.get(key); + + if (result === undefined) { + const position = new LineBreaker(before + after).nextBreak()?.position; + result = position === before.length; + pairBreaks.set(key, result); + } + + return result; +}; + +// U+3042 allows a break on both sides, so pairing with it isolates the rule +// of the other character. +const isLineStartProhibited = (char: string) => !canBreakBetween('あ', char); + +const isLineEndProhibited = (char: string) => !canBreakBetween(char, 'あ'); + /** * Wrap words of attribute string * @@ -33,18 +72,99 @@ const wrapWords = ( engines: Partial = {}, options: LayoutOptions = {}, ) => { + const { hyphens, wordBreak } = options; + + const breakAll = wordBreak === 'break-all'; + const keepAll = wordBreak === 'keep-all'; + + /** + * @param uax14 - Whether UAX #14 allows the break + * @param before - Character before the break + * @param after - Character after the break + * @returns Whether the break is a soft wrap opportunity under word-break + */ + const isSoftBreak = (uax14: boolean, before: string, after: string) => { + if (keepAll && LETTER.test(before) && LETTER.test(after)) return false; + if (breakAll && LETTER.test(before) && LETTER_START.test(after)) { + return true; + } + + return uax14; + }; + + /** + * @param word - Word without white space + * @param hyphenAt - Offsets of hyphenation opportunities + * @returns Line break kind at each offset where the word may break + */ + const getWordBreaks = (word: string, hyphenAt: Set) => { + const breaks = new Map(); + const breaker = new LineBreaker(word); + const chars = Array.from(word); + + const uax14 = new Set(); + let bk; + while ((bk = breaker.nextBreak())) uax14.add(bk.position); + + let offset = 0; + + for (let i = 1; i < chars.length; i += 1) { + offset += chars[i - 1].length; + + const before = chars[i - 1]; + const after = chars[i]; + + if (isSoftBreak(uax14.has(offset), before, after)) { + breaks.set(offset, 'soft'); + } else if ( + hyphenAt.has(offset) && + !(keepAll && uax14.has(offset)) && + !isLineStartProhibited(after) && + !isLineEndProhibited(before) + ) { + breaks.set(offset, 'hyphen'); + } + } + + return breaks; + }; + /** * @param attributedString - Attributed string * @returns Attributed string including syllables */ return (attributedString: AttributedString) => { - const syllables = []; + const syllables: string[] = []; + const syllableBreaks: SyllableBreak[] = []; const fragments = []; const builtinHyphenate = engines.wordHyphenation?.() || defaultHyphenate; const hyphenate = options.hyphenationCallback || builtinHyphenate; + // CSS applies no hyphenation with break-all. + const splitWord = (word: string): string[] => { + if (hyphens === 'none' || breakAll) return [removeSoftHyphens(word)]; + if (hyphens === 'manual') return word.split(SOFT_HYPHEN); + + return hyphenate(word, builtinHyphenate).map(removeSoftHyphens); + }; + + // A null break joins the syllable to the previous one. + const push = (syllable: string, breakBefore: SyllableBreak | null) => { + const last = syllables.length - 1; + + if (last >= 0 && breakBefore === null) { + syllables[last] += syllable; + return; + } + + if (last >= 0) syllableBreaks[last] = breakBefore!; + + syllables.push(syllable); + syllableBreaks.push('soft'); + }; + let offset = 0; for (let i = 0; i < attributedString.runs.length; i += 1) { @@ -58,11 +178,48 @@ const wrapWords = ( .filter(Boolean); for (let j = 0; j < words.length; j += 1) { - const word = words[j]; - const parts = hyphenate(word, builtinHyphenate).map(removeSoftHyphens); + const parts = splitWord(words[j]); + const word = parts.join(''); + + string += word; + + if (word === '') continue; + + const previous = syllables[syllables.length - 1]; + + if (word.trim() === '') { + push(word, 'soft'); + continue; + } + + // Words only meet without white space where a run ends inside a word. + const joinsPrevious = previous !== undefined && previous.trim() !== ''; + + let breakBefore: SyllableBreak | null = 'soft'; + if (joinsPrevious) { + const before = lastChar(previous); + const after = firstChar(word); + + const uax14 = canBreakBetween(before, after); + + breakBefore = isSoftBreak(uax14, before, after) ? 'soft' : null; + } + + const hyphenAt = new Set(); + let position = 0; + for (const part of parts) { + position += part.length; + hyphenAt.add(position); + } + + let start = 0; + for (const [end, kind] of getWordBreaks(word, hyphenAt)) { + push(word.slice(start, end), breakBefore); + breakBefore = kind; + start = end; + } - syllables.push(...parts); - string += parts.join(''); + push(word.slice(start), breakBefore); } // Modify run start and end based on removed soft hyphens. @@ -75,7 +232,11 @@ const wrapWords = ( offset += runOffset; } - const result: AttributedString = { ...fromFragments(fragments), syllables }; + const result: AttributedString = { + ...fromFragments(fragments), + syllables, + syllableBreaks, + }; return result; }; diff --git a/packages/textkit/src/types.ts b/packages/textkit/src/types.ts index e94250922..192b5c526 100644 --- a/packages/textkit/src/types.ts +++ b/packages/textkit/src/types.ts @@ -133,9 +133,15 @@ export type DecorationLine = { style: string; }; +// A soft wrap opportunity breaks as is. A hyphenation opportunity shows the +// hyphenate character when the line breaks there. +export type SyllableBreak = 'soft' | 'hyphen'; + export type AttributedString = { string: string; syllables?: string[]; + /** How the line may break after each syllable. Missing means 'hyphen'. */ + syllableBreaks?: SyllableBreak[]; runs: Run[]; box?: Rect; decorationLines?: DecorationLine[]; @@ -165,6 +171,28 @@ export type LayoutOptions = { shrinkCharFactor?: JustificationFactor; expandWhitespaceFactor?: JustificationFactor; shrinkWhitespaceFactor?: JustificationFactor; + /** + * CSS-like hyphens property. + * - 'none': Words are not hyphenated, even at soft hyphens + * - 'manual': Words are hyphenated only at soft hyphens + * - 'auto': Words are hyphenated by the hyphenation engine or callback (default) + * + * Unlike CSS, the default is 'auto' to keep the existing behavior. + */ + hyphens?: 'none' | 'auto' | 'manual'; + /** + * CSS-like hyphenate-character property. + * String shown at a hyphenation break. Default is '-'. + * An empty string hyphenates without a visible character. + */ + hyphenateCharacter?: string; + /** + * CSS-like word-break property. + * - 'normal': Lines break at UAX #14 opportunities, e.g. between CJK characters (default) + * - 'break-all': Lines may also break between any letters or digits, without hyphenation + * - 'keep-all': Lines do not break between letters, including CJK characters + */ + wordBreak?: 'normal' | 'break-all' | 'keep-all'; }; export type { Font } from '@react-pdf/font'; diff --git a/packages/textkit/src/types/linebreak.d.ts b/packages/textkit/src/types/linebreak.d.ts new file mode 100644 index 000000000..5e3548898 --- /dev/null +++ b/packages/textkit/src/types/linebreak.d.ts @@ -0,0 +1,13 @@ +declare module 'linebreak' { + interface Break { + position: number; + required: boolean; + } + + class LineBreaker { + constructor(text: string); + nextBreak(): Break | null; + } + + export = LineBreaker; +} diff --git a/packages/textkit/tests/engines/linebreaker.test.ts b/packages/textkit/tests/engines/linebreaker.test.ts index 1c6366e8a..4a8d45642 100644 --- a/packages/textkit/tests/engines/linebreaker.test.ts +++ b/packages/textkit/tests/engines/linebreaker.test.ts @@ -7,6 +7,37 @@ import font from '../internal/font'; const width = 50; +const CJK_ADVANCE = 10; + +const makePosition = (xAdvance: number) => ({ + xAdvance, + yAdvance: 0, + xOffset: 0, + yOffset: 0, +}); + +const makeAttributedString = ( + str: string, + syllables: string[], + advanceWidth = CJK_ADVANCE, +) => ({ + string: str, + runs: [ + { + start: 0, + end: str.length, + attributes: { font: [font] }, + glyphIndices: Array.from({ length: str.length }, (_, i) => i), + positions: Array.from({ length: str.length }, () => + makePosition(advanceWidth), + ), + glyphs: [], + string: str, + }, + ], + syllables, +}); + describe('linebreaker', () => { const linebreaker = linebreakerFactory({}); @@ -41,281 +72,9 @@ describe('linebreaker', () => { }; test('should break lines and adds hyphens only where indicated', () => { - const attributedString = { - string: 'Potentieel broeikasgasemissierapport', - runs: [ - { - start: 0, - end: 36, - attributes: { font: [font] }, - stringIndices: [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, - 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, - ], - glyphIndices: [ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, - 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, - ], - positions: [ - { - xAdvance: 11.106, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 667, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 5.004, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 278, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 5.004, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 278, - }, - { - xAdvance: 3.9959999999999996, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 222, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 3.9959999999999996, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 222, - }, - { - xAdvance: 5.004, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 278, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 5.994, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 333, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 3.9959999999999996, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 222, - }, - { - xAdvance: 9, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 500, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 9, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 500, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 9, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 500, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 14.993999999999998, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 833, - }, - { - xAdvance: 3.9959999999999996, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 222, - }, - { - xAdvance: 9, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 500, - }, - { - xAdvance: 9, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 500, - }, - { - xAdvance: 3.9959999999999996, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 222, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 5.813999999999999, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 333, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 10.008, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 556, - }, - { - xAdvance: 6.7139999999999995, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 333, - }, - { - xAdvance: 5.004, - yAdvance: 0, - xOffset: 0, - yOffset: 0, - advanceWidth: 278, - }, - ], - glyphs: [], - string: 'Potentieel broeikasgasemissierapport', - }, - ], - syllables: ['Potentieel', ' ', 'broeikasgas', 'emissie', 'rapport'], - }; + const str = 'Potentieel broeikasgasemissierapport'; + const syllables = ['Potentieel', ' ', 'broeikasgas', 'emissie', 'rapport']; + const attributedString = makeAttributedString(str, syllables, 8); const result = linebreaker(attributedString, [10]); @@ -327,6 +86,41 @@ describe('linebreaker', () => { ]); }); + test('should add hyphens only at hyphenation breaks', () => { + const attributedString = createAttributedString('left', [ + 'state-', + 'of-', + 'really', + 'long', + ]); + + const result = linebreaker( + { + ...attributedString, + syllableBreaks: ['soft', 'soft', 'hyphen', 'soft'], + }, + [40], + ); + + expect(result.map((line) => line.string)).toEqual([ + 'state-', + 'of-', + 'really-', + 'long', + ]); + }); + + test('should use the hyphenate character at hyphenation breaks', () => { + const attributedString = createAttributedString('left', ['really', 'long']); + + const result = linebreakerFactory({ hyphenateCharacter: '~' })( + { ...attributedString, syllableBreaks: ['hyphen', 'soft'] }, + [40], + ); + + expect(result.map((line) => line.string)).toEqual(['really~', 'long']); + }); + test('should preserve breaks with constant line widths', () => { const syllables = 'alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu' diff --git a/packages/textkit/tests/layout/wrapWords.test.ts b/packages/textkit/tests/layout/wrapWords.test.ts index 4c44fa94f..66e7d821e 100644 --- a/packages/textkit/tests/layout/wrapWords.test.ts +++ b/packages/textkit/tests/layout/wrapWords.test.ts @@ -156,4 +156,247 @@ describe('wrapWords', () => { expect(result.runs[1]).toHaveProperty('end', 11); }); }); + + describe('word-break option', () => { + test('should split CJK characters individually with wordBreak: normal (default)', () => { + const instance = wrapWords({}, {}); + const result = instance({ + string: '本当に長いテキスト', + runs: [{ start: 0, end: 9, attributes: {} }], + }); + + // CJK characters should be split individually + expect(result.syllables).toEqual([ + '本', + '当', + 'に', + '長', + 'い', + 'テ', + 'キ', + 'ス', + 'ト', + ]); + }); + + test('should split CJK characters individually with wordBreak: normal (explicit)', () => { + const instance = wrapWords({}, { wordBreak: 'normal' }); + const result = instance({ + string: '本当に長いテキスト', + runs: [{ start: 0, end: 9, attributes: {} }], + }); + + expect(result.syllables).toEqual([ + '本', + '当', + 'に', + '長', + 'い', + 'テ', + 'キ', + 'ス', + 'ト', + ]); + }); + + test('should not split Latin characters with wordBreak: normal', () => { + const instance = wrapWords({}, { wordBreak: 'normal' }); + const result = instance({ + string: 'Hello world', + runs: [{ start: 0, end: 11, attributes: {} }], + }); + + // Latin characters should stay grouped + expect(result.syllables).toEqual(['Hello', ' ', 'world']); + }); + + test('should split mixed CJK and Latin with wordBreak: normal', () => { + const instance = wrapWords({}, { wordBreak: 'normal' }); + const result = instance({ + string: 'Hello世界', + runs: [{ start: 0, end: 7, attributes: {} }], + }); + + // Latin stays grouped, CJK splits + expect(result.syllables).toEqual(['Hello', '世', '界']); + }); + + test('should split all characters with wordBreak: break-all', () => { + const instance = wrapWords({}, { wordBreak: 'break-all' }); + const result = instance({ + string: 'Hello', + runs: [{ start: 0, end: 5, attributes: {} }], + }); + + // All characters should be split + expect(result.syllables).toEqual(['H', 'e', 'l', 'l', 'o']); + }); + + test('should not split CJK characters with wordBreak: keep-all', () => { + const instance = wrapWords({}, { wordBreak: 'keep-all' }); + const result = instance({ + string: '本当に長いテキスト', + runs: [{ start: 0, end: 9, attributes: {} }], + }); + + // CJK characters should stay grouped (not split) + expect(result.syllables).toEqual(['本当に長いテキスト']); + }); + + test('should handle Hiragana correctly', () => { + const instance = wrapWords({}, { wordBreak: 'normal' }); + const result = instance({ + string: 'あいうえお', + runs: [{ start: 0, end: 5, attributes: {} }], + }); + + expect(result.syllables).toEqual(['あ', 'い', 'う', 'え', 'お']); + }); + + test('should handle Katakana correctly', () => { + const instance = wrapWords({}, { wordBreak: 'normal' }); + const result = instance({ + string: 'アイウエオ', + runs: [{ start: 0, end: 5, attributes: {} }], + }); + + expect(result.syllables).toEqual(['ア', 'イ', 'ウ', 'エ', 'オ']); + }); + + test('should handle Korean correctly', () => { + const instance = wrapWords({}, { wordBreak: 'normal' }); + const result = instance({ + string: '한글테스트', + runs: [{ start: 0, end: 5, attributes: {} }], + }); + + expect(result.syllables).toEqual(['한', '글', '테', '스', '트']); + }); + }); + describe('break kinds', () => { + const run = (string: string) => ({ + string, + runs: [{ start: 0, end: string.length, attributes: {} }], + }); + + const splitter = vi.fn((word: string) => + word === 'reallylong' ? ['really', 'long'] : [word], + ); + + test('should mark hyphenation points as hyphen breaks', () => { + const instance = wrapWords({ wordHyphenation: () => splitter }, {}); + const result = instance(run('reallylong')); + + expect(result.syllables).toEqual(['really', 'long']); + expect(result.syllableBreaks).toEqual(['hyphen', 'soft']); + }); + + test('should mark breaks after a hyphen-minus as soft breaks', () => { + const instance = wrapWords({}, {}); + const result = instance(run('state-of-the-art')); + + expect(result.syllables).toEqual(['state-', 'of-', 'the-', 'art']); + expect(result.syllableBreaks).toEqual(['soft', 'soft', 'soft', 'soft']); + }); + + test('should not hyphenate with hyphens: none, even at soft hyphens', () => { + const instance = wrapWords( + { wordHyphenation: () => splitter }, + { hyphens: 'none' }, + ); + const result = instance(run('really\u00adlong')); + + expect(result.string).toEqual('reallylong'); + expect(result.syllables).toEqual(['reallylong']); + }); + + test('should hyphenate only at soft hyphens with hyphens: manual', () => { + const instance = wrapWords( + { wordHyphenation: () => (word: string) => [...word] }, + { hyphens: 'manual' }, + ); + const result = instance(run('really\u00adlong')); + + expect(result.syllables).toEqual(['really', 'long']); + expect(result.syllableBreaks).toEqual(['hyphen', 'soft']); + }); + + test('should not hyphenate with wordBreak: break-all', () => { + const hyphenate = vi.fn((word: string) => [word]); + const instance = wrapWords( + { wordHyphenation: () => hyphenate }, + { wordBreak: 'break-all' }, + ); + const result = instance(run('12345')); + + expect(hyphenate).not.toHaveBeenCalled(); + expect(result.syllables).toEqual(['1', '2', '3', '4', '5']); + expect(result.syllableBreaks).toEqual([ + 'soft', + 'soft', + 'soft', + 'soft', + 'soft', + ]); + }); + + test('should keep punctuation and combining marks attached with wordBreak: break-all', () => { + const instance = wrapWords({}, { wordBreak: 'break-all' }); + + expect(instance(run('(ab).')).syllables).toEqual(['(a', 'b).']); + expect(instance(run('e\u0301e\u0301')).syllables).toEqual([ + 'e\u0301', + 'e\u0301', + ]); + }); + + test('should break after punctuation with wordBreak: keep-all', () => { + const instance = wrapWords({}, { wordBreak: 'keep-all' }); + const result = instance(run('日本語、テスト')); + + expect(result.syllables).toEqual(['日本語、', 'テスト']); + }); + + test('should drop hyphenation points that UAX #14 forbids', () => { + const instance = wrapWords( + { wordHyphenation: () => (word: string) => [...word] }, + {}, + ); + const result = instance(run('あ、い')); + + expect(result.syllables).toEqual(['あ、', 'い']); + expect(result.syllableBreaks).toEqual(['soft', 'soft']); + }); + + test('should break between runs only where UAX #14 allows', () => { + const instance = wrapWords({}, {}); + const result = instance({ + string: 'word.日本', + runs: [ + { start: 0, end: 4, attributes: {} }, + { start: 4, end: 5, attributes: {} }, + { start: 5, end: 6, attributes: {} }, + { start: 6, end: 7, attributes: {} }, + ], + }); + + expect(result.syllables).toEqual(['word.', '日', '本']); + expect(result.syllableBreaks).toEqual(['soft', 'soft', 'soft']); + }); + + test('should not break between CJK runs with wordBreak: keep-all', () => { + const instance = wrapWords({}, { wordBreak: 'keep-all' }); + const result = instance({ + string: '句読点の後ろ', + runs: [ + { start: 0, end: 3, attributes: {} }, + { start: 3, end: 4, attributes: {} }, + { start: 4, end: 5, attributes: {} }, + { start: 5, end: 6, attributes: {} }, + ], + }); + + expect(result.syllables).toEqual(['句読点の後ろ']); + }); + }); });