diff --git a/apps/readest-app/src/__tests__/utils/txt.test.ts b/apps/readest-app/src/__tests__/utils/txt.test.ts index 84cca16f..72a4a9d7 100644 --- a/apps/readest-app/src/__tests__/utils/txt.test.ts +++ b/apps/readest-app/src/__tests__/utils/txt.test.ts @@ -148,6 +148,41 @@ describe('createChapterRegexps — Chinese (zh) regex matching', () => { }); }); + // Measure-word phrases such as 第一封信 ("the first letter") and 第四本书 + // ("the fourth book") are running prose, not chapter headings. The volume + // measure words 卷/本/册/部/封 only start a heading when followed by a + // separator or the line end — not when a noun follows them directly. See + // issue #4658. + describe('measure-word false positives (issue #4658)', () => { + it.each([ + '第一封信', + '第一封信。', + '第七封信', + '第四本书记载着锤法', + '第四本书记载着锤法,来自孙家,可惜对秦铭用处不大了,他已经彻底掌握。', + '第三本书', + '第十部手机', + '第一册书', + '第二卷书卷', + ])('should NOT match measure-word prose "%s"', (line) => { + const regex = getFirstRegex('zh'); + expect(regex.test(`\n${line}\n`)).toBe(false); + }); + + it.each([ + '第一封', + '第一封 致读者', + '第一本', + '第一本 标题', + '第二部 中篇', + '第一册 上', + '第一卷 起始篇', + ])('should still match a real heading "%s"', (line) => { + const regex = getFirstRegex('zh'); + expect(regex.test(`\n${line}\n`)).toBe(true); + }); + }); + describe('should not match', () => { it('should not match chapter heading embedded mid-line', () => { const regex = getFirstRegex('zh'); @@ -387,6 +422,35 @@ describe('extractChaptersFromSegment — Chinese (zh)', () => { expect(chapters[0]!.content).toContain('前文'); }); + it('should not extract 第N封信 letter markers as chapters (issue #4658)', () => { + const text = [ + '第一百八十九章 许七安的七封信', + '驿卒将信递了过来。', + '第一封信', + '写这封信的时候我正在云州。', + '第二封信', + '临安公主亲启。', + ].join('\n'); + const chapters = extractChapters(text, 'zh'); + const titles = chapters.map((c) => c.title); + expect(titles).toEqual(['第一百八十九章 许七安的七封信']); + expect(chapters[0]!.content).toContain('第一封信'); + expect(chapters[0]!.content).toContain('第二封信'); + }); + + it('should not extract 第N本书 + sentence as a chapter (issue #4658)', () => { + const text = [ + '第八十四章 比异人强一点', + '他翻阅着那几本秘籍。', + '第四本书记载着锤法,来自孙家,可惜对秦铭用处不大了,他已经彻底掌握。', + '日子一天天过去。', + ].join('\n'); + const chapters = extractChapters(text, 'zh'); + const titles = chapters.map((c) => c.title); + expect(titles).toEqual(['第八十四章 比异人强一点']); + expect(chapters[0]!.content).toContain('第四本书记载着锤法'); + }); + it('should extract 番外 chapters mixed with regular chapters (issue #4016)', () => { const text = [ '第57章 s级冰系掌控', diff --git a/apps/readest-app/src/utils/txt.ts b/apps/readest-app/src/utils/txt.ts index a2db46b2..5a543f56 100644 --- a/apps/readest-app/src/utils/txt.ts +++ b/apps/readest-app/src/utils/txt.ts @@ -719,15 +719,28 @@ export class TxtToEpubConverter { const chapterRegexps: RegExp[] = []; if (language === 'zh') { + // 第N + unit, expressed as two explicit tiers that share the 第N prefix and + // the trailing boundary. They stay in ONE alternation (and so one split + // pass) on purpose: a segment often mixes chapter and volume headings (a + // volume wraps chapters), and the regexps array is a fallback chain — the + // first regex that splits "well enough" wins — so separate entries would + // recognize one tier and silently drop the other. + const cjkNumber = '第[  零〇一二三四五六七八九十0-9][  零〇一二三四五六七八九十百千万0-9]*'; + // Tier 1 — chapter units. Real headings; a title may attach directly + // (第一章天地初开) or after a separator. + const chapterUnit = String.raw`[章节回讲篇话](?:[::、  \(\)0-9]*[^\n-]{0,36})`; + // Tier 2 — volume/measure-word units. These double as 量词 in prose + // (第一封信 "the first letter", 第四本书 "the fourth book"), so a title only + // counts when introduced by a separator (::、, space, parens) or the line + // ends — never a bare noun directly after the unit. See issue #4658. + const volumeUnit = String.raw`[卷本册部封](?:[::、  \(\)][::、  \(\)0-9]*[^\n-]{0,36})?`; + const numberedHeading = String.raw`${cjkNumber}(?:${chapterUnit}|${volumeUnit})(?!\S)`; + const prefaceHeading = String.raw`(?:楔子|前言|简介|引言|序言|序章|总论|概论|后记|番外篇|番外|外传)(?:[::  ][^\n-]{0,36})?(?!\S)`; + const englishHeading = String.raw`chapter[\s.]*[0-9]+(?:[::.  ]+[^\n-]{0,50})?(?!\S)`; chapterRegexps.push( new RegExp( - String.raw`(?:^|\n)\s*` + - '(' + - [ - String.raw`第[  零〇一二三四五六七八九十0-9][  零〇一二三四五六七八九十百千万0-9]*(?:[章卷节回讲篇封本册部话])(?:[::、  \(\)0-9]*[^\n-]{0,36})(?!\S)`, - String.raw`(?:楔子|前言|简介|引言|序言|序章|总论|概论|后记|番外篇|番外|外传)(?:[::  ][^\n-]{0,36})?(?!\S)`, - String.raw`chapter[\s.]*[0-9]+(?:[::.  ]+[^\n-]{0,50})?(?!\S)`, - ].join('|') + + String.raw`(?:^|\n)\s*(` + + [numberedHeading, prefaceHeading, englishHeading].join('|') + ')', 'gui', ),