From 9f1ffaae72b02f238d1693302490f605caf2f4e1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:31:13 -0700 Subject: [PATCH] fix(terminal): treat zero-width Cf controls in RTL blocks as run-transparent Round-3 review finding: zero-width format controls that live inside the strong-RTL scan ranges (Arabic number signs U+0600-0605, end of ayah U+06DD, Syriac abbreviation mark U+070F, disputed end of ayah U+08E2) and the BOM/ ZWNBSP U+FEFF are width-0 in xterm. Like combining marks they could open a run mid-cell, producing an empty joined cell range that blanks the following glyph in the WebGL renderer. They are Cf not Mn, so canOpenRtlRun could not catch them. Fold them into the run-transparent set (never open, count, or break a run) and document the upstream standalone-width-0-cell join skew. Co-authored-by: Orca --- .../terminal-arabic-shaping-joiner.test.ts | 19 ++++++++++++++++++ .../terminal-arabic-shaping-joiner.ts | 20 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts index c6fb75d8e08..6bed3d7c01d 100644 --- a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts @@ -177,6 +177,25 @@ describe('findRtlJoinRanges', () => { const text = 'منَّ هنا' expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) }) + + it('does not open a run at zero-width Cf controls after an LTR base', () => { + // BOM/ZWNBSP and Arabic number signs are width-0 in xterm, so like + // combining marks a run opened there maps to an empty joined cell range + // that blanks the following glyph in WebGL. + const BOM = '\ufeff' + expect(findRtlJoinRanges(`a${BOM}${BOM}b`)).toEqual([]) + expect(findRtlJoinRanges('x\u0600\u0602y')).toEqual([]) + expect(findRtlJoinRanges(`a${BOM}\u0651b`)).toEqual([]) + }) + + it('keeps zero-width Cf controls from opening or counting an RTL run', () => { + const BOM = '\ufeff' + // A single letter behind a BOM stays isolated (BOM neither opens nor counts). + expect(findRtlJoinRanges(`${BOM}م`)).toEqual([]) + // BOM inside a word does not break the run. + const text = `مرحبا${BOM}بكم` + expect(findRtlJoinRanges(text)).toEqual([[0, text.length]]) + }) }) describe('registerArabicShapingJoiner', () => { diff --git a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts index d24d4a72f27..1749fe4aa63 100644 --- a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts +++ b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts @@ -50,9 +50,22 @@ function isRunNeutralCharCode(charCode: number): boolean { // into two joined chunks laid out in swapped visual order. Transparent: never // opens, closes, extends, or counts toward a run. LRM (U+200E) is strong LTR // and intentionally still breaks the run. +// The zero-width Cf controls inside the RTL blocks (Arabic number signs +// U+0600–0605, end of ayah U+06DD, Syriac abbreviation mark U+070F, disputed +// end of ayah U+08E2) and BOM/ZWNBSP U+FEFF are width-0 in xterm, so like +// combining marks they must never open or count a run (an empty joined cell +// range blanks the following glyph in WebGL) — and they have no shape to join. function isRtlRunTransparentCodePoint(codePoint: number): boolean { return ( - codePoint === 0x200c || codePoint === 0x200d || codePoint === 0x200f || codePoint === 0x061c + codePoint === 0x200c || + codePoint === 0x200d || + codePoint === 0x200f || + codePoint === 0x061c || + (codePoint >= 0x0600 && codePoint <= 0x0605) || + codePoint === 0x06dd || + codePoint === 0x070f || + codePoint === 0x08e2 || + codePoint === 0xfeff ) } @@ -74,6 +87,11 @@ function canOpenRtlRun(codePoint: number): boolean { * cluster, tunneling through neutral characters between RTL words. Runs with * fewer than two RTL code points are skipped: an isolated Arabic letter * already renders in its correct (isolated) form cell-by-cell. + * + * Known upstream limitation (affects every joiner, ligatures too): a + * standalone width-0 cell (e.g. RLM at line start) makes xterm's + * CharacterJoinerService skip the cell without advancing its string index, + * shifting the run's cell range by one — fix belongs upstream in xterm.js. */ export function findRtlJoinRanges(text: string): [number, number][] { const length = text.length