fix(txt): merge scene-break sections into the preceding chapter (#4063) (#4207)

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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-18 00:20:55 +08:00
committed by GitHub
parent 83607d14ea
commit 1d4b7eed87
3 changed files with 109 additions and 4 deletions
@@ -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('<h3>');
});
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', () => { describe('extractTxtFilenameMetadata', () => {
it('extracts the title from CJK 《》 brackets', () => { it('extracts the title from CJK 《》 brackets', () => {
expect(extractTxtFilenameMetadata('《三体》.txt')).toEqual({ title: '三体' }); expect(extractTxtFilenameMetadata('《三体》.txt')).toEqual({ title: '三体' });
+28 -3
View File
@@ -54,6 +54,11 @@ interface Chapter {
title: string; title: string;
content: string; content: string;
isVolume: boolean; 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 { interface Txt2EpubOptions {
@@ -242,12 +247,30 @@ export class TxtToEpubConverter {
option, option,
chapters.length, chapters.length,
); );
chapters.push(...segmentChapters); this.appendSegmentChapters(chapters, segmentChapters);
} }
return chapters; 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(/^<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/, '');
} else {
chapters.push(chapter);
}
}
}
private probeChapterCount( private probeChapterCount(
txtContent: string, txtContent: string,
metadata: Metadata, metadata: Metadata,
@@ -286,7 +309,7 @@ export class TxtToEpubConverter {
option, option,
chapters.length, chapters.length,
); );
chapters.push(...segmentChapters); this.appendSegmentChapters(chapters, segmentChapters);
} }
return chapters; return chapters;
} }
@@ -545,7 +568,7 @@ export class TxtToEpubConverter {
const formattedSegment = this.formatSegment(chunks.join('\n')); const formattedSegment = this.formatSegment(chunks.join('\n'));
const title = `${chapterOffset + chapters.length + 1}`; const title = `${chapterOffset + chapters.length + 1}`;
const content = `<h2>${title}</h2><p>${formattedSegment}</p>`; const content = `<h2>${title}</h2><p>${formattedSegment}</p>`;
chapters.push({ title, content, isVolume: false }); chapters.push({ title, content, isVolume: false, detected: false });
} }
return chapters; return chapters;
} }
@@ -568,6 +591,7 @@ export class TxtToEpubConverter {
title: escapeXml(title), title: escapeXml(title),
content: `${headTitle}<p>${formattedSegment}</p>`, content: `${headTitle}<p>${formattedSegment}</p>`,
isVolume, isVolume,
detected: true,
}); });
} }
@@ -582,6 +606,7 @@ export class TxtToEpubConverter {
title: escapeXml(segmentTitle), title: escapeXml(segmentTitle),
content: `<h3></h3><p>${formattedSegment}</p>`, content: `<h3></h3><p>${formattedSegment}</p>`,
isVolume: false, isVolume: false,
detected: false,
}); });
} }