mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(terminal): shape Bengali and other Indic clusters in the WebGL renderer (#32210)
* fix(terminal): shape Bengali and other Indic clusters in the WebGL renderer xterm gives each Brahmic spacing vowel sign its own cell (matching wcwidth/string-width, which TUIs align on), and the WebGL renderer draws every cell in isolation. Pre-base vowels and conjuncts were never composed, so Bengali rendered as detached marks on dotted circles. Join each multi-cell Indic cluster through the existing lazy character joiner so the browser shapes it as one run. Cell widths are unchanged, so TUI layout and borders keep aligning. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * test(terminal): drop redundant Indic predicate test, make fresh-array test meaningful Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(terminal): stop Indic joiner over-joining Tamil/chillu and registering for non-joining text Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(terminal): keep a trailing Indic cluster prefix between lazy-joiner writes Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
Neil
parent
ba9eb7fcae
commit
d11208c2f1
@@ -229,6 +229,18 @@ describe('registerArabicShapingJoiner', () => {
|
||||
webglLive = true
|
||||
expect(handler('مرحبا')).toEqual([[0, 5]])
|
||||
})
|
||||
|
||||
it('returns RTL runs and Indic clusters from one handler in start order', () => {
|
||||
const host = createJoinerHost()
|
||||
registerArabicShapingJoiner(host.terminal, () => true)
|
||||
|
||||
expect(host.getRegistered()!('বাংলা مرحبا কি')).toEqual([
|
||||
[0, 3],
|
||||
[3, 5],
|
||||
[6, 11],
|
||||
[12, 14]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('configureLazyArabicShapingJoiner', () => {
|
||||
@@ -260,6 +272,60 @@ describe('configureLazyArabicShapingJoiner', () => {
|
||||
expect(host.events).toEqual([])
|
||||
})
|
||||
|
||||
it('registers before the first Bengali write and shapes its clusters', () => {
|
||||
const host = createLazyHost()
|
||||
configureLazyArabicShapingJoiner(host.terminal, () => true)
|
||||
|
||||
ensureArabicShapingJoinerForText(host.terminal, 'আমি দেখি')
|
||||
|
||||
expect(host.events).toEqual(['register'])
|
||||
expect(host.getHandler()!('আমি দেখি')).toEqual([
|
||||
[1, 3],
|
||||
[4, 6],
|
||||
[6, 8]
|
||||
])
|
||||
})
|
||||
|
||||
it('does not register for Indic text that needs no joining', () => {
|
||||
const host = createLazyHost()
|
||||
configureLazyArabicShapingJoiner(host.terminal, () => true)
|
||||
|
||||
ensureArabicShapingJoinerForText(host.terminal, '১২৩ কু ।')
|
||||
|
||||
expect(host.events).toEqual([])
|
||||
})
|
||||
|
||||
it('registers when a Bengali spacing vowel sign arrives in the next write', () => {
|
||||
const host = createLazyHost()
|
||||
configureLazyArabicShapingJoiner(host.terminal, () => true)
|
||||
|
||||
ensureArabicShapingJoinerForText(host.terminal, 'ক')
|
||||
expect(host.events).toEqual([])
|
||||
ensureArabicShapingJoinerForText(host.terminal, 'ি')
|
||||
|
||||
expect(host.events).toEqual(['register'])
|
||||
})
|
||||
|
||||
it('registers when a conjunct is split after the virama', () => {
|
||||
const host = createLazyHost()
|
||||
configureLazyArabicShapingJoiner(host.terminal, () => true)
|
||||
|
||||
ensureArabicShapingJoinerForText(host.terminal, 'ক্')
|
||||
ensureArabicShapingJoinerForText(host.terminal, 'ষ')
|
||||
|
||||
expect(host.events).toEqual(['register'])
|
||||
})
|
||||
|
||||
it('does not carry an Indic tail across an ASCII write', () => {
|
||||
const host = createLazyHost()
|
||||
configureLazyArabicShapingJoiner(host.terminal, () => true)
|
||||
|
||||
ensureArabicShapingJoinerForText(host.terminal, 'ক')
|
||||
ensureArabicShapingJoinerForText(host.terminal, ' ি')
|
||||
|
||||
expect(host.events).toEqual([])
|
||||
})
|
||||
|
||||
it('registers once before the first RTL write and cleans it up', () => {
|
||||
const host = createLazyHost()
|
||||
const cleanup = configureLazyArabicShapingJoiner(host.terminal, () => true)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { findIndicClusterRanges } from './terminal-indic-cluster-ranges'
|
||||
|
||||
type ArabicShapingTerminal = Pick<Terminal, 'registerCharacterJoiner' | 'deregisterCharacterJoiner'>
|
||||
|
||||
@@ -6,7 +7,7 @@ type LazyArabicShapingJoinerState = {
|
||||
cleanup: (() => void) | null
|
||||
isShapingActive: () => boolean
|
||||
registrationAttempted: boolean
|
||||
trailingHighSurrogate: string
|
||||
trailingChunkTail: string
|
||||
}
|
||||
|
||||
const lazyArabicShapingJoinerByTerminal = new WeakMap<
|
||||
@@ -161,14 +162,21 @@ export function findRtlJoinRanges(text: string): [number, number][] {
|
||||
return ranges
|
||||
}
|
||||
|
||||
/** Register the RTL shaping joiner; returns a cleanup deregistering it — Terminal.dispose() leaks joiners (xtermjs/xterm.js#3289). */
|
||||
// Why: Indic clusters share the lazy joiner so one registration, WebGL gate and wiring cover both scripts; the RTL and Indic ranges can never overlap.
|
||||
function findShapingJoinRanges(text: string): [number, number][] {
|
||||
const ranges = findRtlJoinRanges(text)
|
||||
const indicRanges = findIndicClusterRanges(text)
|
||||
return indicRanges.length === 0 ? ranges : [...ranges, ...indicRanges].sort((a, b) => a[0] - b[0])
|
||||
}
|
||||
|
||||
/** Register the shaping joiner (RTL runs and Indic clusters); returns a cleanup deregistering it — Terminal.dispose() leaks joiners (xtermjs/xterm.js#3289). */
|
||||
export function registerArabicShapingJoiner(
|
||||
terminal: ArabicShapingTerminal,
|
||||
isShapingActive: () => boolean
|
||||
): () => void {
|
||||
// Why: DOM renderer letter-spacing breaks grid alignment for joined runs, so join only while WebGL is the live renderer.
|
||||
const joinerId = terminal.registerCharacterJoiner((text) =>
|
||||
isShapingActive() ? findRtlJoinRanges(text) : []
|
||||
isShapingActive() ? findShapingJoinRanges(text) : []
|
||||
)
|
||||
return () => {
|
||||
terminal.deregisterCharacterJoiner(joinerId)
|
||||
@@ -191,7 +199,7 @@ export function configureLazyArabicShapingJoiner(
|
||||
cleanup: null,
|
||||
isShapingActive,
|
||||
registrationAttempted: false,
|
||||
trailingHighSurrogate: ''
|
||||
trailingChunkTail: ''
|
||||
}
|
||||
lazyArabicShapingJoinerByTerminal.set(terminal, state)
|
||||
|
||||
@@ -209,6 +217,17 @@ export function configureLazyArabicShapingJoiner(
|
||||
}
|
||||
}
|
||||
|
||||
const INDIC_TAIL = /[\u0900-\u0DFF\u200C\u200D]{1,8}$/
|
||||
|
||||
// A trailing high surrogate, or the trailing Indic run that a following chunk may complete into a cluster.
|
||||
function getChunkTail(text: string): string {
|
||||
const finalCodeUnit = text.charCodeAt(text.length - 1)
|
||||
if (finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff) {
|
||||
return text.at(-1) ?? ''
|
||||
}
|
||||
return INDIC_TAIL.exec(text)?.[0] ?? ''
|
||||
}
|
||||
|
||||
/** Register the configured joiner immediately before the first RTL write. */
|
||||
export function ensureArabicShapingJoinerForText(
|
||||
terminal: ArabicShapingTerminal,
|
||||
@@ -219,17 +238,14 @@ export function ensureArabicShapingJoinerForText(
|
||||
return
|
||||
}
|
||||
|
||||
// Why: PTY/replay chunks can split a supplementary-plane RTL code point across surrogate halves; keep the boundary unit.
|
||||
const scanText = state.trailingHighSurrogate + text
|
||||
const finalCharacter = scanText.at(-1) ?? ''
|
||||
const finalCodeUnit = finalCharacter.charCodeAt(0)
|
||||
state.trailingHighSurrogate =
|
||||
finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff ? finalCharacter : ''
|
||||
if (!containsStrongRtlText(scanText)) {
|
||||
// Why: PTY/replay chunks can split a supplementary-plane RTL code point or an Indic cluster; keep the boundary tail.
|
||||
const scanText = state.trailingChunkTail + text
|
||||
state.trailingChunkTail = getChunkTail(scanText)
|
||||
if (!containsStrongRtlText(scanText) && findIndicClusterRanges(scanText).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
state.trailingHighSurrogate = ''
|
||||
state.trailingChunkTail = ''
|
||||
state.registrationAttempted = true
|
||||
try {
|
||||
state.cleanup = registerArabicShapingJoiner(terminal, state.isShapingActive)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { findIndicClusterRanges } from './terminal-indic-cluster-ranges'
|
||||
|
||||
describe('findIndicClusterRanges', () => {
|
||||
it('returns no ranges for ASCII, Latin accents, and Arabic', () => {
|
||||
expect(findIndicClusterRanges('ls -la | grep foo && echo done')).toEqual([])
|
||||
expect(findIndicClusterRanges('café é مرحبا')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns a fresh array on every call so xterm can merge into it safely', () => {
|
||||
findIndicClusterRanges('কি').push([9, 9])
|
||||
expect(findIndicClusterRanges('কি')).toEqual([[0, 2]])
|
||||
})
|
||||
|
||||
it('joins a base letter with its spacing vowel sign', () => {
|
||||
expect(findIndicClusterRanges('কি')).toEqual([[0, 2]])
|
||||
expect(findIndicClusterRanges('কৌ')).toEqual([[0, 2]])
|
||||
})
|
||||
|
||||
it('joins each cluster of a word separately', () => {
|
||||
// আ + মি + দে + খি: lone letters stay per-cell, only multi-cell clusters join.
|
||||
expect(findIndicClusterRanges('আমি দেখি')).toEqual([
|
||||
[1, 3],
|
||||
[4, 6],
|
||||
[6, 8]
|
||||
])
|
||||
})
|
||||
|
||||
it('joins a conjunct through virama chains', () => {
|
||||
expect(findIndicClusterRanges('ক্ষ')).toEqual([[0, 3]])
|
||||
expect(findIndicClusterRanges('ন্ত্র')).toEqual([[0, 5]])
|
||||
})
|
||||
|
||||
it('joins a conjunct together with a trailing vowel sign', () => {
|
||||
// অ + ন্যে + র
|
||||
expect(findIndicClusterRanges('অন্যের')).toEqual([[1, 5]])
|
||||
})
|
||||
|
||||
it('joins Devanagari clusters', () => {
|
||||
expect(findIndicClusterRanges('हिन्दी')).toEqual([
|
||||
[0, 2],
|
||||
[2, 6]
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves nonspacing-only clusters in their single base cell', () => {
|
||||
expect(findIndicClusterRanges('কু')).toEqual([])
|
||||
expect(findIndicClusterRanges('চাঁদ')).toEqual([[0, 3]])
|
||||
})
|
||||
|
||||
it('does not join a virama with nothing after it', () => {
|
||||
expect(findIndicClusterRanges('ক্')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps ASCII and box-drawing neighbours out of the ranges', () => {
|
||||
expect(findIndicClusterRanges('│ কি abc │')).toEqual([[2, 4]])
|
||||
})
|
||||
|
||||
it('joins a Sinhala conjunct written with virama + ZWJ', () => {
|
||||
expect(findIndicClusterRanges('ක්\u200Dර')).toEqual([[0, 4]])
|
||||
})
|
||||
|
||||
it('does not join across a ZWNJ-blocked conjunct', () => {
|
||||
expect(findIndicClusterRanges('ক্\u200Cষ')).toEqual([])
|
||||
})
|
||||
|
||||
it('does not let a bare ZWJ open a conjunct', () => {
|
||||
expect(findIndicClusterRanges('ক\u200Dখ')).toEqual([])
|
||||
})
|
||||
|
||||
it('ends a Malayalam chillu cluster instead of joining the next letter', () => {
|
||||
expect(findIndicClusterRanges('ന്\u200Dക')).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves Tamil pulli chains per-cell', () => {
|
||||
expect(findIndicClusterRanges('க்க்க்')).toEqual([])
|
||||
})
|
||||
|
||||
it('still joins a Tamil spacing vowel sign', () => {
|
||||
expect(findIndicClusterRanges('கொ')).toEqual([[0, 2]])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
// Why: xterm gives each Brahmic spacing vowel sign (U+09BF, U+09CB, …) its own cell, matching
|
||||
// wcwidth/string-width, so TUIs align on that count. WebGL then draws each cell in isolation and the
|
||||
// font never reorders or composes the cluster. Joining the cluster's cells lets the browser shape it
|
||||
// without changing the cell count.
|
||||
|
||||
const INDIC_SCAN_FLOOR = 0x0900
|
||||
const INDIC_SCAN_CEILING = 0x0dff
|
||||
|
||||
// Why: Tamil pulli (U+0BCD) is omitted because it renders as a visible dot, so Tamil never forms conjuncts.
|
||||
const VIRAMAS = '\\u094D\\u09CD\\u0A4D\\u0ACD\\u0B4D\\u0C4D\\u0CCD\\u0D4D\\u0DCA'
|
||||
|
||||
// Base letter, then marks/joiners, or a letter that follows a virama (+ optional ZWJ) as a conjunct.
|
||||
// Malayalam virama + ZWJ is a legacy chillu, which ends its cluster instead of joining the next letter.
|
||||
const INDIC_CLUSTER = new RegExp(
|
||||
`(?=\\p{L})[\\u0900-\\u0DFF](?:[\\p{M}\\u200C\\u200D]|(?<=[${VIRAMAS}]\\u200D?)(?<!\\u0D4D\\u200D)(?=\\p{L})[\\u0900-\\u0DFF])*`,
|
||||
'gu'
|
||||
)
|
||||
// Mn-only clusters (e.g. কু) already render in their single base cell.
|
||||
const SPANS_MULTIPLE_CELLS = new RegExp(`\\p{Mc}|[${VIRAMAS}]\\u200D?\\p{L}`, 'u')
|
||||
|
||||
export function containsIndicScriptText(text: string): boolean {
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const unit = text.charCodeAt(index)
|
||||
if (unit >= INDIC_SCAN_FLOOR && unit <= INDIC_SCAN_CEILING) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Character-joiner handler: [start, end) ranges of Indic clusters that occupy more than one cell. */
|
||||
export function findIndicClusterRanges(text: string): [number, number][] {
|
||||
// Why: xterm merges other joiners' results into this array in place, so it must be freshly allocated each call.
|
||||
const ranges: [number, number][] = []
|
||||
if (!containsIndicScriptText(text)) {
|
||||
return ranges
|
||||
}
|
||||
for (const match of text.matchAll(INDIC_CLUSTER)) {
|
||||
if (match[0].length > 1 && SPANS_MULTIPLE_CELLS.test(match[0])) {
|
||||
ranges.push([match.index, match.index + match[0].length])
|
||||
}
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
Reference in New Issue
Block a user