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. 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..c5f858cad 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,51 @@ const MyDoc = () => { Courier + + + + Built-in CJK — Korean (auto-registered) + + + 한국어 텍스트 + + + + + + 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..a561e3636 --- /dev/null +++ b/packages/examples/vite/src/examples/vertical-text/index.tsx @@ -0,0 +1,198 @@ +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 Writing Mode + + Demonstrating writingMode: vertical-rl and vertical-lr for CJK text + + + + vertical-lr (left to right columns) + + + + + Japanese + + すべての人間は、生まれながらにして自由であり、かつ尊厳と権。 + + + + + Latin text + + Lorem ipsum dolor sit amet consectetur adipisicing elit. + Aspernatur repellat minima itaque. + + + + + + Auto-detected CJK (no fontFamily) + + + + + Mixed CJK — fonts detected automatically + + + 한국어 日本語 中文 mixed text 人人生而自由,在尊嚴和權利。 모든 + 사람은 의견의 자유와 표현의 자유에 대한 권리를 가진다. + + + + + ); +}; + +export default { + id: 'vertical-text', + name: 'Vertical Text', + description: '', + Document: VerticalText, +}; 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({ 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/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/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..1611e6cd2 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,41 @@ 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 silently skipped. + const cjkFallbacks = getCJKFallbackFontFamilies(); + const internalFallbacks = new Set(cjkFallbacks); + // Fallback font fontFamilies.push('Helvetica'); + for (const cjkFont of cjkFallbacks) { + if (!fontFamilies.includes(cjkFont)) { + fontFamilies.push(cjkFont); + } + } const font = fontFamilies.map((fontFamilyName) => { const opts = { fontFamily: fontFamilyName, fontWeight, fontStyle }; + 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 const backgroundColor = level === 0 ? null : instance.style.backgroundColor; @@ -90,6 +113,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..f00a0ec3d 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; + + let columnX = 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; + 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: columnWidth, // line height becomes column width + height: verticalColumnHeight(line), // fontSize-based height to match rendering + }; + + columnX += columnWidth; + + 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..e4f0a446e 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 `line.box.width` across columns) + // - 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/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]; 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); + }); +}); diff --git a/packages/render/src/primitives/renderText.ts b/packages/render/src/primitives/renderText.ts index f16e7c5f0..284184ab2 100644 --- a/packages/render/src/primitives/renderText.ts +++ b/packages/render/src/primitives/renderText.ts @@ -8,15 +8,95 @@ 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); + const scale = fontSize / unitsPerEm; + + 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) * scale)} ${number(y + (pos.yOffset || 0) * scale)} 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 +139,17 @@ const renderAttachments = (ctx: Context, run: Run, glyphs: Run['glyphs']) => { ctx.restore(); }; -const renderRun = (ctx: Context, run: Run) => { +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; @@ -78,7 +168,15 @@ const renderRun = (ctx: Context, run: Run) => { 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); @@ -92,13 +190,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 +287,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 +310,48 @@ 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 runAdvance = calcVerticalRunAdvance(run); + const backgroundRect = { + x: 0, + y: 0, + width: line.box.width, + height: runAdvance, + }; + 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 +365,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(); 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 }; 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; };