fix(proofread): support text replacement for text with no word boundaries, closes #2889 (#2894)

This commit is contained in:
Huang Xin
2026-01-09 10:27:38 +01:00
committed by GitHub
parent cfe51d01ee
commit 5eecc735aa
2 changed files with 84 additions and 3 deletions
@@ -209,6 +209,60 @@ describe('proofreadTransformer', () => {
expect(result).toContain('dog');
expect(result).toContain('category'); // Should not replace "cat" in "category"
});
test('should replace CJK characters correctly', async () => {
const rules: ProofreadRule[] = [
{
id: '1',
scope: 'book',
pattern: '猫',
replacement: '狗',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
},
];
const ctx = createMockContext(rules, '<p>我有一只猫。</p>');
const result = await proofreadTransformer.transform(ctx);
expect(result).toContain('我有一只狗。');
expect(result).not.toContain('猫');
});
test('should replace multiple different words correctly', async () => {
const rules: ProofreadRule[] = [
{
id: '1',
scope: 'book',
pattern: '猫',
replacement: '狗',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 1,
wholeWord: true,
},
{
id: '2',
scope: 'book',
pattern: '我',
replacement: '你',
enabled: true,
isRegex: false,
caseSensitive: true,
order: 2,
wholeWord: true,
},
];
const ctx = createMockContext(rules, '<p>我有一只猫。</p>');
const result = await proofreadTransformer.transform(ctx);
expect(result).toContain('你有一只狗。');
expect(result).not.toContain('我');
expect(result).not.toContain('猫');
});
});
describe('regex functionality', () => {
@@ -11,6 +11,29 @@ interface NormalizedPattern {
const isUnicodeWordChar = (char: string): boolean => /[\p{L}\p{N}_]/u.test(char || '');
const hasUnicodeChars = (text: string): boolean => /[^\x00-\x7F]/.test(text);
// Scripts that don't use spaces between words (no word boundaries)
// CJK: Chinese, Japanese, Korean
// Thai, Lao, Khmer, Myanmar, Tibetan
const NO_WORD_BOUNDARY_RANGES = [
'\u4E00-\u9FFF', // CJK Unified Ideographs
'\u3400-\u4DBF', // CJK Unified Ideographs Extension A
'\u3000-\u303F', // CJK Symbols and Punctuation
'\uFF00-\uFFEF', // Halfwidth and Fullwidth Forms
'\u3040-\u309F', // Hiragana
'\u30A0-\u30FF', // Katakana
'\uAC00-\uD7AF', // Korean Hangul Syllables
'\u0E00-\u0E7F', // Thai
'\u0E80-\u0EFF', // Lao
'\u1780-\u17FF', // Khmer
'\u1000-\u109F', // Myanmar
'\u0F00-\u0FFF', // Tibetan
].join('');
const isNoWordBoundaryChar = (char: string): boolean =>
new RegExp(`[${NO_WORD_BOUNDARY_RANGES}]`).test(char || '');
const isPureNoWordBoundaryScript = (text: string): boolean =>
new RegExp(`^[${NO_WORD_BOUNDARY_RANGES}]+$`).test(text);
function normalizePattern(
pattern: string,
isRegex: boolean,
@@ -61,11 +84,15 @@ function isValidMatch(text: string, match: RegExpExecArray, rule: ProofreadRule)
if (!isExactMatch) return false;
}
if (hasUnicodeChars(rule.pattern)) {
// Skip word boundary check for scripts without word boundaries (CJK, Thai, Lao, Khmer, Myanmar, Tibetan)
if (hasUnicodeChars(rule.pattern) && !isPureNoWordBoundaryScript(rule.pattern)) {
const charBefore = text[match.index - 1] ?? '';
const charAfter = text[match.index + match[0].length] ?? '';
if (isUnicodeWordChar(charBefore) || isUnicodeWordChar(charAfter)) {
return false;
// Only check word boundaries if adjacent chars are from scripts that use word boundaries
if (!isNoWordBoundaryChar(charBefore) && !isNoWordBoundaryChar(charAfter)) {
if (isUnicodeWordChar(charBefore) || isUnicodeWordChar(charAfter)) {
return false;
}
}
}