diff --git a/README.md b/README.md index ee00d5cf..d9695fe3 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,49 @@ while (true) { This usage allows rendering to canvas, SVG, WebGL and (eventually) server-side. +### 3. Vertical Japanese layout (`vertical-rl`) + +Pass `{ writingMode: 'vertical-rl' }` to `prepare()` (or `prepareWithSegments()`) to lay out Japanese text in the browser's `writing-mode: vertical-rl` style — characters flow top-to-bottom, columns stack right-to-left. + +The API parameters become **logical** (inline/block) values: + +| Parameter | Logical meaning | +|---|---| +| `maxWidth` | Max **column height** (inline-axis, top → bottom) | +| `lineHeight` | **Column width** (block-axis advance per column) | +| `LayoutResult.height` | Total physical **width** of all columns | +| `LayoutLine.width` | Used **height** of that column | + +```ts +import { prepare, layout, prepareWithSegments, layoutWithLines } from '@chenglou/pretext' + +const text = '日本語のテキストで縦書きのレイアウトを行うことができます。' +const font = '16px "Hiragino Mincho ProN"' +const columnHeight = 400 // max column height in px +const columnWidth = 20 // each column is 20px wide + +// Predict how many columns the text will fill: +const prepared = prepare(text, font, { writingMode: 'vertical-rl' }) +const { lineCount, height } = layout(prepared, columnHeight, columnWidth) +// lineCount = number of columns, height = total physical width of all columns + +// Or get the lines for manual rendering: +const richPrepared = prepareWithSegments(text, font, { writingMode: 'vertical-rl' }) +const { lines } = layoutWithLines(richPrepared, columnHeight, columnWidth) +// Each line is one column; line.width is the used column height. +// Render each column at x = totalWidth - (i + 1) * columnWidth (right-to-left stacking). +for (let i = 0; i < lines.length; i++) { + const x = lines.length * columnWidth - (i + 1) * columnWidth + ctx.save() + ctx.translate(x + columnWidth / 2, 0) + ctx.rotate(Math.PI / 2) // rotate canvas for sideways Latin; CJK is already upright + ctx.fillText(lines[i].text, 0, 0) + ctx.restore() +} +``` + +The canvas `measureText()` horizontal advances already match the vertical advances browsers use: CJK ideographs and kana are square cells (horizontal ≈ vertical advance), and Latin/ASCII characters rotate sideways so their vertical extent equals their horizontal width. Kinsoku (line-start/end prohibition) rules for Japanese punctuation apply in both axes. + ### API Glossary Use-case 1 APIs: diff --git a/src/analysis.ts b/src/analysis.ts index a22d881e..e7d8be07 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -1,5 +1,7 @@ export type WhiteSpaceMode = 'normal' | 'pre-wrap' +export type WritingMode = 'horizontal-tb' | 'vertical-rl' + export type SegmentBreakKind = | 'text' | 'space' diff --git a/src/layout.test.ts b/src/layout.test.ts index 3b5d01bb..a10be806 100644 --- a/src/layout.test.ts +++ b/src/layout.test.ts @@ -625,3 +625,40 @@ describe('layout invariants', () => { } }) }) + +describe('vertical-rl layout invariants', () => { + // In vertical-rl mode the parameters are logical: + // maxWidth = column height (inline-axis constraint, top-to-bottom) + // lineHeight = column width (block-axis advance per column, right-to-left) + // LayoutResult.height = total physical width of all columns + // + // Canvas measureText() horizontal advances are already correct vertical + // advances for CJK/kana (square cells) and sideways-rotated Latin. + + const JAPANESE = '日本語のテキストで縦書きのレイアウトを行うことができます。' + const COLUMN_WIDTH = 20 + + test('vertical-rl prepare produces a positive line count for Japanese text', () => { + const prepared = prepare(JAPANESE, FONT, { writingMode: 'vertical-rl' }) + const result = layout(prepared, 200, COLUMN_WIDTH) + expect(result.lineCount).toBeGreaterThan(0) + expect(result.height).toBe(result.lineCount * COLUMN_WIDTH) + }) + + test('vertical-rl column count grows as column height shrinks', () => { + const prepared = prepare(JAPANESE, FONT, { writingMode: 'vertical-rl' }) + const tall = layout(prepared, 400, COLUMN_WIDTH) + const short = layout(prepared, 200, COLUMN_WIDTH) + const veryShort = layout(prepared, 100, COLUMN_WIDTH) + expect(short.lineCount).toBeGreaterThanOrEqual(tall.lineCount) + expect(veryShort.lineCount).toBeGreaterThanOrEqual(short.lineCount) + }) + + test('prepareWithSegments exposes writingMode on the result', () => { + const horiz = prepareWithSegments(JAPANESE, FONT) + expect((horiz as { writingMode: string }).writingMode).toBe('horizontal-tb') + + const vert = prepareWithSegments(JAPANESE, FONT, { writingMode: 'vertical-rl' }) + expect((vert as { writingMode: string }).writingMode).toBe('vertical-rl') + }) +}) diff --git a/src/layout.ts b/src/layout.ts index 465a0673..e24fa6b2 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -46,6 +46,7 @@ import { type SegmentBreakKind, type TextAnalysis, type WhiteSpaceMode, + type WritingMode, } from './analysis.js' import { clearMeasurementCaches, @@ -92,6 +93,7 @@ type PreparedCore = { discretionaryHyphenWidth: number // Visible width added when a soft hyphen is chosen as the break tabStopAdvance: number // Absolute advance between tab stops for pre-wrap tab segments chunks: PreparedLineChunk[] // Precompiled hard-break chunks for line walking + writingMode: WritingMode // CSS writing-mode the text was prepared for; affects caller geometry interpretation } // Keep the main prepared handle opaque so the public API does not accidentally @@ -146,8 +148,11 @@ export type PrepareProfile = { export type PrepareOptions = { whiteSpace?: WhiteSpaceMode + writingMode?: WritingMode } +export { type WritingMode } + export type PreparedLineChunk = { startSegmentIndex: number endSegmentIndex: number @@ -156,7 +161,7 @@ export type PreparedLineChunk = { // --- Public API --- -function createEmptyPrepared(includeSegments: boolean): InternalPreparedText | PreparedTextWithSegments { +function createEmptyPrepared(includeSegments: boolean, writingMode: WritingMode): InternalPreparedText | PreparedTextWithSegments { if (includeSegments) { return { widths: [], @@ -170,6 +175,7 @@ function createEmptyPrepared(includeSegments: boolean): InternalPreparedText | P discretionaryHyphenWidth: 0, tabStopAdvance: 0, chunks: [], + writingMode, segments: [], } as unknown as PreparedTextWithSegments } @@ -185,6 +191,7 @@ function createEmptyPrepared(includeSegments: boolean): InternalPreparedText | P discretionaryHyphenWidth: 0, tabStopAdvance: 0, chunks: [], + writingMode, } as unknown as InternalPreparedText } @@ -192,6 +199,7 @@ function measureAnalysis( analysis: TextAnalysis, font: string, includeSegments: boolean, + writingMode: WritingMode = 'horizontal-tb', ): InternalPreparedText | PreparedTextWithSegments { const graphemeSegmenter = getSharedGraphemeSegmenter() const engineProfile = getEngineProfile() @@ -203,7 +211,7 @@ function measureAnalysis( const spaceWidth = getCorrectedSegmentWidth(' ', getSegmentMetrics(' ', cache), emojiCorrection) const tabStopAdvance = spaceWidth * 8 - if (analysis.len === 0) return createEmptyPrepared(includeSegments) + if (analysis.len === 0) return createEmptyPrepared(includeSegments, writingMode) const widths: number[] = [] const lineEndFitAdvances: number[] = [] @@ -373,6 +381,7 @@ function measureAnalysis( discretionaryHyphenWidth, tabStopAdvance, chunks, + writingMode, segments, } as unknown as PreparedTextWithSegments } @@ -388,6 +397,7 @@ function measureAnalysis( discretionaryHyphenWidth, tabStopAdvance, chunks, + writingMode, } as unknown as InternalPreparedText } @@ -428,7 +438,7 @@ function prepareInternal( options?: PrepareOptions, ): InternalPreparedText | PreparedTextWithSegments { const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace) - return measureAnalysis(analysis, font, includeSegments) + return measureAnalysis(analysis, font, includeSegments, options?.writingMode ?? 'horizontal-tb') } // Diagnostic-only helper used by the browser benchmark harness to separate the @@ -437,7 +447,7 @@ export function profilePrepare(text: string, font: string, options?: PrepareOpti const t0 = performance.now() const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace) const t1 = performance.now() - const prepared = measureAnalysis(analysis, font, false) as InternalPreparedText + const prepared = measureAnalysis(analysis, font, false, options?.writingMode ?? 'horizontal-tb') as InternalPreparedText const t2 = performance.now() let breakableSegments = 0 @@ -492,6 +502,11 @@ function getInternalPrepared(prepared: PreparedText): InternalPreparedText { // - Break before any non-space segment that would overflow the line // - Trailing whitespace hangs past the line edge (doesn't trigger breaks) // - Segments wider than maxWidth are broken at grapheme boundaries +// +// Vertical layout (writingMode: 'vertical-rl'): +// Parameters are logical — maxWidth is the max column height (inline-axis +// constraint) and lineHeight is the column width (block-axis advance). +// LayoutResult.height is the total physical width of all columns. export function layout(prepared: PreparedText, maxWidth: number, lineHeight: number): LayoutResult { // Keep the resize hot path specialized. `layoutWithLines()` shares the same // break semantics but also tracks line ranges; the extra bookkeeping is too @@ -666,6 +681,8 @@ function materializeLine( // Batch low-level line geometry pass. This is the non-materializing counterpart // to layoutWithLines(), useful for shrinkwrap and other aggregate geometry work. +// For vertical-rl text, maxWidth is the column height and LayoutLineRange.width +// is the used column height for each column. export function walkLineRanges( prepared: PreparedTextWithSegments, maxWidth: number, @@ -678,6 +695,8 @@ export function walkLineRanges( }) } +// Streaming single-line step. For vertical-rl text, maxWidth is the column +// height and LayoutLine.width is the used column height for that column. export function layoutNextLine( prepared: PreparedTextWithSegments, start: LayoutCursor, @@ -692,6 +711,9 @@ export function layoutNextLine( // Caller still supplies lineHeight at layout time. Mirrors layout()'s break // decisions, but keeps extra per-line bookkeeping so it should stay off the // resize hot path. +// For vertical-rl text, maxWidth is the column height, lineHeight is the column +// width, LayoutLinesResult.height is the total physical width of all columns, +// and each LayoutLine.width is the used column height for that column. export function layoutWithLines(prepared: PreparedTextWithSegments, maxWidth: number, lineHeight: number): LayoutLinesResult { const lines: LayoutLine[] = [] if (prepared.widths.length === 0) return { lineCount: 0, height: 0, lines }