From 724bddfd6489054c014ce0083e1afadc1212ae5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TATSUNO=20=E2=80=9CTaz=E2=80=9D=20Yasuhiro?= Date: Tue, 27 Jan 2026 15:02:24 +0900 Subject: [PATCH 1/3] feat: CSS-compatible workBreak, hyphens and hyphenateCharacter --- .changeset/wicked-rockets-check.md | 8 + .../vite/src/examples/soft-hyphens/index.tsx | 197 ++++++++++- packages/layout/src/text/layoutText.ts | 3 + packages/layout/tests/text/layoutText.test.ts | 80 +++++ packages/stylesheet/src/types.ts | 7 + packages/textkit/package.json | 1 + .../textkit/src/engines/linebreaker/index.ts | 99 +++++- packages/textkit/src/layout/wrapWords.ts | 121 ++++++- packages/textkit/src/types.ts | 20 ++ packages/textkit/src/types/linebreak.d.ts | 13 + .../textkit/tests/engines/linebreaker.test.ts | 309 ++---------------- .../textkit/tests/layout/wrapWords.test.ts | 117 +++++++ 12 files changed, 688 insertions(+), 287 deletions(-) create mode 100644 .changeset/wicked-rockets-check.md create mode 100644 packages/textkit/src/types/linebreak.d.ts diff --git a/.changeset/wicked-rockets-check.md b/.changeset/wicked-rockets-check.md new file mode 100644 index 000000000..7059d21e0 --- /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 hyphenateCharacterord diff --git a/packages/examples/vite/src/examples/soft-hyphens/index.tsx b/packages/examples/vite/src/examples/soft-hyphens/index.tsx index b9c8166b0..daea2f892 100644 --- a/packages/examples/vite/src/examples/soft-hyphens/index.tsx +++ b/packages/examples/vite/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,143 @@ 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 + + Potentieelbroeikasgasemissierapport + + + + + hyphenateCharacter: ... + + Potentieelbroeikasgasemissierapport + + + + + + + 2. CJK Text — wordBreak + + Problem: "グレートブリテン" alone on a line due to + script-based run splitting + + + + + wordBreak: keep-all (problem) + + グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉 + + + + + + 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 + + break-all allows URLs to wrap at any character + + + + + wordBreak: normal (overflow) + + https://example.com/very/very/loooong/path/to/resource + + + + + + wordBreak: break-all, hyphens: none + + + 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/packages/layout/src/text/layoutText.ts b/packages/layout/src/text/layoutText.ts index e87235b67..04a63d121 100644 --- a/packages/layout/src/text/layoutText.ts +++ b/packages/layout/src/text/layoutText.ts @@ -63,6 +63,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 34441e008..47aa10625 100644 --- a/packages/layout/tests/text/layoutText.test.ts +++ b/packages/layout/tests/text/layoutText.test.ts @@ -103,4 +103,84 @@ describe('text layoutText', () => { expect.any(Function), ); }); + + test('should not add hyphens when hyphens style is "none"', async () => { + const text = 'reallylongtext'; + 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(lines[0].string).toEqual('really'); + expect(lines[1].string).toEqual('long'); + expect(lines[2].string).toEqual('text'); + }); + + 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', + hyphens: 'none', + }); + const lines = layoutText(node, 15, 100, fontStore); + + expect(lines.length).toBeGreaterThan(1); + + const allChars = lines.map((line) => line.string).join(''); + expect(allChars).toBe('Hello'); + }); }); diff --git a/packages/stylesheet/src/types.ts b/packages/stylesheet/src/types.ts index e7bd5adc6..f45bb6f1c 100644 --- a/packages/stylesheet/src/types.ts +++ b/packages/stylesheet/src/types.ts @@ -321,12 +321,19 @@ export type TextTransform = export type VerticalAlign = 'sub' | 'super'; +export type Hyphens = 'none' | 'manual' | 'auto'; + +export type WordBreak = 'normal' | 'break-all' | 'keep-all'; + export type TextStyle = { direction?: 'ltr' | 'rtl'; fontSize?: number | string; fontFamily?: string | string[]; fontStyle?: FontStyle; fontWeight?: FontWeight; + 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 3f593b219..5dbd86821 100644 --- a/packages/textkit/package.json +++ b/packages/textkit/package.json @@ -28,6 +28,7 @@ "@react-pdf/fns": "3.1.3", "bidi-js": "^1.0.2", "hyphen": "^1.6.4", + "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 db55a1c6a..28fb9d241 100644 --- a/packages/textkit/src/engines/linebreaker/index.ts +++ b/packages/textkit/src/engines/linebreaker/index.ts @@ -1,3 +1,5 @@ +import unicode from 'unicode-properties'; + import bestFit from './bestFit'; import knuthPlass from './knuthPlass'; import slice from '../../attributedString/slice'; @@ -6,6 +8,17 @@ import advanceWidthBetween from '../../attributedString/advanceWidthBetween'; import { AttributedString, Attributes, LayoutOptions } from '../../types'; import { Node } from './types'; +/** + * Check if a character is East Asian Wide or Fullwidth. + * These characters don't need hyphens when wrapping. + */ +const isEastAsianWide = (char: string): boolean => { + const codePoint = char.codePointAt(0); + if (codePoint === undefined) return false; + const eaw = unicode.getEastAsianWidth(codePoint); + return eaw === 'W' || eaw === 'F'; +}; + const HYPHEN = 0x002d; const TOLERANCE_STEPS = 5; const TOLERANCE_LIMIT = 50; @@ -16,22 +29,83 @@ 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]; +}; + +/** + * Check if a hyphen should be inserted at the end of a line. + * CJK characters don't need hyphens when wrapping. + * + * @param line - Line attributed string + * @param hyphenCodePoints - Hyphen code points to use + * @returns True if hyphen should be inserted + */ +const shouldInsertHyphen = ( + line: AttributedString, + hyphenCodePoints: number[] | null, +): boolean => { + if (hyphenCodePoints === null) return false; + + // Get the last character of the line + const lastChar = line.string.slice(-1); + if (!lastChar) return false; + + // Don't insert hyphen after East Asian Wide characters (CJK, etc.) + if (isEastAsianWide(lastChar)) return false; + + return true; +}; + /** * 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[] = breaks.reduce((acc, breakPoint) => { const node = nodes[breakPoint]; const prevNode = nodes[breakPoint - 1]; @@ -46,7 +120,12 @@ const breakLines = ( line = slice(start, end, attributedString); - line = insertGlyph(line.string.length, HYPHEN, line); + // Insert hyphen character(s) if configured and appropriate + if (shouldInsertHyphen(line, hyphenCodePoints)) { + for (const codePoint of hyphenCodePoints!) { + line = insertGlyph(line.string.length, codePoint, line); + } + } } else { end = node.end; line = slice(start, end, attributedString); @@ -54,7 +133,8 @@ const breakLines = ( start = end; - return [...acc, line]; + acc.push(line); + return acc; }, []); lines.push(slice(start, attributedString.string.length, attributedString)); @@ -77,13 +157,15 @@ const getNodes = ( ): Node[] => { let start = 0; - const hyphenWidth = 5; + const hyphenWidth = getHyphenCodePoints(options) === null ? 0 : 5; const { syllables } = attributedString; const hyphenPenalty = options.hyphenationPenalty || (align === 'justify' ? 100 : 600); + const allowCJKBreak = options.wordBreak !== 'keep-all'; + const result = syllables.reduce((acc: Node[], s: string, index: number) => { const width = advanceWidthBetween( start, @@ -106,8 +188,13 @@ const getNodes = ( acc.push(knuthPlass.box(width, start, end, hyphenated)); if (syllables[index + 1] && hyphenated) { - // Add penalty node. Penalty nodes are used to represent hyphenation points. - acc.push(knuthPlass.penalty(hyphenWidth, hyphenPenalty, 1)); + // CJK boundaries are soft wrap opportunities (penalty 0, no hyphen width) + // unless keep-all is set, which suppresses CJK breaks + const isSoftWrap = allowCJKBreak && isEastAsianWide(s.slice(-1)); + const penaltyValue = isSoftWrap ? 0 : hyphenPenalty; + const penaltyWidth = isSoftWrap ? 0 : hyphenWidth; + + acc.push(knuthPlass.penalty(penaltyWidth, penaltyValue, 1)); } } @@ -161,7 +248,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..dd0ed97fb 100644 --- a/packages/textkit/src/layout/wrapWords.ts +++ b/packages/textkit/src/layout/wrapWords.ts @@ -1,9 +1,60 @@ +import LineBreaker from 'linebreak'; + import fromFragments from '../attributedString/fromFragments'; import { Engines } from '../engines'; import { AttributedString, LayoutOptions } from '../types'; const SOFT_HYPHEN = '\u00ad'; +/** + * Check if a character is prohibited at the start of a line using UAX #14. + * Uses a CJK character as a neutral test character since CJK allows breaking after. + */ +const isLineStartProhibited = (char: string): boolean => { + // Use a CJK character (あ) as neutral test character + // CJK characters allow breaking after them by default in UAX #14 + const testStr = 'あ' + char; + const breaker = new LineBreaker(testStr); + const bk = breaker.nextBreak(); + // If first break is at position 1, breaking before char is allowed (not prohibited) + // If first break is > 1 or null, char is line-start prohibited + return bk === null || bk.position > 1; +}; + +/** + * Apply line-breaking rules (UAX #14) to merge only line-start prohibited characters + * with the preceding syllable. This preserves hyphenation points while handling + * language-specific rules. + * + * @param syllables - Array of syllables + * @returns Array of syllables with line-breaking rules applied + */ +const applyLineBreakingRules = (syllables: string[]): string[] => { + if (syllables.length <= 1) return syllables; + + const result: string[] = []; + + for (let i = 0; i < syllables.length; i++) { + const syllable = syllables[i]; + + // Check if this syllable starts with a line-start prohibited character + // and should be merged with the previous syllable + if ( + result.length > 0 && + syllable.length > 0 && + !syllable[0].match(/\s/) && // Don't merge whitespace + isLineStartProhibited(syllable[0]) + ) { + // Merge with previous syllable + result[result.length - 1] += syllable; + } else { + result.push(syllable); + } + } + + return result; +}; + /** * Default word hyphenation engine used when no one provided. * Does not perform word hyphenation at all @@ -23,6 +74,65 @@ const removeSoftHyphens = (word: string) => { return word.replaceAll(SOFT_HYPHEN, ''); }; +/** + * Split text using UAX #14 line break algorithm. + * This properly handles CJK characters and other Unicode text. + * + * @param text - Text to split + * @returns Array of segments + */ +const splitByLineBreak = (text: string): string[] => { + const breaker = new LineBreaker(text); + const segments: string[] = []; + let lastBreak = 0; + let bk; + + while ((bk = breaker.nextBreak())) { + const segment = text.slice(lastBreak, bk.position); + if (segment) segments.push(segment); + lastBreak = bk.position; + } + + return segments; +}; + +/** + * Apply word-break rules to split parts into syllables. + * + * @param parts - Array of parts from hyphenation + * @param wordBreak - Word break mode + * @returns Array of syllables + */ +const applyWordBreak = ( + parts: string[], + wordBreak: LayoutOptions['wordBreak'], +): string[] => { + if (wordBreak === 'keep-all') { + return parts; + } + + const breakAll = wordBreak === 'break-all'; + const result: string[] = []; + + for (const part of parts) { + // Whitespace is kept as-is + if (part.trim() === '') { + result.push(part); + continue; + } + + if (breakAll) { + // break-all: split all characters + result.push(...[...part]); + } else { + // normal (default): use UAX #14 to split at valid break points + result.push(...splitByLineBreak(part)); + } + } + + return result; +}; + /** * Wrap words of attribute string * @@ -38,7 +148,7 @@ const wrapWords = ( * @returns Attributed string including syllables */ return (attributedString: AttributedString) => { - const syllables = []; + const rawSyllables: string[] = []; const fragments = []; const builtinHyphenate = engines.wordHyphenation?.() || defaultHyphenate; @@ -61,8 +171,11 @@ const wrapWords = ( const word = words[j]; const parts = hyphenate(word, builtinHyphenate).map(removeSoftHyphens); - syllables.push(...parts); - string += parts.join(''); + // Apply word-break rules to split parts further if needed + const splitParts = applyWordBreak(parts, options.wordBreak); + + rawSyllables.push(...splitParts); + string += splitParts.join(''); } // Modify run start and end based on removed soft hyphens. @@ -75,6 +188,8 @@ const wrapWords = ( offset += runOffset; } + const syllables = applyLineBreakingRules(rawSyllables); + const result: AttributedString = { ...fromFragments(fragments), syllables }; return result; diff --git a/packages/textkit/src/types.ts b/packages/textkit/src/types.ts index fa31bf753..433944185 100644 --- a/packages/textkit/src/types.ts +++ b/packages/textkit/src/types.ts @@ -140,6 +140,26 @@ export type LayoutOptions = { shrinkCharFactor?: JustificationFactor; expandWhitespaceFactor?: JustificationFactor; shrinkWhitespaceFactor?: JustificationFactor; + /** + * CSS-like hyphens property. + * - 'none': No hyphenation at line breaks + * - 'manual': Hyphenation only at soft hyphens (default behavior) + * - 'auto': Automatic hyphenation (same as 'manual' currently) + */ + hyphens?: 'none' | 'auto' | 'manual'; + /** + * CSS-like hyphenate-character property. + * Character to display when hyphenating. Default is '-'. + * Set to empty string to disable hyphen display. + */ + hyphenateCharacter?: string; + /** + * CSS-like word-break property. + * - 'normal': CJK text can break at any character (default) + * - 'break-all': All text can break at any character + * - 'keep-all': CJK text only breaks at word boundaries + */ + 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 76522cc45..db09ac2e1 100644 --- a/packages/textkit/tests/engines/linebreaker.test.ts +++ b/packages/textkit/tests/engines/linebreaker.test.ts @@ -7,285 +7,44 @@ 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({}); 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]); diff --git a/packages/textkit/tests/layout/wrapWords.test.ts b/packages/textkit/tests/layout/wrapWords.test.ts index 4c44fa94f..76bf8a9be 100644 --- a/packages/textkit/tests/layout/wrapWords.test.ts +++ b/packages/textkit/tests/layout/wrapWords.test.ts @@ -156,4 +156,121 @@ 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(['한', '글', '테', '스', '트']); + }); + }); }); From 1be9d3c7f37b3ab5d63aea927b246070157bce75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TATSUNO=20=E2=80=9CTaz=E2=80=9D=20Yasuhiro?= Date: Tue, 6 Oct 2026 13:37:50 +0900 Subject: [PATCH 2/3] test(renderer): add image snapshots for hyphens, hyphenateCharacter and wordBreak --- ...where-with-word-break-break-all-1-snap.png | Bin 0 -> 10965 bytes ...hyphens-and-hyphenate-character-1-snap.png | Bin 0 -> 16209 bytes ...gether-with-word-break-keep-all-1-snap.png | Bin 0 -> 9302 bytes packages/renderer/tests/text.test.jsx | 73 ++++++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-break-anywhere-with-word-break-break-all-1-snap.png create mode 100644 packages/renderer/tests/snapshots/text-test-jsx-tests-text-test-jsx-text-should-control-hyphens-and-hyphenate-character-1-snap.png create mode 100644 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 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 0000000000000000000000000000000000000000..8f59f1c3c56984c65d12d222573eb45aca29583a GIT binary patch literal 10965 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2e68e084bbf15d6d5261fbc130be8505ca385084 GIT binary patch literal 16209 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

