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