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 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-03-10 02:00:44 +08:00
committed by GitHub
parent fad27d74a0
commit 5646e0cfb5
2 changed files with 74 additions and 0 deletions
@@ -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<Uint8Array>({
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(
+20
View File
@@ -453,6 +453,26 @@ export class RemoteFile extends File implements ClosableFile {
return new DeferredBlob(dataPromise, contentType);
}
override stream(): ReadableStream<Uint8Array<ArrayBuffer>> {
const CHUNK_SIZE = 1024 * 1024;
let offset = 0;
return new ReadableStream<Uint8Array<ArrayBuffer>>({
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();