From 1d4b7eed870c90f1d6a7c11b3adf54308a40417c Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 18 May 2026 00:20:55 +0800 Subject: [PATCH] fix(txt): merge scene-break sections into the preceding chapter (#4063) (#4207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TXT-to-EPUB segment regex splits on dash dividers (`-{8,}`), which authors commonly use as in-chapter scene breaks. Each heading-less section after such a divider was emitted as its own chapter — a numbered paragraph fallback chapter, or a chapter titled after a stray sentence — flooding the generated TOC with entries that aren't real chapters. Mark chapters with whether their title came from a detected heading, and merge heading-less chapters into the preceding detected chapter instead of pushing them as separate TOC entries. Fully heading-less text still chunks into numbered fallback chapters as before. Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/__tests__/utils/txt-converter.test.ts | 80 +++++++++++++++++++ apps/readest-app/src/utils/txt.ts | 31 ++++++- packages/foliate-js | 2 +- 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/apps/readest-app/src/__tests__/utils/txt-converter.test.ts b/apps/readest-app/src/__tests__/utils/txt-converter.test.ts index 36897af5..74aa3724 100644 --- a/apps/readest-app/src/__tests__/utils/txt-converter.test.ts +++ b/apps/readest-app/src/__tests__/utils/txt-converter.test.ts @@ -346,6 +346,86 @@ describe('TxtToEpubConverter', () => { }); }); +describe('scene-break dividers do not pollute the TOC (issue #4063)', () => { + const zhMetadata: TestMetadata = { + bookTitle: 'Test', + author: '', + language: 'zh', + identifier: 'test', + }; + const option = { linesBetweenSegments: 8, fallbackParagraphsPerChapter: 100 }; + const divider = '-'.repeat(37); + + it('keeps a single chapter when dash dividers split scene breaks', () => { + const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI; + const text = [ + '第15章', + '这是第十五章的开头内容。', + divider, + '场景切换后的内容继续。', + divider, + '又一个场景的内容。', + ].join('\n'); + + const chapters = converter.extractChapters(text, zhMetadata, option); + + expect(chapters.length).toBe(1); + expect(chapters[0]!.title).toContain('第15章'); + expect(chapters[0]!.content).toContain('场景切换后的内容继续'); + expect(chapters[0]!.content).toContain('又一个场景的内容'); + }); + + it('merges mid-chapter content split off before the next heading', () => { + const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI; + const text = [ + '第1章', + '绿水青山就是金山银山。', + divider, + '分隔符之后继续讲述的内容。', + '第2章', + '新的章节正式开始了。', + ].join('\n'); + + const chapters = converter.extractChapters(text, zhMetadata, option); + + expect(chapters.map((c) => c.title)).toEqual(['第1章', '第2章']); + expect(chapters[0]!.content).toContain('分隔符之后继续讲述的内容'); + expect(chapters[0]!.content).not.toContain('

'); + }); + + it('still chunks heading-less plain text by paragraph fallback', () => { + const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI; + const paragraphs = Array.from({ length: 250 }, (_, i) => `段落${i + 1}`).join('\n'); + + const chapters = converter.extractChapters(paragraphs, zhMetadata, option); + + expect(chapters.length).toBe(3); + }); + + it('merges divider-split scene breaks in the chunked file path', async () => { + const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI; + const text = [ + '第15章', + '这是第十五章的开头内容。', + divider, + '场景切换后的内容继续。', + divider, + '又一个场景的内容。', + ].join('\n'); + const file = new File([text], 'sample.txt'); + + const chapters = await converter.extractChaptersFromFileBySegments( + file, + 'utf-8', + zhMetadata, + option, + ); + + expect(chapters.length).toBe(1); + expect(chapters[0]!.title).toContain('第15章'); + }); +}); + describe('extractTxtFilenameMetadata', () => { it('extracts the title from CJK 《》 brackets', () => { expect(extractTxtFilenameMetadata('《三体》.txt')).toEqual({ title: '三体' }); diff --git a/apps/readest-app/src/utils/txt.ts b/apps/readest-app/src/utils/txt.ts index d03bd80a..5caa6131 100644 --- a/apps/readest-app/src/utils/txt.ts +++ b/apps/readest-app/src/utils/txt.ts @@ -54,6 +54,11 @@ interface Chapter { title: string; content: string; isVolume: boolean; + // True when the title came from a detected chapter heading. Chapters whose + // content was not found under a heading (paragraph fallback, or stray text + // split off by the segment regex) are merged into the preceding detected + // chapter instead of becoming bogus TOC entries. See issue #4063. + detected?: boolean; } interface Txt2EpubOptions { @@ -242,12 +247,30 @@ export class TxtToEpubConverter { option, chapters.length, ); - chapters.push(...segmentChapters); + this.appendSegmentChapters(chapters, segmentChapters); } return chapters; } + /** + * Append a segment's chapters to the running list. The segment regex also + * splits on dash dividers, which authors frequently use as in-chapter scene + * breaks; the content after such a divider has no heading of its own. When a + * heading-less chapter follows a detected chapter, merge its content into + * that chapter instead of emitting a separate (bogus) TOC entry. See #4063. + */ + private appendSegmentChapters(chapters: Chapter[], segmentChapters: Chapter[]): void { + for (const chapter of segmentChapters) { + const previous = chapters[chapters.length - 1]; + if (!chapter.detected && previous?.detected) { + previous.content += chapter.content.replace(/^]*>[\s\S]*?<\/h[1-6]>/, ''); + } else { + chapters.push(chapter); + } + } + } + private probeChapterCount( txtContent: string, metadata: Metadata, @@ -286,7 +309,7 @@ export class TxtToEpubConverter { option, chapters.length, ); - chapters.push(...segmentChapters); + this.appendSegmentChapters(chapters, segmentChapters); } return chapters; } @@ -545,7 +568,7 @@ export class TxtToEpubConverter { const formattedSegment = this.formatSegment(chunks.join('\n')); const title = `${chapterOffset + chapters.length + 1}`; const content = `

${title}

${formattedSegment}

`; - chapters.push({ title, content, isVolume: false }); + chapters.push({ title, content, isVolume: false, detected: false }); } return chapters; } @@ -568,6 +591,7 @@ export class TxtToEpubConverter { title: escapeXml(title), content: `${headTitle}

${formattedSegment}

`, isVolume, + detected: true, }); } @@ -582,6 +606,7 @@ export class TxtToEpubConverter { title: escapeXml(segmentTitle), content: `

${formattedSegment}

`, isVolume: false, + detected: false, }); } diff --git a/packages/foliate-js b/packages/foliate-js index 4361f29b..3c597a6d 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 4361f29b5a23c4593cf32e781a37b0f3ef910e91 +Subproject commit 3c597a6dc5632b157cebd363e03f8ae33df516a1