Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/analysis.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type WhiteSpaceMode = 'normal' | 'pre-wrap'

export type WritingMode = 'horizontal-tb' | 'vertical-rl'

export type SegmentBreakKind =
| 'text'
| 'space'
Expand Down
37 changes: 37 additions & 0 deletions src/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
30 changes: 26 additions & 4 deletions src/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
type SegmentBreakKind,
type TextAnalysis,
type WhiteSpaceMode,
type WritingMode,
} from './analysis.js'
import {
clearMeasurementCaches,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -146,8 +148,11 @@ export type PrepareProfile = {

export type PrepareOptions = {
whiteSpace?: WhiteSpaceMode
writingMode?: WritingMode
}

export { type WritingMode }

export type PreparedLineChunk = {
startSegmentIndex: number
endSegmentIndex: number
Expand All @@ -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: [],
Expand All @@ -170,6 +175,7 @@ function createEmptyPrepared(includeSegments: boolean): InternalPreparedText | P
discretionaryHyphenWidth: 0,
tabStopAdvance: 0,
chunks: [],
writingMode,
segments: [],
} as unknown as PreparedTextWithSegments
}
Expand All @@ -185,13 +191,15 @@ function createEmptyPrepared(includeSegments: boolean): InternalPreparedText | P
discretionaryHyphenWidth: 0,
tabStopAdvance: 0,
chunks: [],
writingMode,
} as unknown as InternalPreparedText
}

function measureAnalysis(
analysis: TextAnalysis,
font: string,
includeSegments: boolean,
writingMode: WritingMode = 'horizontal-tb',
): InternalPreparedText | PreparedTextWithSegments {
const graphemeSegmenter = getSharedGraphemeSegmenter()
const engineProfile = getEngineProfile()
Expand All @@ -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[] = []
Expand Down Expand Up @@ -373,6 +381,7 @@ function measureAnalysis(
discretionaryHyphenWidth,
tabStopAdvance,
chunks,
writingMode,
segments,
} as unknown as PreparedTextWithSegments
}
Expand All @@ -388,6 +397,7 @@ function measureAnalysis(
discretionaryHyphenWidth,
tabStopAdvance,
chunks,
writingMode,
} as unknown as InternalPreparedText
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 }
Expand Down