# zjccmu>B)bGlauC6C9oZ-Gs`ExtSe%j-}{~*2kO06W9I@cK7JH1Z+fid))rH&o&NxQ zKI|?C>qd*SUcF*eR8m5DLRD4Owza`r&+QrEY+o29Qtdpcg@}!xG3KJ~meU<-8I-Jw4v(&$&5Tzn!^uxD-!#Q#Yft#uDzg zD3=Rq3@BbDAD@~IS5eVquu!Ppx$6sN84W1{sY&~@79hp>HE z{FUn;5K!Q|`CZRQ{io5qlifa$4WEd}v$!~XKr>H(w-^rr)b+yJ2-U7IoHjExjsi;I z)WxJq6fuQ`g%8hVQE3O*8Y!k|6Np$)s*0=~Y^V(+OG!z@rN4PuG@@o2q~%{D zM9&pX^Mt+v_FN4P5Bt)aFtunmv(JOyQ0|#D zT%;s*17tmfwChw~keV2ifkSeM9jBGDkCyES+}KsLJ1m}rpS{2 zV7~KA$2#ube_y_!Jz`7i|N7PQ?`6Qe9bE~op{=Xy;B2c3HT3AOpP%od${!vcetuei z%f`mWmq-6Sgr_gfAMELh9XNrags^=7{MdQmr6k2{tt*BTtuElPzeXJlFsTMC*}lUj zKn_xC*e$)`p}gAbBmz`fNci?22kI}!bAGxz9Qkwf`}dyMo17~rL9nuf{ea#I`A-^Z z=7V=*O#T#H#%L526mN=)UsqKTLm)O)>q_%@cb;&(iy!R4N`HE0rtSVxH#;Y1C**y) zCMLKvG&DY2%8v~k(9kfBP@e^uB7t4E*CagzIM{%$WtJ_bPOYH#=%oXZpl;L$VZ+G{ zx;Y%yOG@|lTME>W3#ecgl@Xyf<4}_5galyJV6w}`aHZ^OIy1a+5<)OuthY00i}JDuLXlps?_Xa!?SR&@2%- z-Nmr{1!>0!PeNgr?$cgfUD-H0Y7+*N5jETpb%gz}V<06QU3%R}Xg4Ma9M6`jVMrqJM#zs)6n=%%RY~Ijvg+ z(gFxB#;JA?o$+)S6fEa#fx>kJ;$w9+Gm5unK5x}aifN6h?>?@quA<BS+Y|$HIXheDKJ`gc3Mj$Xp)QUM+Rwv;F5+LzDpe>nQ}o;2GlKlL5F8r_2o% zf3oGumkLN(cl6ns;>V@%O;+v7We?n6itNIoP;n0tw18-btRpwTy`y9Ij;d{<0j#`> z+@cM2bYuZ}kKgHQLQ?Qj#MkxZsZ58+=>s&aM|R2kHihH*ow*n?JyB0P?h-aMG-%>z zVFKYp { 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(); + }); }); From 17be8c55e7cbab3fbdd70be3c048b2437ee23462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TATSUNO=20=E2=80=9CTaz=E2=80=9D=20Yasuhiro?= Date: Tue, 6 Oct 2026 21:53:18 +0900 Subject: [PATCH 3/3] fix(textkit): align wordBreak and hyphens with CSS Soft wrap opportunities and hyphenation opportunities were mixed in one list, so a hyphen showed at any break inside a word. URLs got "/-", hyphenated words got "--", and break-all added hyphens. Syllables now record the kind of each break. Only hyphenation breaks show the hyphenate character. - hyphens: none does not hyphenate, even at soft hyphens - hyphens: manual hyphenates only at soft hyphens - break-all applies no hyphenation and keeps punctuation rules - keep-all keeps letters together but still breaks after punctuation - breaks between text runs follow UAX #14 --- .changeset/wicked-rockets-check.md | 10 +- .../src/examples/soft-hyphens/index.tsx | 33 +-- packages/layout/tests/text/layoutText.test.ts | 32 ++- ...where-with-word-break-break-all-1-snap.png | Bin 10965 -> 10928 bytes ...hyphens-and-hyphenate-character-1-snap.png | Bin 16209 -> 15191 bytes ...gether-with-word-break-keep-all-1-snap.png | Bin 9302 -> 9348 bytes packages/renderer/tests/text.test.jsx | 6 +- .../textkit/src/engines/linebreaker/index.ts | 66 +---- packages/textkit/src/layout/wrapWords.ts | 262 ++++++++++-------- packages/textkit/src/types.ts | 24 +- .../textkit/tests/engines/linebreaker.test.ts | 35 +++ .../textkit/tests/layout/wrapWords.test.ts | 126 +++++++++ 12 files changed, 384 insertions(+), 210 deletions(-) diff --git a/.changeset/wicked-rockets-check.md b/.changeset/wicked-rockets-check.md index 7059d21e0..ce24bedaa 100644 --- a/.changeset/wicked-rockets-check.md +++ b/.changeset/wicked-rockets-check.md @@ -1,8 +1,8 @@ --- -"@react-pdf/vite-example": minor -"@react-pdf/stylesheet": minor -"@react-pdf/textkit": minor -"@react-pdf/layout": minor +'@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 hyphenateCharacterord +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 daea2f892..a853a9760 100644 --- a/apps/examples/src/examples/soft-hyphens/index.tsx +++ b/apps/examples/src/examples/soft-hyphens/index.tsx @@ -242,7 +242,7 @@ const SoftHyphens = () => ( - hyphens: none + hyphens: none (overflows) Potentieelbroeikasgasemissierapport @@ -260,20 +260,14 @@ const SoftHyphens = () => ( 2. CJK Text — wordBreak - Problem: "グレートブリテン" alone on a line due to - script-based run splitting + keep-all breaks only at spaces and punctuation - wordBreak: keep-all (problem) - - グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉 + wordBreak: keep-all + + グレートブリテン および 北アイルランド 連合王国、という言葉 @@ -282,7 +276,7 @@ const SoftHyphens = () => ( wordBreak: normal (CJK breaks anywhere) - グレートブリテンおよび北アイルランド連合王国という言葉は本当に長い言葉 + グレートブリテン および 北アイルランド 連合王国、という言葉 @@ -322,27 +316,20 @@ const SoftHyphens = () => ( 4. Long URLs - break-all allows URLs to wrap at any character + normal wraps after slashes, break-all wraps at any letter - wordBreak: normal (overflow) + wordBreak: normal https://example.com/very/very/loooong/path/to/resource - - wordBreak: break-all, hyphens: none - - + wordBreak: break-all + https://example.com/very/very/loooong/path/to/resource diff --git a/packages/layout/tests/text/layoutText.test.ts b/packages/layout/tests/text/layoutText.test.ts index f771fa62d..81424c2e2 100644 --- a/packages/layout/tests/text/layoutText.test.ts +++ b/packages/layout/tests/text/layoutText.test.ts @@ -104,9 +104,9 @@ describe('text layoutText', () => { ); }); - test('should not add hyphens when hyphens style is "none"', async () => { - const text = 'reallylongtext'; - const hyphens = ['really­', 'long', 'text']; + 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( @@ -116,9 +116,24 @@ describe('text layoutText', () => { ); 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'); + 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 () => { @@ -172,10 +187,7 @@ describe('text layoutText', () => { test('should break all characters with wordBreak: break-all', async () => { const text = 'Hello'; - const node = createTextNode(text, { - wordBreak: 'break-all', - hyphens: 'none', - }); + const node = createTextNode(text, { wordBreak: 'break-all' }); const lines = layoutText(node, 15, 100, fontStore); expect(lines.length).toBeGreaterThan(1); 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 index 8f59f1c3c56984c65d12d222573eb45aca29583a..2f52d029d4d6718f8866c978cff7b05d825b4655 100644 GIT binary patch literal 10928 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 W{(Syw9eCK8{e3{rEcE|2jja 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 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 index 2e68e084bbf15d6d5261fbc130be8505ca385084..73995cda809b1c19c722f4179d85c1e026057748 100644 GIT binary patch literal 15191 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 literal 16209 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 O2t_$HWQB}b=>Gy=-NAeS literal 9302 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

