From e0bf714a722f05c920257452d9270f7271f74a91 Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 04:15:31 -0300 Subject: [PATCH 01/14] feat(stylesheet): add writingMode style property and built-in font types - Add WritingMode type ('horizontal-tb' | 'vertical-rl' | 'vertical-lr') - Add BuiltInFontFamily type for IntelliSense autocomplete of built-in fonts - Add FontFamily type using (string & {}) pattern for custom + built-in fonts - Add writingMode to TextStyle and textkit Attributes types - Add writingMode handler in stylesheet text resolver - Add tests for writingMode style resolution --- packages/stylesheet/src/resolve/text.ts | 1 + packages/stylesheet/src/types.ts | 29 ++++++++++++++++++++++++- packages/stylesheet/tests/text.test.ts | 18 +++++++++++++++ packages/textkit/src/types.ts | 1 + 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/stylesheet/src/resolve/text.ts b/packages/stylesheet/src/resolve/text.ts index 48a0abeff..b609ca618 100644 --- a/packages/stylesheet/src/resolve/text.ts +++ b/packages/stylesheet/src/resolve/text.ts @@ -94,6 +94,7 @@ const handlers = { textOverflow: processNoopValue<'textOverflow'>, textTransform: processNoopValue<'textTransform'>, verticalAlign: processNoopValue<'verticalAlign'>, + writingMode: processNoopValue<'writingMode'>, }; export default handlers; diff --git a/packages/stylesheet/src/types.ts b/packages/stylesheet/src/types.ts index e7bd5adc6..82ef0776e 100644 --- a/packages/stylesheet/src/types.ts +++ b/packages/stylesheet/src/types.ts @@ -321,10 +321,36 @@ export type TextTransform = export type VerticalAlign = 'sub' | 'super'; +export type WritingMode = 'horizontal-tb' | 'vertical-rl' | 'vertical-lr'; + +/** + * Built-in font family names available without explicit registration. + * CJK fonts are auto-registered and lazily loaded from Google Fonts. + */ +export type BuiltInFontFamily = + | 'Courier' + | 'Courier-Bold' + | 'Courier-Oblique' + | 'Courier-BoldOblique' + | 'Helvetica' + | 'Helvetica-Bold' + | 'Helvetica-Oblique' + | 'Helvetica-BoldOblique' + | 'Times-Roman' + | 'Times-Bold' + | 'Times-Italic' + | 'Times-BoldItalic' + | 'Noto Sans SC' + | 'Noto Sans TC' + | 'Noto Sans JP' + | 'Noto Sans KR'; + +export type FontFamily = BuiltInFontFamily | (string & {}); + export type TextStyle = { direction?: 'ltr' | 'rtl'; fontSize?: number | string; - fontFamily?: string | string[]; + fontFamily?: FontFamily | FontFamily[]; fontStyle?: FontStyle; fontWeight?: FontWeight; letterSpacing?: number | string; @@ -338,6 +364,7 @@ export type TextStyle = { textOverflow?: 'ellipsis'; textTransform?: TextTransform; verticalAlign?: VerticalAlign; + writingMode?: WritingMode; }; export type TextExpandedStyle = TextStyle; diff --git a/packages/stylesheet/tests/text.test.ts b/packages/stylesheet/tests/text.test.ts index 5998d8472..de0dabb10 100644 --- a/packages/stylesheet/tests/text.test.ts +++ b/packages/stylesheet/tests/text.test.ts @@ -408,4 +408,22 @@ describe('resolve stylesheet text', () => { expect(styles).toEqual({ verticalAlign: 'super' }); }); + + test('should resolve writing mode horizontal-tb', () => { + const styles = resolveStyle({ writingMode: 'horizontal-tb' }); + + expect(styles).toEqual({ writingMode: 'horizontal-tb' }); + }); + + test('should resolve writing mode vertical-rl', () => { + const styles = resolveStyle({ writingMode: 'vertical-rl' }); + + expect(styles).toEqual({ writingMode: 'vertical-rl' }); + }); + + test('should resolve writing mode vertical-lr', () => { + const styles = resolveStyle({ writingMode: 'vertical-lr' }); + + expect(styles).toEqual({ writingMode: 'vertical-lr' }); + }); }); diff --git a/packages/textkit/src/types.ts b/packages/textkit/src/types.ts index fa31bf753..7c991cc1c 100644 --- a/packages/textkit/src/types.ts +++ b/packages/textkit/src/types.ts @@ -81,6 +81,7 @@ export type Attributes = { underlineStyle?: string; verticalAlign?: string; wordSpacing?: number; + writingMode?: 'horizontal-tb' | 'vertical-rl' | 'vertical-lr'; yOffset?: number; }; From eb0f88e7e79453710283b46a939f63c0f3670e76 Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 04:15:59 -0300 Subject: [PATCH 02/14] feat(font): add built-in CJK font families with auto-registration - Add CJK constant with CHINESE_SIMPLIFIED, CHINESE_TRADITIONAL, JAPANESE, KOREAN - Add CJK_FONT_FAMILIES as deprecated alias for backward compatibility - Add CJK_FONT_NAMES array of all CJK font family names - Register CJK fonts (Noto Sans SC/TC/JP/KR) in FontStore constructor - Fonts are lazily loaded from Google Fonts CDN with normal and bold weights --- packages/font/src/index.ts | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/font/src/index.ts b/packages/font/src/index.ts index 9b364635a..29db37f5d 100644 --- a/packages/font/src/index.ts +++ b/packages/font/src/index.ts @@ -8,6 +8,57 @@ import { SingleLoad, } from './types'; +/** + * Built-in CJK font family names. + * These are registered automatically and fetched lazily from Google Fonts. + * + * @example + * import { CJK } from '@react-pdf/font'; + * // or: import { CJK } from '@react-pdf/renderer'; + * 한국어 + */ +export const CJK = { + /** Simplified Chinese (Mainland China) */ + CHINESE_SIMPLIFIED: 'Noto Sans SC', + /** Traditional Chinese (Taiwan, Macau) */ + CHINESE_TRADITIONAL: 'Noto Sans TC', + /** Japanese (Hiragana, Katakana, Kanji) */ + JAPANESE: 'Noto Sans JP', + /** Korean (Hangul, Hanja) */ + KOREAN: 'Noto Sans KR', +} as const; + +/** @deprecated Use `CJK` instead */ +export const CJK_FONT_FAMILIES = CJK; + +/** All CJK font family names as an array */ +export const CJK_FONT_NAMES: string[] = Object.values(CJK); + +// Google Fonts CSS API base – resolved to static .ttf URLs at registration time. +// Using the CSS2 API with TTF format ensures stable, versioned font file URLs. +const CJK_FONT_SOURCES: Record = { + [CJK.CHINESE_SIMPLIFIED]: { + normal: + 'https://fonts.gstatic.com/s/notosanssc/v40/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYw.ttf', + bold: 'https://fonts.gstatic.com/s/notosanssc/v40/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaGzjCnYw.ttf', + }, + [CJK.CHINESE_TRADITIONAL]: { + normal: + 'https://fonts.gstatic.com/s/notosanstc/v39/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_Co.ttf', + bold: 'https://fonts.gstatic.com/s/notosanstc/v39/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz70e1_Co.ttf', + }, + [CJK.JAPANESE]: { + normal: + 'https://fonts.gstatic.com/s/notosansjp/v56/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj75s.ttf', + bold: 'https://fonts.gstatic.com/s/notosansjp/v56/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFPYk75s.ttf', + }, + [CJK.KOREAN]: { + normal: + 'https://fonts.gstatic.com/s/notosanskr/v39/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLQ.ttf', + bold: 'https://fonts.gstatic.com/s/notosanskr/v39/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzg01eLQ.ttf', + }, +}; + class FontStore { fontFamilies: Record = {}; @@ -91,6 +142,17 @@ class FontStore { src: 'Times-BoldItalic', }); + // Register built-in CJK fonts (lazy-loaded from Google Fonts CDN) + for (const [family, urls] of Object.entries(CJK_FONT_SOURCES)) { + this.register({ + family, + fonts: [ + { src: urls.normal, fontStyle: 'normal', fontWeight: 400 }, + { src: urls.bold, fontStyle: 'normal', fontWeight: 700 }, + ], + }); + } + // Load default fonts this.load({ From 9fe347fb9f5526d8551668013df209a0209594d5 Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 04:16:16 -0300 Subject: [PATCH 03/14] feat(layout): add vertical text layout with CJK auto-detection - Add vertical writing mode support in text layout engine - Swap container width/height for vertical text (characters flow top-to-bottom) - Use 999999 instead of Infinity to avoid NaN from IEEE 754 arithmetic - Transform horizontal line boxes back to vertical column boxes - Compute column height using per-glyph Math.max(xAdvance, fontSize) - Update linesHeight/linesWidth for vertical mode dimension semantics - Update measureText to handle vertical text measurement correctly - Add CJK codepoint detection utilities (containsCJK, getCJKFallbackFontFamilies) - Add CJK fonts as fallbacks in attributed string font resolution - Auto-detect and preload CJK fonts when CJK characters found in text - Add tests for vertical layout, linesHeight, and linesWidth --- packages/layout/src/steps/resolveAssets.ts | 17 +++ packages/layout/src/text/cjk.ts | 38 +++++++ .../layout/src/text/getAttributedString.ts | 23 +++- packages/layout/src/text/layoutText.ts | 104 +++++++++++++++++- packages/layout/src/text/linesHeight.ts | 11 ++ packages/layout/src/text/linesWidth.ts | 10 ++ packages/layout/src/text/measureText.ts | 29 +++++ packages/layout/tests/text/layoutText.test.ts | 43 ++++++++ .../layout/tests/text/linesHeight.test.ts | 47 ++++++++ packages/layout/tests/text/linesWidth.test.ts | 46 ++++++++ 10 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 packages/layout/src/text/cjk.ts create mode 100644 packages/layout/tests/text/linesHeight.test.ts create mode 100644 packages/layout/tests/text/linesWidth.test.ts diff --git a/packages/layout/src/steps/resolveAssets.ts b/packages/layout/src/steps/resolveAssets.ts index f50e922e3..12505538c 100644 --- a/packages/layout/src/steps/resolveAssets.ts +++ b/packages/layout/src/steps/resolveAssets.ts @@ -4,6 +4,7 @@ import { castArray } from '@react-pdf/fns'; import fetchEmojis from '../text/emoji'; import fetchImage from '../image/fetchImage'; +import { containsCJK, getCJKFallbackFontFamilies } from '../text/cjk'; import { SafeDocumentNode, SafeImageNode, @@ -33,6 +34,7 @@ const fetchAssets = ( const promises: Promise[] = []; const listToExplore = [node]; const emojiSource = fontStore ? fontStore.getEmojiSource() : null; + let cjkDetected = false; while (listToExplore.length > 0) { const n = listToExplore.shift(); @@ -57,10 +59,12 @@ const fetchAssets = ( if (typeof n === 'string') { promises.push(...fetchEmojis(n, emojiSource)); + if (!cjkDetected && containsCJK(n)) cjkDetected = true; } if ('value' in n && typeof n.value === 'string') { promises.push(...fetchEmojis(n.value, emojiSource)); + if (!cjkDetected && containsCJK(n.value)) cjkDetected = true; } if (n.children) { @@ -70,6 +74,19 @@ const fetchAssets = ( } } + // Auto-load CJK fonts when CJK characters are detected in text content + if (cjkDetected && fontStore) { + const cjkFamilies = getCJKFallbackFontFamilies(); + for (const family of cjkFamilies) { + promises.push( + fontStore.load({ fontFamily: family, fontStyle: 'normal', fontWeight: 400 }), + ); + promises.push( + fontStore.load({ fontFamily: family, fontStyle: 'normal', fontWeight: 700 }), + ); + } + } + return promises; }; diff --git a/packages/layout/src/text/cjk.ts b/packages/layout/src/text/cjk.ts new file mode 100644 index 000000000..08fdd1377 --- /dev/null +++ b/packages/layout/src/text/cjk.ts @@ -0,0 +1,38 @@ +import { CJK_FONT_NAMES } from '@react-pdf/font'; + +/** + * Check if a Unicode codepoint is a CJK character (Chinese, Japanese, or Korean). + */ +const isCJKCodePoint = (cp: number): boolean => + (cp >= 0x2e80 && cp <= 0x2eff) || // CJK Radicals Supplement + (cp >= 0x3000 && cp <= 0x303f) || // CJK Symbols and Punctuation + (cp >= 0x3040 && cp <= 0x309f) || // Hiragana + (cp >= 0x30a0 && cp <= 0x30ff) || // Katakana + (cp >= 0x3100 && cp <= 0x312f) || // Bopomofo + (cp >= 0x3130 && cp <= 0x318f) || // Hangul Compatibility Jamo + (cp >= 0x31f0 && cp <= 0x31ff) || // Katakana Phonetic Extensions + (cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A + (cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs + (cp >= 0xac00 && cp <= 0xd7af) || // Hangul Syllables + (cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs + (cp >= 0x1100 && cp <= 0x11ff) || // Hangul Jamo + (cp >= 0x20000 && cp <= 0x2a6df) || // CJK Extension B + (cp >= 0xff00 && cp <= 0xffef); // Halfwidth and Fullwidth Forms + +/** + * Check if a string contains any CJK characters. + */ +export const containsCJK = (text: string): boolean => { + for (let i = 0; i < text.length; i += 1) { + const cp = text.codePointAt(i); + if (cp === undefined) continue; + if (isCJKCodePoint(cp)) return true; + if (cp > 0xffff) i += 1; // Skip surrogate pair + } + return false; +}; + +/** + * Get the list of CJK font family names to use as fallbacks. + */ +export const getCJKFallbackFontFamilies = (): string[] => CJK_FONT_NAMES; diff --git a/packages/layout/src/text/getAttributedString.ts b/packages/layout/src/text/getAttributedString.ts index abab6c3c9..8fc8e14b4 100644 --- a/packages/layout/src/text/getAttributedString.ts +++ b/packages/layout/src/text/getAttributedString.ts @@ -5,6 +5,7 @@ import FontStore from '@react-pdf/font'; import { embedEmojis } from './emoji'; import ignoreChars from './ignoreChars'; import transformText from './transformText'; +import { getCJKFallbackFontFamilies } from './cjk'; import { SafeNode, SafeTextNode, @@ -60,19 +61,34 @@ const getFragments = ( textIndent, opacity, verticalAlign, + writingMode, } = instance.style; const fontFamilies = typeof fontFamily === 'string' ? [fontFamily] : [...(fontFamily || [])]; + // Add CJK fonts as fallbacks (before Helvetica) so fontSubstitution can + // pick them for CJK codepoints. Only fonts that are already loaded will + // produce non-null data; unloaded ones are filtered out below. + const cjkFallbacks = getCJKFallbackFontFamilies(); + for (const cjkFont of cjkFallbacks) { + if (!fontFamilies.includes(cjkFont)) { + fontFamilies.push(cjkFont); + } + } + // Fallback font fontFamilies.push('Helvetica'); const font = fontFamilies.map((fontFamilyName) => { const opts = { fontFamily: fontFamilyName, fontWeight, fontStyle }; - const obj = fontStore.getFont(opts); - return obj?.data; - }); + try { + const obj = fontStore.getFont(opts); + return obj?.data; + } catch { + return undefined; + } + }).filter(Boolean); // Don't pass main background color to textkit. Will be rendered by the render package instead const backgroundColor = level === 0 ? null : instance.style.backgroundColor; @@ -90,6 +106,7 @@ const getFragments = ( characterSpacing: letterSpacing, strikeStyle: textDecorationStyle, underlineStyle: textDecorationStyle, + writingMode, underline: textDecoration === 'underline' || textDecoration === 'underline line-through' || diff --git a/packages/layout/src/text/layoutText.ts b/packages/layout/src/text/layoutText.ts index e87235b67..35ca0955e 100644 --- a/packages/layout/src/text/layoutText.ts +++ b/packages/layout/src/text/layoutText.ts @@ -28,6 +28,14 @@ const getMaxLines = (node) => node.style?.maxLines; const getTextOverflow = (node) => node.style?.textOverflow; +const getWritingMode = (node) => + node.style?.writingMode || 'horizontal-tb'; + +const isVerticalWritingMode = (node) => { + const wm = getWritingMode(node); + return wm === 'vertical-rl' || wm === 'vertical-lr'; +}; + /** * Get layout container for specific text node * @@ -40,6 +48,22 @@ const getContainer = (width, height, node) => { const maxLines = getMaxLines(node); const textOverflow = getTextOverflow(node); + // For vertical writing modes, swap width and height so the linebreaker + // treats the available height as the "line length" (characters flow top-to-bottom) + // and the width as the available space for columns. + // Use large finite numbers instead of Infinity to avoid NaN from IEEE 754 + // arithmetic (Infinity * 0 = NaN) inside textkit's justifyLine. + if (isVerticalWritingMode(node)) { + return { + x: 0, + y: 0, + width: height > 0 ? height : 999999, + maxLines, + height: width > 0 ? width : 999999, + truncateMode: textOverflow, + }; + } + return { x: 0, y: 0, @@ -65,6 +89,77 @@ const getLayoutOptions = (fontStore, node) => ({ null, }); +/** + * Transform lines from horizontal layout space back to vertical layout space. + * In vertical mode, textkit lays out as if horizontal, so we need to reinterpret: + * - Each "line" becomes a vertical column + * - line.box.y becomes the x position (columns laid out horizontally) + * - line.box.x becomes the y position (characters within a column) + * - line.box.width/height are swapped + * + * @param lines - Lines from textkit (in horizontal coordinate space) + * @param containerWidth - Original container width (before swap) + * @param writingMode - Writing mode + * @returns Lines with boxes transformed back to vertical coordinate space + */ +/** + * Compute the actual vertical column height for a line. + * In vertical CJK text each glyph occupies one em-height (fontSize). + * We take the larger of the horizontal advance and fontSize per glyph + * to ensure the column height matches what the renderer will produce. + */ +const verticalColumnHeight = (line) => { + if (!line.runs) return line.xAdvance || 0; + + let height = 0; + for (const run of line.runs) { + const fontSize = run.attributes?.fontSize || 12; + const numGlyphs = run.glyphs?.length || 0; + + if (run.positions) { + for (const pos of run.positions) { + height += Math.max(pos.xAdvance || 0, fontSize); + } + } else { + height += numGlyphs * fontSize; + } + } + + return height; +}; + +const transformVerticalLines = (lines, containerWidth, writingMode) => { + if (!lines || lines.length === 0) return lines; + + // Calculate total columns width (sum of line heights in horizontal space = column widths in vertical) + let columnX = 0; + const columnWidth = lines.length > 0 && lines[0].box ? lines[0].box.height : 0; + + return lines.map((line, i) => { + if (!line.box) return line; + + // In vertical-rl, columns go from right to left + // In vertical-lr, columns go from left to right + let x; + if (writingMode === 'vertical-rl') { + x = containerWidth - columnX - columnWidth; + } else { + x = columnX; + } + + const newBox = { + x, + y: line.box.x, // horizontal x position becomes vertical y position + width: line.box.height, // line height becomes column width + height: verticalColumnHeight(line), // fontSize-based height to match rendering + }; + + columnX += line.box.height; + + return Object.assign({}, line, { box: newBox }); + }); +}; + /** * Get text lines for given node * @@ -85,7 +180,14 @@ const layoutText = ( const options = getLayoutOptions(fontStore, node); const lines = engine(attributedString, container, options); - return lines.reduce((acc, line) => [...acc, ...line], []); + const flatLines = lines.reduce((acc, line) => [...acc, ...line], []); + + if (isVerticalWritingMode(node)) { + const writingMode = getWritingMode(node); + return transformVerticalLines(flatLines, width, writingMode); + } + + return flatLines; }; export default layoutText; diff --git a/packages/layout/src/text/linesHeight.ts b/packages/layout/src/text/linesHeight.ts index 8313c324f..14f4b378f 100644 --- a/packages/layout/src/text/linesHeight.ts +++ b/packages/layout/src/text/linesHeight.ts @@ -1,5 +1,10 @@ import { SafeTextNode } from '../types'; +const isVerticalWritingMode = (node: SafeTextNode) => { + const wm = node.style?.writingMode; + return wm === 'vertical-rl' || wm === 'vertical-lr'; +}; + /** * Get lines height (if any) * @@ -8,6 +13,12 @@ import { SafeTextNode } from '../types'; */ const linesHeight = (node: SafeTextNode) => { if (!node.lines) return -1; + + if (isVerticalWritingMode(node)) { + // For vertical text, height is the maximum column height + return Math.max(0, ...node.lines.map((line) => line.box?.height || 0)); + } + return node.lines.reduce((acc, line) => acc + line.box.height, 0); }; diff --git a/packages/layout/src/text/linesWidth.ts b/packages/layout/src/text/linesWidth.ts index d595d60dc..f736acd0a 100644 --- a/packages/layout/src/text/linesWidth.ts +++ b/packages/layout/src/text/linesWidth.ts @@ -1,5 +1,10 @@ import { SafeTextNode } from '../types'; +const isVerticalWritingMode = (node: SafeTextNode) => { + const wm = node.style?.writingMode; + return wm === 'vertical-rl' || wm === 'vertical-lr'; +}; + /** * Get lines width (if any) * @@ -9,6 +14,11 @@ import { SafeTextNode } from '../types'; const linesWidth = (node: SafeTextNode) => { if (!node.lines) return 0; + if (isVerticalWritingMode(node)) { + // For vertical text, total width is sum of all column widths + return node.lines.reduce((acc, line) => acc + (line.box?.width || 0), 0); + } + return Math.max(0, ...node.lines.map((line) => line.xAdvance)); }; diff --git a/packages/layout/src/text/measureText.ts b/packages/layout/src/text/measureText.ts index ec808c9cf..686ca635e 100644 --- a/packages/layout/src/text/measureText.ts +++ b/packages/layout/src/text/measureText.ts @@ -8,6 +8,11 @@ import { SafePageNode, SafeTextNode } from '../types'; const ALIGNMENT_FACTORS = { center: 0.5, right: 1 }; +const isVerticalWritingMode = (node: SafeTextNode) => { + const wm = node.style?.writingMode; + return wm === 'vertical-rl' || wm === 'vertical-lr'; +}; + /** * Yoga text measure function * @@ -23,6 +28,30 @@ const measureText = fontStore: FontStore, ): Yoga.MeasureFunction => (width, widthMode, height) => { + if (isVerticalWritingMode(node)) { + // For vertical text, after transformVerticalLines: + // - linesWidth returns the total width of all columns (sum of column widths, via max xAdvance of line boxes) + // - linesHeight returns the column height (the max height among columns) + if (widthMode === Yoga.MeasureMode.Exactly) { + if (!node.lines) + node.lines = layoutText(node, width, height, fontStore); + + return { height: linesHeight(node), width }; + } + + if (widthMode === Yoga.MeasureMode.AtMost) { + if (!node.lines) + node.lines = layoutText(node, width, height, fontStore); + + return { + height: linesHeight(node), + width: Math.min(width, linesWidth(node)), + }; + } + + return {}; + } + if (widthMode === Yoga.MeasureMode.Exactly) { if (!node.lines) node.lines = layoutText(node, width, height, fontStore); diff --git a/packages/layout/tests/text/layoutText.test.ts b/packages/layout/tests/text/layoutText.test.ts index 34441e008..1ad04a812 100644 --- a/packages/layout/tests/text/layoutText.test.ts +++ b/packages/layout/tests/text/layoutText.test.ts @@ -103,4 +103,47 @@ describe('text layoutText', () => { expect.any(Function), ); }); + + describe('vertical writing mode', () => { + test('Should render vertical-rl text with transformed line boxes', async () => { + const node = createTextNode('Hello', { writingMode: 'vertical-rl' }); + const lines = layoutText(node, 200, 500, fontStore); + + expect(lines.length).toBeGreaterThan(0); + + // In vertical-rl, the first column should be on the right side + if (lines.length > 0 && lines[0].box) { + // Box should have width (column width) and height (column height) + expect(lines[0].box.width).toBeGreaterThan(0); + expect(lines[0].box.height).toBeGreaterThan(0); + } + }); + + test('Should render vertical-lr text with transformed line boxes', async () => { + const node = createTextNode('Hello', { writingMode: 'vertical-lr' }); + const lines = layoutText(node, 200, 500, fontStore); + + expect(lines.length).toBeGreaterThan(0); + + // In vertical-lr, the first column should be on the left side + if (lines.length > 0 && lines[0].box) { + expect(lines[0].box.x).toBe(0); + } + }); + + test('Should handle empty text in vertical mode', async () => { + const node = createTextNode('', { writingMode: 'vertical-rl' }); + const lines = layoutText(node, 200, 500, fontStore); + + expect(lines).toHaveLength(0); + }); + + test('Should swap container dimensions for vertical text', async () => { + const node = createTextNode('ABC', { writingMode: 'vertical-rl' }); + // Width=100, Height=500: in vertical mode, characters flow within height=500 (as "line length") + const lines = layoutText(node, 100, 500, fontStore); + + expect(lines.length).toBeGreaterThan(0); + }); + }); }); diff --git a/packages/layout/tests/text/linesHeight.test.ts b/packages/layout/tests/text/linesHeight.test.ts new file mode 100644 index 000000000..1542d2885 --- /dev/null +++ b/packages/layout/tests/text/linesHeight.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'vitest'; +import * as P from '@react-pdf/primitives'; +import { SafeTextNode } from '../../src'; +import linesHeight from '../../src/text/linesHeight'; + +const createTextNode = (style = {}, lines = []): SafeTextNode => ({ + style, + props: {}, + type: P.Text, + children: [], + lines, +}); + +describe('linesHeight', () => { + test('should return -1 for no lines', () => { + const node = createTextNode(); + delete node.lines; + expect(linesHeight(node)).toBe(-1); + }); + + test('should return sum of line heights for horizontal text', () => { + const lines = [ + { string: 'a', runs: [], box: { x: 0, y: 0, width: 200, height: 20 } }, + { string: 'b', runs: [], box: { x: 0, y: 20, width: 200, height: 20 } }, + ]; + const node = createTextNode({}, lines); + expect(linesHeight(node)).toBe(40); + }); + + test('should return max column height for vertical-rl text', () => { + const lines = [ + { string: 'a', runs: [], box: { x: 180, y: 0, width: 20, height: 100 } }, + { string: 'b', runs: [], box: { x: 160, y: 0, width: 20, height: 80 } }, + ]; + const node = createTextNode({ writingMode: 'vertical-rl' }, lines); + expect(linesHeight(node)).toBe(100); + }); + + test('should return max column height for vertical-lr text', () => { + const lines = [ + { string: 'a', runs: [], box: { x: 0, y: 0, width: 20, height: 120 } }, + { string: 'b', runs: [], box: { x: 20, y: 0, width: 20, height: 80 } }, + ]; + const node = createTextNode({ writingMode: 'vertical-lr' }, lines); + expect(linesHeight(node)).toBe(120); + }); +}); diff --git a/packages/layout/tests/text/linesWidth.test.ts b/packages/layout/tests/text/linesWidth.test.ts new file mode 100644 index 000000000..44cd9a55d --- /dev/null +++ b/packages/layout/tests/text/linesWidth.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'vitest'; +import * as P from '@react-pdf/primitives'; +import { SafeTextNode } from '../../src'; +import linesWidth from '../../src/text/linesWidth'; + +const createTextNode = (style = {}, lines = []): SafeTextNode => ({ + style, + props: {}, + type: P.Text, + children: [], + lines, +}); + +describe('linesWidth', () => { + test('should return 0 for no lines', () => { + const node = createTextNode(); + expect(linesWidth(node)).toBe(0); + }); + + test('should return max xAdvance for horizontal text', () => { + const lines = [ + { string: 'a', runs: [], xAdvance: 100, box: { x: 0, y: 0, width: 200, height: 20 } }, + { string: 'b', runs: [], xAdvance: 150, box: { x: 0, y: 20, width: 200, height: 20 } }, + ]; + const node = createTextNode({}, lines); + expect(linesWidth(node)).toBe(150); + }); + + test('should return sum of column widths for vertical-rl text', () => { + const lines = [ + { string: 'a', runs: [], xAdvance: 100, box: { x: 180, y: 0, width: 20, height: 100 } }, + { string: 'b', runs: [], xAdvance: 100, box: { x: 160, y: 0, width: 20, height: 100 } }, + ]; + const node = createTextNode({ writingMode: 'vertical-rl' }, lines); + expect(linesWidth(node)).toBe(40); + }); + + test('should return sum of column widths for vertical-lr text', () => { + const lines = [ + { string: 'a', runs: [], xAdvance: 100, box: { x: 0, y: 0, width: 20, height: 100 } }, + { string: 'b', runs: [], xAdvance: 100, box: { x: 20, y: 0, width: 20, height: 100 } }, + ]; + const node = createTextNode({ writingMode: 'vertical-lr' }, lines); + expect(linesWidth(node)).toBe(40); + }); +}); From 799bf97a2d5b4a95d2308ae3dcf056ff59fb15f5 Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 04:16:24 -0300 Subject: [PATCH 04/14] fix(layout): prevent infinite pagination loop with oversized text - Pass contentArea to splitText (was previously only passed to splitView) - Force at least one line onto current page when first line exceeds contentArea, preventing endless push-to-next-page loop - Return [current, null] when no lines remain for next page, stopping pagination when all text content has been consumed --- .../layout/src/steps/resolvePagination.ts | 4 ++- packages/layout/src/text/splitText.ts | 33 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/layout/src/steps/resolvePagination.ts b/packages/layout/src/steps/resolvePagination.ts index f029d7619..de55879bc 100644 --- a/packages/layout/src/steps/resolvePagination.ts +++ b/packages/layout/src/steps/resolvePagination.ts @@ -164,7 +164,9 @@ const splitView = (node: SafeNode, height: number, contentArea: number) => { }; const split = (node: SafeNode, height: number, contentArea: number) => - isText(node) ? splitText(node, height) : splitView(node, height, contentArea); + isText(node) + ? splitText(node, height, contentArea) + : splitView(node, height, contentArea); const shouldResolveDynamicNodes = (node: SafeNode) => { const children = node.children || []; diff --git a/packages/layout/src/text/splitText.ts b/packages/layout/src/text/splitText.ts index 848b3b68e..e6450965b 100644 --- a/packages/layout/src/text/splitText.ts +++ b/packages/layout/src/text/splitText.ts @@ -32,9 +32,25 @@ const getLineBreak = (node: SafeTextNode, height: number) => { return slicedLine; }; -// Also receives contentArea in case it's needed -const splitText = (node: SafeTextNode, height: number) => { - const slicedLineIndex = getLineBreak(node, height); +const splitText = ( + node: SafeTextNode, + height: number, + contentArea: number = Infinity, +) => { + let slicedLineIndex = getLineBreak(node, height); + + // When no lines fit on the current page but lines exist, check if the first + // line (e.g. a vertical text column) is taller than any page can accommodate. + // If so, force at least one line onto the current page to prevent an infinite + // pagination loop where the oversized line is endlessly pushed to the next page. + if (slicedLineIndex === 0 && node.lines.length > 0) { + const firstLineHeight = node.lines[0]?.box?.height || 0; + + if (firstLineHeight > contentArea) { + slicedLineIndex = 1; + } + } + const currentHeight = heightAtLineIndex(node, slicedLineIndex); const nextHeight = node.box.height - currentHeight; @@ -55,6 +71,15 @@ const splitText = (node: SafeTextNode, height: number) => { lines: node.lines.slice(0, slicedLineIndex), }); + const nextLines = node.lines.slice(slicedLineIndex); + + // If no lines remain for the next page, stop splitting. This prevents an + // infinite pagination loop when all text content has been consumed but the + // node still has a fixed height (from style) larger than the page. + if (nextLines.length === 0) { + return [current, null]; + } + const next: SafeTextNode = Object.assign({}, node, { box: { ...node.box, @@ -70,7 +95,7 @@ const splitText = (node: SafeTextNode, height: number) => { borderTopLeftRadius: 0, borderTopRightRadius: 0, }, - lines: node.lines.slice(slicedLineIndex), + lines: nextLines, }); return [current, next]; From 72224405340f16dae103ac00ca995f5dbdc50d3c Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 04:16:31 -0300 Subject: [PATCH 05/14] feat(render): add vertical text rendering - Add renderGlyphsVertical for top-to-bottom glyph positioning - Center glyphs horizontally within column using advanceWidth - Use Math.max(xAdvance, fontSize) per glyph to prevent overlap - Return total advance for inter-run CTM advancement - Update renderRun to handle vertical mode with CTM translation - Update renderLine to position at column (x, y) without lineAscent offset - Update renderText to detect writingMode and adjust initial translation - Support vertical background rendering for text runs --- packages/render/src/primitives/renderText.ts | 168 +++++++++++++++---- 1 file changed, 138 insertions(+), 30 deletions(-) diff --git a/packages/render/src/primitives/renderText.ts b/packages/render/src/primitives/renderText.ts index f16e7c5f0..4d218bc92 100644 --- a/packages/render/src/primitives/renderText.ts +++ b/packages/render/src/primitives/renderText.ts @@ -8,15 +8,94 @@ import { Attachment, AttributedString, DecorationLine, + Glyph, Paragraph, + Position, Rect, Run, } from '@react-pdf/textkit'; +import encodeGlyphs from '../operations/encodeGlyphs'; const DEST_REGEXP = /^#.+/; const isSrcId = (src: string) => src.match(DEST_REGEXP); +const number = (n: number) => { + if (n > -1e21 && n < 1e21) { + return Math.round(n * 1e6) / 1e6; + } + throw new Error(`unsupported number: ${n}`); +}; + +/** + * Render glyphs vertically (top-to-bottom) for CJK vertical text. + * Each glyph is positioned individually using the Tm operator. + * + * Coordinate system: renderLine has already translated the CTM to the + * column's (x, y). We apply the same y-flip as horizontal renderGlyphs + * (`transform(1,0,0,-1,0, pageHeight)`). In the flipped space, + * y = pageHeight maps back to the CTM origin (top of column) and + * decreasing y moves DOWN the page. + * + * @returns {number} Total vertical advance in points. + */ +const renderGlyphsVertical = ( + ctx: Context, + glyphs: Glyph[], + positions: Position[], + fontSize: number, +): number => { + const unitsPerEm = ctx._font.font.unitsPerEm || 1000; + const encodedGlyphs = encodeGlyphs(ctx._font, glyphs); + + ctx.save(); + + // Flip coordinate system (same as horizontal renderGlyphs) + ctx.transform(1, 0, 0, -1, 0, ctx.page.height); + + // Add font to page if necessary + if (ctx.page.fonts[ctx._font.id] == null) { + ctx.page.fonts[ctx._font.id] = ctx._font.ref(); + } + + ctx.addContent('BT'); + ctx.addContent(`/${ctx._font.id} ${number(fontSize)} Tf`); + + // In the flipped coordinate space, y = pageHeight corresponds to the + // CTM origin (the top of the column that renderLine translated to). + // Each glyph advances downward (decreasing y in flipped space). + let y = ctx.page.height; + let totalAdvance = 0; + + for (let i = 0; i < encodedGlyphs.length; i += 1) { + const pos = positions[i]; + + // Glyph width in points (advanceWidth is in font units) + const glyphWidth = (glyphs[i].advanceWidth * fontSize) / unitsPerEm; + + // Center the glyph horizontally within the column + const x = (fontSize - glyphWidth) / 2; + + ctx.addContent( + `1 0 0 1 ${number(x + (pos.xOffset || 0))} ${number(y)} Tm`, + ); + ctx.addContent(`<${encodedGlyphs[i]}> Tj`); + + // In vertical CJK text each character occupies one em-height (fontSize). + // Use the larger of the horizontal advance and fontSize to guarantee + // glyphs never overlap, even when the font isn't fully loaded or when + // proportional characters are mixed with full-width ones. + const advance = Math.max(pos.xAdvance || 0, fontSize); + y -= advance; + totalAdvance += advance; + } + + ctx.addContent('ET'); + ctx.restore(); + + return totalAdvance; +}; + const renderAttachment = (ctx: Context, attachment: Attachment) => { const { xOffset = 0, yOffset = 0, width, height, image } = attachment; @@ -59,7 +138,7 @@ const renderAttachments = (ctx: Context, run: Run, glyphs: Run['glyphs']) => { ctx.restore(); }; -const renderRun = (ctx: Context, run: Run) => { +const renderRun = (ctx: Context, run: Run, vertical = false) => { if (!run.glyphs) return; if (!run.positions) return; @@ -92,13 +171,20 @@ const renderRun = (ctx: Context, run: Run) => { ctx.font(font.type === 'STANDARD' ? font.fullName : font, fontSize); - try { - renderGlyphs(ctx, glyphs, run.positions!, 0, 0); - } catch (error) { - console.log(error); - } + if (vertical) { + const advance = renderGlyphsVertical(ctx, glyphs, run.positions!, fontSize || 12); + // Advance the CTM downward so the next run in this column + // starts below the current one (analogous to horizontal ctx.translate(xAdvance, 0)). + ctx.translate(0, advance); + } else { + try { + renderGlyphs(ctx, glyphs, run.positions!, 0, 0); + } catch (error) { + console.log(error); + } - ctx.translate(xAdvance, 0); + ctx.translate(xAdvance, 0); + } }; const renderBackground = ( @@ -182,13 +268,20 @@ const renderDecorationLine = (ctx: Context, decorationLine: DecorationLine) => { ctx.restore(); }; -const renderLine = (ctx: Context, line: AttributedString) => { +const renderLine = (ctx: Context, line: AttributedString, vertical = false) => { if (!line.box) return; const lineAscent = line.ascent || 0; ctx.save(); - ctx.translate(line.box.x, line.box.y + lineAscent); + + if (vertical) { + // For vertical text, the line box represents a column + // x = column's horizontal position, y = top of column + ctx.translate(line.box.x, line.box.y); + } else { + ctx.translate(line.box.x, line.box.y + lineAscent); + } for (let i = 0; i < line.runs.length; i += 1) { const run = line.runs[i]; @@ -198,35 +291,47 @@ const renderLine = (ctx: Context, line: AttributedString) => { const xAdvance = run.xAdvance ?? 0; const overflowRight = isLastRun ? line.overflowRight ?? 0 : 0; - const backgroundRect = { - x: 0, - y: -lineAscent, - height: line.box.height, - width: xAdvance - overflowRight, - }; - - renderBackground(ctx, backgroundRect, run.attributes.backgroundColor); + if (vertical) { + const backgroundRect = { + x: 0, + y: 0, + width: line.box.width, + height: line.box.height, + }; + renderBackground(ctx, backgroundRect, run.attributes.backgroundColor); + } else { + const backgroundRect = { + x: 0, + y: -lineAscent, + height: line.box.height, + width: xAdvance - overflowRight, + }; + renderBackground(ctx, backgroundRect, run.attributes.backgroundColor); + } } - renderRun(ctx, run); + renderRun(ctx, run, vertical); } ctx.restore(); - ctx.save(); - ctx.translate(line.box.x, line.box.y); - if (line.decorationLines) { - for (let i = 0; i < line.decorationLines.length; i += 1) { - const decorationLine = line.decorationLines[i]; - renderDecorationLine(ctx, decorationLine); + if (!vertical) { + ctx.save(); + ctx.translate(line.box.x, line.box.y); + + if (line.decorationLines) { + for (let i = 0; i < line.decorationLines.length; i += 1) { + const decorationLine = line.decorationLines[i]; + renderDecorationLine(ctx, decorationLine); + } } - } - ctx.restore(); + ctx.restore(); + } }; -const renderBlock = (ctx: Context, block: Paragraph) => { +const renderBlock = (ctx: Context, block: Paragraph, vertical = false) => { block.forEach((line) => { - renderLine(ctx, line); + renderLine(ctx, line, vertical); }); }; @@ -240,12 +345,15 @@ const renderText = (ctx: Context, node: SafeTextNode) => { const paddingLeft = node.box?.paddingLeft || 0; const initialY = node.lines[0] ? node.lines[0].box!.y : 0; const offsetX = node.alignOffset || 0; + const vertical = + node.style?.writingMode === 'vertical-rl' || + node.style?.writingMode === 'vertical-lr'; ctx.save(); - ctx.translate(left + paddingLeft - offsetX, top + paddingTop - initialY); + ctx.translate(left + paddingLeft - offsetX, top + paddingTop - (vertical ? 0 : initialY)); blocks.forEach((block) => { - renderBlock(ctx, block); + renderBlock(ctx, block, vertical); }); ctx.restore(); From bae2c3187e17eb33567966aa11111781ffa954ce Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 04:16:41 -0300 Subject: [PATCH 06/14] feat(renderer): export CJK font families API - Export CJK and CJK_FONT_FAMILIES from renderer package - Add TypeScript declarations for CJK constant with JSDoc - Add deprecated CJK_FONT_FAMILIES alias in type declarations --- packages/renderer/index.d.ts | 21 +++++++++++++++++++++ packages/renderer/src/index.js | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/renderer/index.d.ts b/packages/renderer/index.d.ts index 1aa2dc804..727a00139 100644 --- a/packages/renderer/index.d.ts +++ b/packages/renderer/index.d.ts @@ -742,6 +742,27 @@ declare namespace ReactPDF { export const Font: FontStore; + /** + * Built-in CJK font family names. + * These are registered automatically and lazily loaded from Google Fonts. + * + * @example + * 한국어 + */ + export const CJK: { + /** Simplified Chinese (Mainland China) */ + readonly CHINESE_SIMPLIFIED: 'Noto Sans SC'; + /** Traditional Chinese (Taiwan, Macau) */ + readonly CHINESE_TRADITIONAL: 'Noto Sans TC'; + /** Japanese (Hiragana, Katakana, Kanji) */ + readonly JAPANESE: 'Noto Sans JP'; + /** Korean (Hangul, Hanja) */ + readonly KOREAN: 'Noto Sans KR'; + }; + + /** @deprecated Use `CJK` instead */ + export const CJK_FONT_FAMILIES: typeof CJK; + export const StyleSheet: { create: (styles: T) => T; }; diff --git a/packages/renderer/src/index.js b/packages/renderer/src/index.js index 86444f3b6..3a38dfb14 100644 --- a/packages/renderer/src/index.js +++ b/packages/renderer/src/index.js @@ -1,4 +1,4 @@ -import FontStore from '@react-pdf/font'; +import FontStore, { CJK, CJK_FONT_FAMILIES } from '@react-pdf/font'; import renderPDF from '@react-pdf/render'; import PDFDocument from '@react-pdf/pdfkit'; import layoutDocument from '@react-pdf/layout'; @@ -187,4 +187,4 @@ const StyleSheet = { create: (s) => s, }; -export { version, Font, StyleSheet, pdf, createRenderer }; +export { version, Font, StyleSheet, pdf, createRenderer, CJK, CJK_FONT_FAMILIES }; From f50cb18c9110d7fa2d83837f246c175fb5ea99a2 Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 04:19:23 -0300 Subject: [PATCH 07/14] chore: add changeset for vertical writing mode and CJK fonts --- .changeset/vertical-text-cjk.md | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .changeset/vertical-text-cjk.md diff --git a/.changeset/vertical-text-cjk.md b/.changeset/vertical-text-cjk.md new file mode 100644 index 000000000..55331343c --- /dev/null +++ b/.changeset/vertical-text-cjk.md @@ -0,0 +1,45 @@ +--- +"@react-pdf/textkit": minor +"@react-pdf/stylesheet": minor +"@react-pdf/font": minor +"@react-pdf/layout": minor +"@react-pdf/render": minor +"@react-pdf/renderer": minor +--- + +feat: add vertical writing mode and built-in CJK font support + +### Vertical Writing Mode + +Added `writingMode` style property for `` elements, supporting vertical top-to-bottom text layout commonly used in CJK (Chinese, Japanese, Korean) typography. + +```jsx + + 모든 사람은 의견의 자유와 표현의 자유에 대한 권리를 가진다. + +``` + +Supported values: +- `horizontal-tb` (default) — standard left-to-right, top-to-bottom +- `vertical-rl` — top-to-bottom, columns from right to left +- `vertical-lr` — top-to-bottom, columns from left to right + +### Built-in CJK Fonts + +CJK fonts (Noto Sans) are now registered automatically and lazily loaded from Google Fonts. No manual `Font.register()` needed. + +```jsx +import { CJK } from '@react-pdf/renderer'; + +// Explicit font selection +한국어 텍스트 + +// Or just use CJK text — fonts are auto-detected and loaded +日本語テキスト +``` + +Available fonts: `CJK.CHINESE_SIMPLIFIED`, `CJK.CHINESE_TRADITIONAL`, `CJK.JAPANESE`, `CJK.KOREAN`. + +### Font IntelliSense + +`fontFamily` now provides autocomplete for all built-in fonts (Helvetica, Courier, Times-Roman, and CJK Noto Sans families) while still accepting custom registered font names. From a6b652e95a79dd26ff8009a0ea79826fd00d77ab Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 09:51:36 -0300 Subject: [PATCH 08/14] docs(examples): add vertical text and CJK font examples - Add 'Vertical Text' example tab with vertical-rl, vertical-lr, and auto-detected CJK demonstrations across Korean, Japanese, and Chinese - Add CJK font examples to 'Font Family Fallback' tab showing built-in CJK fonts, auto-detection, and mixed-script fallback chains --- .../examples/font-family-fallback/index.tsx | 40 ++++ packages/examples/vite/src/examples/index.ts | 2 + .../vite/src/examples/vertical-text/index.tsx | 183 ++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 packages/examples/vite/src/examples/vertical-text/index.tsx diff --git a/packages/examples/vite/src/examples/font-family-fallback/index.tsx b/packages/examples/vite/src/examples/font-family-fallback/index.tsx index d3dfb4736..58071a57d 100644 --- a/packages/examples/vite/src/examples/font-family-fallback/index.tsx +++ b/packages/examples/vite/src/examples/font-family-fallback/index.tsx @@ -6,6 +6,7 @@ import { Text, StyleSheet, Font, + CJK, } from '@react-pdf/renderer'; import RobotoFont from '../../../public/Roboto-Regular.ttf'; @@ -126,6 +127,45 @@ const MyDoc = () => { Courier + + + Built-in CJK — Korean (auto-registered) + + 한국어 텍스트 — no Font.register() needed + + + + + Built-in CJK — Japanese (auto-registered) + + 日本語テキスト — フォント登録不要 + + + + + Built-in CJK — Simplified Chinese (auto-registered) + + 简体中文文本 — 无需字体注册 + + + + + + CJK auto-detection — no fontFamily specified + + + Mixed: Hello 한국어 日本語 中文 world + + + + + + Roboto + CJK fallback (mixed scripts) + + + Roboto Latin with 한국어 fallback + + ); }; diff --git a/packages/examples/vite/src/examples/index.ts b/packages/examples/vite/src/examples/index.ts index 94f26e953..aee45c4c7 100644 --- a/packages/examples/vite/src/examples/index.ts +++ b/packages/examples/vite/src/examples/index.ts @@ -25,6 +25,7 @@ import responsiveImages from './responsive-images'; import math from './math'; import passwordProtection from './password-protection'; import softHyphens from './soft-hyphens'; +import verticalText from './vertical-text'; const EXAMPLES = [ scripts, @@ -54,6 +55,7 @@ const EXAMPLES = [ math, passwordProtection, softHyphens, + verticalText, ]; export default EXAMPLES; diff --git a/packages/examples/vite/src/examples/vertical-text/index.tsx b/packages/examples/vite/src/examples/vertical-text/index.tsx new file mode 100644 index 000000000..da02b94b8 --- /dev/null +++ b/packages/examples/vite/src/examples/vertical-text/index.tsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { + Document, + Page, + View, + Text, + StyleSheet, + CJK, +} from '@react-pdf/renderer'; + +const styles = StyleSheet.create({ + body: { + padding: 40, + backgroundColor: '#fafafa', + }, + title: { + fontSize: 18, + fontWeight: 'bold', + color: '#1a1a1a', + }, + subtitle: { + fontSize: 9, + color: '#888', + marginBottom: 20, + }, + sectionTitle: { + fontSize: 12, + fontWeight: 'bold', + color: '#444', + marginTop: 16, + marginBottom: 8, + }, + row: { + flexDirection: 'row', + gap: 12, + marginBottom: 12, + }, + card: { + backgroundColor: '#ffffff', + borderRadius: 5, + padding: 12, + borderWidth: 1, + borderColor: '#e8e8e8', + flex: 1, + }, + cardLabel: { + fontSize: 8, + color: '#999', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 8, + }, +}); + +const VerticalText = () => { + return ( + + + Vertical Writing Mode + + Demonstrating writingMode: vertical-rl and vertical-lr for CJK text + + + vertical-rl (right to left columns) + + + + Korean + + 모든 사람은 의견의 자유와 표현의 자유에 대한 권리를 가진다. + + + + + Japanese + + すべての人間は、生まれながらにして自由であり、かつ尊厳と権利とについて平等である。 + + + + + + + Simplified Chinese + + 人人生而自由,在尊严和权利上一律平等。 + + + + + Traditional Chinese + + 人人生而自由,在尊嚴和權利上一律平等。 + + + + + vertical-lr (left to right columns) + + + + Korean (vertical-lr) + + 왼쪽에서 오른쪽으로 열이 진행됩니다. + + + + + Latin text (vertical-rl) + + ABCDEFGHIJKLMNOPQRSTUVWXYZ + + + + + Auto-detected CJK (no fontFamily) + + + + Mixed CJK — fonts detected automatically + + + 한국어 日本語 中文 mixed text + + + + + ); +}; + +export default { + id: 'vertical-text', + name: 'Vertical Text', + description: '', + Document: VerticalText, +}; From 05a2a2ce9ef8676735c3e1a3cd164bfb7075c617 Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 11:04:01 -0300 Subject: [PATCH 09/14] fix(render): scale xOffset/yOffset by fontSize/unitsPerEm in renderGlyphsVertical Position offsets (xOffset, yOffset) are in font units (1000-em), not points. Apply the same scale factor used by horizontal renderGlyphs so kerning and ligature offsets are correctly converted to points in vertical mode. --- packages/render/src/primitives/renderText.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/render/src/primitives/renderText.ts b/packages/render/src/primitives/renderText.ts index 4d218bc92..f7e24c400 100644 --- a/packages/render/src/primitives/renderText.ts +++ b/packages/render/src/primitives/renderText.ts @@ -47,6 +47,7 @@ const renderGlyphsVertical = ( ): number => { const unitsPerEm = ctx._font.font.unitsPerEm || 1000; const encodedGlyphs = encodeGlyphs(ctx._font, glyphs); + const scale = fontSize / unitsPerEm; ctx.save(); @@ -77,7 +78,7 @@ const renderGlyphsVertical = ( const x = (fontSize - glyphWidth) / 2; ctx.addContent( - `1 0 0 1 ${number(x + (pos.xOffset || 0))} ${number(y)} Tm`, + `1 0 0 1 ${number(x + (pos.xOffset || 0) * scale)} ${number(y + (pos.yOffset || 0) * scale)} Tm`, ); ctx.addContent(`<${encodedGlyphs[i]}> Tj`); From 70028c8abe8788d4d837df21c3c7c06f6912013f Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 11:15:09 -0300 Subject: [PATCH 10/14] fix(render): size vertical background rect to run advance, not full column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background was painted as (0, 0, column.width, column.height) for every run, overpainting subsequent runs and blank space. Now each run's background height is the sum of Math.max(pos.xAdvance, fontSize) for its glyphs — exactly the vertical space that run occupies before the CTM advances. --- packages/render/src/primitives/renderText.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/render/src/primitives/renderText.ts b/packages/render/src/primitives/renderText.ts index f7e24c400..2b9a5fa69 100644 --- a/packages/render/src/primitives/renderText.ts +++ b/packages/render/src/primitives/renderText.ts @@ -139,6 +139,16 @@ const renderAttachments = (ctx: Context, run: Run, glyphs: Run['glyphs']) => { ctx.restore(); }; +const calcVerticalRunAdvance = (run: Run): number => { + if (!run.positions || !run.attributes.fontSize) return 0; + const fontSize = run.attributes.fontSize; + let advance = 0; + for (const pos of run.positions) { + advance += Math.max(pos.xAdvance || 0, fontSize); + } + return advance; +}; + const renderRun = (ctx: Context, run: Run, vertical = false) => { if (!run.glyphs) return; if (!run.positions) return; @@ -293,11 +303,12 @@ const renderLine = (ctx: Context, line: AttributedString, vertical = false) => { const overflowRight = isLastRun ? line.overflowRight ?? 0 : 0; if (vertical) { + const runAdvance = calcVerticalRunAdvance(run); const backgroundRect = { x: 0, y: 0, width: line.box.width, - height: line.box.height, + height: runAdvance, }; renderBackground(ctx, backgroundRect, run.attributes.backgroundColor); } else { From a13ccf4ceb89718e08ed1e8ea9bc88fef8010fa0 Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 11:21:23 -0300 Subject: [PATCH 11/14] fix(layout): use per-column width in transformVerticalLines for vertical-rl columnWidth was read once from lines[0].box.height and reused for all columns when computing the x position in vertical-rl mode. Mixed font sizes or line heights produce columns of different widths, causing incorrect x placement for all columns after the first. Now each column reads its own box.height. --- .../examples/font-family-fallback/index.tsx | 14 +++++-- .../vite/src/examples/vertical-text/index.tsx | 39 +++++++++++++------ packages/layout/src/text/layoutText.ts | 8 ++-- 3 files changed, 41 insertions(+), 20 deletions(-) diff --git a/packages/examples/vite/src/examples/font-family-fallback/index.tsx b/packages/examples/vite/src/examples/font-family-fallback/index.tsx index 58071a57d..c5f858cad 100644 --- a/packages/examples/vite/src/examples/font-family-fallback/index.tsx +++ b/packages/examples/vite/src/examples/font-family-fallback/index.tsx @@ -129,21 +129,27 @@ const MyDoc = () => { - Built-in CJK — Korean (auto-registered) + + Built-in CJK — Korean (auto-registered) + - 한국어 텍스트 — no Font.register() needed + 한국어 텍스트 - Built-in CJK — Japanese (auto-registered) + + Built-in CJK — Japanese (auto-registered) + 日本語テキスト — フォント登録不要 - Built-in CJK — Simplified Chinese (auto-registered) + + Built-in CJK — Simplified Chinese (auto-registered) + 简体中文文本 — 无需字体注册 diff --git a/packages/examples/vite/src/examples/vertical-text/index.tsx b/packages/examples/vite/src/examples/vertical-text/index.tsx index da02b94b8..9396d07e0 100644 --- a/packages/examples/vite/src/examples/vertical-text/index.tsx +++ b/packages/examples/vite/src/examples/vertical-text/index.tsx @@ -61,7 +61,9 @@ const VerticalText = () => { Demonstrating writingMode: vertical-rl and vertical-lr for CJK text - vertical-rl (right to left columns) + + vertical-rl (right to left columns) + @@ -88,7 +90,7 @@ const VerticalText = () => { height: 200, }} > - すべての人間は、生まれながらにして自由であり、かつ尊厳と権利とについて平等である。 + すべての人間は、生まれながらにして自由であり、かつ尊厳と権。 @@ -104,7 +106,7 @@ const VerticalText = () => { height: 200, }} > - 人人生而自由,在尊严和权利上一律平等。 + 人人生而自由,在尊严和权利。 @@ -118,25 +120,34 @@ const VerticalText = () => { height: 200, }} > - 人人生而自由,在尊嚴和權利上一律平等。 + 人人生而自由,在尊嚴和權利。 + - vertical-lr (left to right columns) + + Vertical Writing Mode + + Demonstrating writingMode: vertical-rl and vertical-lr for CJK text + + + + vertical-lr (left to right columns) + - Korean (vertical-lr) + Japanese (vertical-lr) - 왼쪽에서 오른쪽으로 열이 진행됩니다. + すべての人間は、生まれながらにして自由であり、かつ尊厳と権。 @@ -146,15 +157,18 @@ const VerticalText = () => { style={{ writingMode: 'vertical-rl', fontSize: 14, - height: 160, + height: 80, }} > - ABCDEFGHIJKLMNOPQRSTUVWXYZ + Lorem ipsum dolor sit amet consectetur adipisicing elit. + Aspernatur repellat minima itaque. - Auto-detected CJK (no fontFamily) + + Auto-detected CJK (no fontFamily) + @@ -167,7 +181,8 @@ const VerticalText = () => { height: 180, }} > - 한국어 日本語 中文 mixed text + 한국어 日本語 中文 mixed text 人人生而自由,在尊嚴和權利。 모든 + 사람은 의견의 자유와 표현의 자유에 대한 권리를 가진다. diff --git a/packages/layout/src/text/layoutText.ts b/packages/layout/src/text/layoutText.ts index 35ca0955e..f00a0ec3d 100644 --- a/packages/layout/src/text/layoutText.ts +++ b/packages/layout/src/text/layoutText.ts @@ -131,13 +131,13 @@ const verticalColumnHeight = (line) => { const transformVerticalLines = (lines, containerWidth, writingMode) => { if (!lines || lines.length === 0) return lines; - // Calculate total columns width (sum of line heights in horizontal space = column widths in vertical) let columnX = 0; - const columnWidth = lines.length > 0 && lines[0].box ? lines[0].box.height : 0; return lines.map((line, i) => { if (!line.box) return line; + const columnWidth = line.box.height; // each column's own width + // In vertical-rl, columns go from right to left // In vertical-lr, columns go from left to right let x; @@ -150,11 +150,11 @@ const transformVerticalLines = (lines, containerWidth, writingMode) => { const newBox = { x, y: line.box.x, // horizontal x position becomes vertical y position - width: line.box.height, // line height becomes column width + width: columnWidth, // line height becomes column width height: verticalColumnHeight(line), // fontSize-based height to match rendering }; - columnX += line.box.height; + columnX += columnWidth; return Object.assign({}, line, { box: newBox }); }); From 7772bdb89e572de9382f2da160d6ed247ab5ec3e Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 11:29:36 -0300 Subject: [PATCH 12/14] fix(layout): only suppress font errors for internal CJK fallbacks, not user fonts The blanket try/catch in getAttributedString silently swallowed errors for all font families including user-specified ones, masking registration issues and silently falling back to Helvetica. Now only the internally-injected CJK fallback families (which may not be loaded yet) are wrapped in try/catch. User-specified fontFamily values preserve the original throwing behavior, consistent with how svg/layoutText.ts handles font resolution. --- .../vite/src/examples/vertical-text/index.tsx | 6 ++--- .../layout/src/text/getAttributedString.ts | 25 ++++++++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/examples/vite/src/examples/vertical-text/index.tsx b/packages/examples/vite/src/examples/vertical-text/index.tsx index 9396d07e0..a561e3636 100644 --- a/packages/examples/vite/src/examples/vertical-text/index.tsx +++ b/packages/examples/vite/src/examples/vertical-text/index.tsx @@ -138,7 +138,7 @@ const VerticalText = () => { - Japanese (vertical-lr) + Japanese { - Latin text (vertical-rl) + Latin text { const opts = { fontFamily: fontFamilyName, fontWeight, fontStyle }; - try { - const obj = fontStore.getFont(opts); - return obj?.data; - } catch { - return undefined; + if (internalFallbacks.has(fontFamilyName)) { + // Silently skip CJK fallbacks that haven't loaded yet + try { + const obj = fontStore.getFont(opts); + return obj?.data; + } catch { + return undefined; + } } + // User-specified fonts: preserve the error so registration issues are visible + const obj = fontStore.getFont(opts); + return obj?.data; }).filter(Boolean); // Don't pass main background color to textkit. Will be rendered by the render package instead From 065afe8574b2d01fe1470e15d695a2dce0dcb3f1 Mon Sep 17 00:00:00 2001 From: Fernando Rodrigues Cardoso Date: Sun, 12 Apr 2026 11:38:21 -0300 Subject: [PATCH 13/14] Update packages/layout/src/text/measureText.ts rewrite comments on vertical-mode to reflect current vertical width semantics. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/layout/src/text/measureText.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/layout/src/text/measureText.ts b/packages/layout/src/text/measureText.ts index 686ca635e..e4f0a446e 100644 --- a/packages/layout/src/text/measureText.ts +++ b/packages/layout/src/text/measureText.ts @@ -30,7 +30,7 @@ const measureText = (width, widthMode, height) => { if (isVerticalWritingMode(node)) { // For vertical text, after transformVerticalLines: - // - linesWidth returns the total width of all columns (sum of column widths, via max xAdvance of line boxes) + // - linesWidth returns the total width of all columns (sum of `line.box.width` across columns) // - linesHeight returns the column height (the max height among columns) if (widthMode === Yoga.MeasureMode.Exactly) { if (!node.lines) From dd510d69aae8ca05412e6b607d0fe93f2ab889be Mon Sep 17 00:00:00 2001 From: Nando-Cardoso Date: Sun, 12 Apr 2026 11:39:19 -0300 Subject: [PATCH 14/14] fix(render): use vertical metrics for link annotation rects in vertical mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link hit-boxes were using horizontal run metrics (0, -height-descent, xAdvance, height) even in vertical mode, so annotations would not align with rendered text. In vertical mode the run occupies (0, 0, columnWidth, runAdvance) relative to the current CTM — x:0 is the column left edge, y:0 is the top of the run before the CTM translate, width is fontSize (one em = column width), and height is the pre-computed vertical advance from calcVerticalRunAdvance. --- packages/render/src/primitives/renderText.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/render/src/primitives/renderText.ts b/packages/render/src/primitives/renderText.ts index 2b9a5fa69..284184ab2 100644 --- a/packages/render/src/primitives/renderText.ts +++ b/packages/render/src/primitives/renderText.ts @@ -168,7 +168,15 @@ const renderRun = (ctx: Context, run: Run, vertical = false) => { ctx.fillOpacity(opacity); if (link) { - if (isSrcId(link)) { + if (vertical) { + const runAdvance = calcVerticalRunAdvance(run); + const columnWidth = fontSize || 12; + if (isSrcId(link)) { + ctx.goTo(0, 0, columnWidth, runAdvance, link.slice(1)); + } else { + ctx.link(0, 0, columnWidth, runAdvance, link); + } + } else if (isSrcId(link)) { ctx.goTo(0, -height - descent, xAdvance, height, link.slice(1)); } else { ctx.link(0, -height - descent, xAdvance, height, link);