From 5646e0cfb5e9f37188e44a579a489f070d4b4bed Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 10 Mar 2026 02:00:44 +0800 Subject: [PATCH] fix(txt): add stream() to RemoteFile to fix large TXT import on desktop, closes #3495 (#3502) RemoteFile (used on all desktop platforms) extended File([]) with empty data and overrode slice(), text(), arrayBuffer() but not stream(). The large file path (>8MB) introduced in #3320 uses file.stream() to read content incrementally, which returned empty data from the base File class, causing "No chapters detected" for large TXT files. Co-authored-by: Claude Opus 4.6 --- .../src/__tests__/utils/txt-converter.test.ts | 54 +++++++++++++++++++ apps/readest-app/src/utils/file.ts | 20 +++++++ 2 files changed, 74 insertions(+) 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 7f6f1d24..9a54e555 100644 --- a/apps/readest-app/src/__tests__/utils/txt-converter.test.ts +++ b/apps/readest-app/src/__tests__/utils/txt-converter.test.ts @@ -260,6 +260,60 @@ describe('TxtToEpubConverter', () => { expect(result.chapterCount).toBe(2); }); + it('convert large file should work when stream() is built from slice() like RemoteFile', async () => { + const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI; + const content = '第一章 开始\n这是第一章的内容。\n\n第二章 继续\n这是第二章的内容。'; + const backingBlob = new Blob([content]); + + // Simulate a fixed RemoteFile: stream() reads data via slice(), not from base File([]) + const fixedFile = new File([], 'large.txt'); + const fileSize = 9 * 1024 * 1024; + Object.defineProperty(fixedFile, 'size', { value: fileSize }); + Object.defineProperty(fixedFile, 'slice', { + value: (start?: number, end?: number) => backingBlob.slice(start, end), + }); + Object.defineProperty(fixedFile, 'stream', { + value: () => { + const CHUNK_SIZE = 1024 * 1024; + let offset = 0; + return new ReadableStream({ + pull: async (controller) => { + if (offset >= fileSize) { + controller.close(); + return; + } + const end = Math.min(offset + CHUNK_SIZE, fileSize); + const buf = await backingBlob.slice(offset, end).arrayBuffer(); + controller.enqueue(new Uint8Array(buf)); + offset = end; + }, + }); + }, + }); + + converter.createEpub = async () => new Blob(); + + const result = await converter.convert({ file: fixedFile }); + expect(result.chapterCount).toBeGreaterThanOrEqual(1); + }); + + it('convert large file should fail when stream() returns empty data (unfixed RemoteFile)', async () => { + const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI; + + // Simulate the bug: RemoteFile with unoverridden stream() returns empty data + const brokenFile = new File([], 'large.txt'); + Object.defineProperty(brokenFile, 'size', { value: 9 * 1024 * 1024 }); + Object.defineProperty(brokenFile, 'slice', { + value: (start?: number, end?: number) => + new Blob(['第一章 开始\n内容\n\n第二章 继续\n内容']).slice(start, end), + }); + // stream() is NOT overridden — inherits base File's empty stream + + converter.createEpub = async () => new Blob(); + + await expect(converter.convert({ file: brokenFile })).rejects.toThrow('No chapters detected'); + }); + it('iterateSegmentsFromFile should cancel stream on early return', async () => { const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI & { iterateSegmentsFromFile( diff --git a/apps/readest-app/src/utils/file.ts b/apps/readest-app/src/utils/file.ts index df2ff85e..8d67af2c 100644 --- a/apps/readest-app/src/utils/file.ts +++ b/apps/readest-app/src/utils/file.ts @@ -453,6 +453,26 @@ export class RemoteFile extends File implements ClosableFile { return new DeferredBlob(dataPromise, contentType); } + override stream(): ReadableStream> { + const CHUNK_SIZE = 1024 * 1024; + let offset = 0; + + return new ReadableStream>({ + pull: async (controller) => { + if (offset >= this.size) { + controller.close(); + return; + } + + const end = Math.min(offset + CHUNK_SIZE, this.size); + const buffer = await this.fetchRange(offset, end - 1); + + controller.enqueue(new Uint8Array(buffer)); + offset = end; + }, + }); + } + override async text() { const blob = this.slice(0, this.size); return blob.text();