# zjccmu>B)bGlauC6C9oZ-Gs`ExtSe%j-}{~*2kO06W9I@cK7JH1Z+fid))rH&o&NxQ zKI|?C>qd*SUcF*eR8m5DLRD4Owza`r&+QrEY+o29Qtdpcg@}!xG3KJ~meU<-8I-Jw4v(&$&5Tzn!^uxD-!#Q#Yft#uDzg zD3=Rq3@BbDAD@~IS5eVquu!Ppx$6sN84W1{sY&~@79hp>HE z{FUn;5K!Q|`CZRQ{io5qlifa$4WEd}v$!~XKr>H(w-^rr)b+yJ2-U7IoHjExjsi;I z)WxJq6fuQ`g%8hVQE3O*8Y!k|6Np$)s*0=~Y^V(+OG!z@rN4PuG@@o2q~%{D zM9&pX^Mt+v_FN4P5Bt)aFtunmv(JOyQ0|#D zT%;s*17tmfwChw~keV2ifkSeM9jBGDkCyES+}KsLJ1m}rpS{2 zV7~KA$2#ube_y_!Jz`7i|N7PQ?`6Qe9bE~op{=Xy;B2c3HT3AOpP%od${!vcetuei z%f`mWmq-6Sgr_gfAMELh9XNrags^=7{MdQmr6k2{tt*BTtuElPzeXJlFsTMC*}lUj zKn_xC*e$)`p}gAbBmz`fNci?22kI}!bAGxz9Qkwf`}dyMo17~rL9nuf{ea#I`A-^Z z=7V=*O#T#H#%L526mN=)UsqKTLm)O)>q_%@cb;&(iy!R4N`HE0rtSVxH#;Y1C**y) zCMLKvG&DY2%8v~k(9kfBP@e^uB7t4E*CagzIM{%$WtJ_bPOYH#=%oXZpl;L$VZ+G{ zx;Y%yOG@|lTME>W3#ecgl@Xyf<4}_5galyJV6w}`aHZ^OIy1a+5<)OuthY00i}JDuLXlps?_Xa!?SR&@2%- z-Nmr{1!>0!PeNgr?$cgfUD-H0Y7+*N5jETpb%gz}V<06QU3%R}Xg4Ma9M6`jVMrqJM#zs)6n=%%RY~Ijvg+ z(gFxB#;JA?o$+)S6fEa#fx>kJ;$w9+Gm5unK5x}aifN6h?>?@quA<BS+Y|$HIXheDKJ`gc3Mj$Xp)QUM+Rwv;F5+LzDpe>nQ}o;2GlKlL5F8r_2o% zf3oGumkLN(cl6ns;>V@%O;+v7We?n6itNIoP;n0tw18-btRpwTy`y9Ij;d{<0j#`> z+@cM2bYuZ}kKgHQLQ?Qj#MkxZsZ58+=>s&aM|R2kHihH*ow*n?JyB0P?h-aMG-%>z zVFKYp { }); test('should keep CJK words together with wordBreak keep-all', async () => { - const text = 'グレートブリテンおよび北アイルランド連合王国'; + const text = 'グレートブリテン および 北アイルランド 連合王国'; const style = { fontFamily: 'NotoSansJP', @@ -256,9 +256,7 @@ describe('text', () => { {url} - - {url} - + {url} , ); diff --git a/packages/textkit/src/engines/linebreaker/index.ts b/packages/textkit/src/engines/linebreaker/index.ts index 4062f28c4..61424b9d1 100644 --- a/packages/textkit/src/engines/linebreaker/index.ts +++ b/packages/textkit/src/engines/linebreaker/index.ts @@ -1,5 +1,3 @@ -import unicode from 'unicode-properties'; - import bestFit from './bestFit'; import knuthPlass from './knuthPlass'; import slice from '../../attributedString/slice'; @@ -8,17 +6,6 @@ import advanceWidthBetween from '../../attributedString/advanceWidthBetween'; import { AttributedString, Attributes, LayoutOptions } from '../../types'; import { Node } from './types'; -/** - * Check if a character is East Asian Wide or Fullwidth. - * These characters don't need hyphens when wrapping. - */ -const isEastAsianWide = (char: string): boolean => { - const codePoint = char.codePointAt(0); - if (codePoint === undefined) return false; - const eaw = unicode.getEastAsianWidth(codePoint); - return eaw === 'W' || eaw === 'F'; -}; - const HYPHEN = 0x002d; const TOLERANCE_STEPS = 5; const TOLERANCE_LIMIT = 50; @@ -62,30 +49,6 @@ const getHyphenCodePoints = (options: LayoutOptions): number[] | null => { return [HYPHEN]; }; -/** - * Check if a hyphen should be inserted at the end of a line. - * CJK characters don't need hyphens when wrapping. - * - * @param line - Line attributed string - * @param hyphenCodePoints - Hyphen code points to use - * @returns True if hyphen should be inserted - */ -const shouldInsertHyphen = ( - line: AttributedString, - hyphenCodePoints: number[] | null, -): boolean => { - if (hyphenCodePoints === null) return false; - - // Get the last character of the line - const lastChar = line.string.slice(-1); - if (!lastChar) return false; - - // Don't insert hyphen after East Asian Wide characters (CJK, etc.) - if (isEastAsianWide(lastChar)) return false; - - return true; -}; - /** * Slice attributed string to many lines * @@ -122,9 +85,9 @@ const breakLines = ( line = slice(start, end, attributedString); - // Insert hyphen character(s) if configured and appropriate - if (shouldInsertHyphen(line, hyphenCodePoints)) { - for (const codePoint of hyphenCodePoints!) { + // 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); } } @@ -160,13 +123,11 @@ const getNodes = ( const hyphenWidth = getHyphenCodePoints(options) === null ? 0 : 5; - const { syllables } = attributedString; + const { syllables, syllableBreaks } = attributedString; const hyphenPenalty = options.hyphenationPenalty || (align === 'justify' ? 100 : 600); - const allowCJKBreak = options.wordBreak !== 'keep-all'; - const result = syllables.reduce((acc: Node[], s: string, index: number) => { const width = advanceWidthBetween( start, @@ -182,20 +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) { - // CJK boundaries are soft wrap opportunities (penalty 0, no hyphen width) - // unless keep-all is set, which suppresses CJK breaks - const isSoftWrap = allowCJKBreak && isEastAsianWide(s.slice(-1)); - const penaltyValue = isSoftWrap ? 0 : hyphenPenalty; - const penaltyWidth = isSoftWrap ? 0 : hyphenWidth; - - acc.push(knuthPlass.penalty(penaltyWidth, penaltyValue, 1)); + // 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)); } } diff --git a/packages/textkit/src/layout/wrapWords.ts b/packages/textkit/src/layout/wrapWords.ts index dd0ed97fb..494dfa405 100644 --- a/packages/textkit/src/layout/wrapWords.ts +++ b/packages/textkit/src/layout/wrapWords.ts @@ -2,58 +2,14 @@ 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'; -/** - * Check if a character is prohibited at the start of a line using UAX #14. - * Uses a CJK character as a neutral test character since CJK allows breaking after. - */ -const isLineStartProhibited = (char: string): boolean => { - // Use a CJK character (あ) as neutral test character - // CJK characters allow breaking after them by default in UAX #14 - const testStr = 'あ' + char; - const breaker = new LineBreaker(testStr); - const bk = breaker.nextBreak(); - // If first break is at position 1, breaking before char is allowed (not prohibited) - // If first break is > 1 or null, char is line-start prohibited - return bk === null || bk.position > 1; -}; - -/** - * Apply line-breaking rules (UAX #14) to merge only line-start prohibited characters - * with the preceding syllable. This preserves hyphenation points while handling - * language-specific rules. - * - * @param syllables - Array of syllables - * @returns Array of syllables with line-breaking rules applied - */ -const applyLineBreakingRules = (syllables: string[]): string[] => { - if (syllables.length <= 1) return syllables; - - const result: string[] = []; - - for (let i = 0; i < syllables.length; i++) { - const syllable = syllables[i]; - - // Check if this syllable starts with a line-start prohibited character - // and should be merged with the previous syllable - if ( - result.length > 0 && - syllable.length > 0 && - !syllable[0].match(/\s/) && // Don't merge whitespace - isLineStartProhibited(syllable[0]) - ) { - // Merge with previous syllable - result[result.length - 1] += syllable; - } else { - result.push(syllable); - } - } - - return result; -}; +// 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. @@ -74,65 +30,38 @@ const removeSoftHyphens = (word: string) => { return word.replaceAll(SOFT_HYPHEN, ''); }; -/** - * Split text using UAX #14 line break algorithm. - * This properly handles CJK characters and other Unicode text. - * - * @param text - Text to split - * @returns Array of segments - */ -const splitByLineBreak = (text: string): string[] => { - const breaker = new LineBreaker(text); - const segments: string[] = []; - let lastBreak = 0; - let bk; - - while ((bk = breaker.nextBreak())) { - const segment = text.slice(lastBreak, bk.position); - if (segment) segments.push(segment); - lastBreak = bk.position; - } +const firstChar = (text: string) => /^[\s\S]/u.exec(text)![0]; - return segments; -}; +const lastChar = (text: string) => /[\s\S]$/u.exec(text)![0]; + +const pairBreaks = new Map(); /** - * Apply word-break rules to split parts into syllables. + * Whether UAX #14 allows a line break between two characters * - * @param parts - Array of parts from hyphenation - * @param wordBreak - Word break mode - * @returns Array of syllables + * @param before - Character before the break + * @param after - Character after the break + * @returns Whether a break is allowed */ -const applyWordBreak = ( - parts: string[], - wordBreak: LayoutOptions['wordBreak'], -): string[] => { - if (wordBreak === 'keep-all') { - return parts; - } - - const breakAll = wordBreak === 'break-all'; - const result: string[] = []; - - for (const part of parts) { - // Whitespace is kept as-is - if (part.trim() === '') { - result.push(part); - continue; - } - - if (breakAll) { - // break-all: split all characters - result.push(...[...part]); - } else { - // normal (default): use UAX #14 to split at valid break points - result.push(...splitByLineBreak(part)); - } +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 * @@ -143,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 rawSyllables: string[] = []; + 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) { @@ -168,14 +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; - // Apply word-break rules to split parts further if needed - const splitParts = applyWordBreak(parts, options.wordBreak); + const previous = syllables[syllables.length - 1]; - rawSyllables.push(...splitParts); - string += splitParts.join(''); + 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; + } + + push(word.slice(start), breakBefore); } // Modify run start and end based on removed soft hyphens. @@ -188,9 +232,11 @@ const wrapWords = ( offset += runOffset; } - const syllables = applyLineBreakingRules(rawSyllables); - - 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 9086e7b3f..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[]; @@ -167,22 +173,24 @@ export type LayoutOptions = { shrinkWhitespaceFactor?: JustificationFactor; /** * CSS-like hyphens property. - * - 'none': No hyphenation at line breaks - * - 'manual': Hyphenation only at soft hyphens (default behavior) - * - 'auto': Automatic hyphenation (same as 'manual' currently) + * - '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. - * Character to display when hyphenating. Default is '-'. - * Set to empty string to disable hyphen display. + * String shown at a hyphenation break. Default is '-'. + * An empty string hyphenates without a visible character. */ hyphenateCharacter?: string; /** * CSS-like word-break property. - * - 'normal': CJK text can break at any character (default) - * - 'break-all': All text can break at any character - * - 'keep-all': CJK text only breaks at word boundaries + * - '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'; }; diff --git a/packages/textkit/tests/engines/linebreaker.test.ts b/packages/textkit/tests/engines/linebreaker.test.ts index 2af8544ac..4a8d45642 100644 --- a/packages/textkit/tests/engines/linebreaker.test.ts +++ b/packages/textkit/tests/engines/linebreaker.test.ts @@ -86,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 76bf8a9be..66e7d821e 100644 --- a/packages/textkit/tests/layout/wrapWords.test.ts +++ b/packages/textkit/tests/layout/wrapWords.test.ts @@ -273,4 +273,130 @@ describe('wrapWords', () => { 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(['句読点の後ろ']); + }); + }); });