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;
};