fix(rsvp): unicode-aware ORP calculation for non-Latin scripts, closes #3958 (#3964)

JavaScript's `\w` only matches ASCII, so the ORP letter-count regex stripped
all characters from Cyrillic (and other non-Latin) words, producing length 0
and always highlighting the first letter. Use `\p{L}\p{N}_` with the `u` flag
so letters from any script count toward the word length.
This commit is contained in:
Huang Xin
2026-04-27 13:00:23 +08:00
committed by GitHub
parent 1527dd9b3a
commit aa60123d38
2 changed files with 44 additions and 1 deletions
@@ -117,6 +117,49 @@ describe('RSVPController', () => {
});
});
describe('ORP calculation', () => {
test('places ORP near the start of short Latin words', () => {
const doc = makeDoc('Hello world');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
const words = controller.currentState.words;
// 5-letter words: ORP at index 1
expect(words[0]!.orpIndex).toBe(1);
expect(words[1]!.orpIndex).toBe(1);
});
test('places ORP based on letter count for Cyrillic words', () => {
// "Привет" = 6 letters, "мир" = 3 letters
const doc = makeDoc('Привет мир');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
const words = controller.currentState.words;
expect(words[0]!.text).toBe('Привет');
// 6-letter word should have ORP at index 2 (same as Latin "Hellos")
expect(words[0]!.orpIndex).toBe(2);
expect(words[1]!.text).toBe('мир');
// 3-letter word: ORP at index 0
expect(words[1]!.orpIndex).toBe(0);
});
test('places ORP based on letter count for accented Latin words', () => {
// "naïve" = 5 letters with combining/precomposed diacritic
const doc = makeDoc('naïve');
const view = createMockView(0, [doc]);
const controller = new RSVPController(view, 'test-book-abc123');
controller.start();
const words = controller.currentState.words;
expect(words[0]!.text).toBe('naïve');
// Should be treated as a 5-letter word, ORP at index 1
expect(words[0]!.orpIndex).toBe(1);
});
});
describe('duplicate word blank insertion', () => {
test('inserts blank between two consecutive identical words', () => {
const doc = makeDoc('the the cat');
@@ -738,7 +738,7 @@ export class RSVPController extends EventTarget {
return Math.floor(word.length / 2);
}
const cleanWord = word.replace(/[^\w]/g, '');
const cleanWord = word.replace(/[^\p{L}\p{N}_]/gu, '');
const len = cleanWord.length;
if (len <= 1) return 0;