From 234ecc31132498c075eb610a3cfb773a242ee1d6 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 29 Apr 2026 03:10:10 +0800 Subject: [PATCH] fix(epub): fall back to case-insensitive zip lookups (#3991) Try an exact ZIP entry match first, then fall back to a case-insensitive lookup for EPUB resources when archive entry casing differs from manifest paths. Keep ambiguous case-only duplicates exact-match only, and add a regression test that opens a real EPUB through DocumentLoader. --- .../document-loader-epub-case.test.ts | 64 +++++++++++++++++++ apps/readest-app/src/libs/document.ts | 19 +++++- 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 apps/readest-app/src/__tests__/document/document-loader-epub-case.test.ts diff --git a/apps/readest-app/src/__tests__/document/document-loader-epub-case.test.ts b/apps/readest-app/src/__tests__/document/document-loader-epub-case.test.ts new file mode 100644 index 00000000..bc4baf54 --- /dev/null +++ b/apps/readest-app/src/__tests__/document/document-loader-epub-case.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { DocumentLoader } from '@/libs/document'; + +const createCaseMismatchEpub = async () => { + const { ZipWriter, BlobWriter, TextReader } = await import('@zip.js/zip.js'); + const writer = new ZipWriter(new BlobWriter('application/epub+zip')); + + await writer.add('mimetype', new TextReader('application/epub+zip'), { level: 0 }); + await writer.add( + 'META-INF/container.xml', + new TextReader(` + + + + +`), + ); + await writer.add( + 'OPS/content.opf', + new TextReader(` + + + case-mismatch + Case mismatch + en + + + + + + + +`), + ); + await writer.add( + 'OPS/text/chapter1.xhtml', + new TextReader(` + + + Case mismatch + + +

Hello from the lowercase chapter entry.

+ +`), + ); + + const blob = await writer.close(); + return new File([blob], 'case-mismatch.epub', { type: 'application/epub+zip' }); +}; + +describe('DocumentLoader EPUB zip lookup', () => { + it('loads EPUB resources when manifest href casing differs from the zip entry', async () => { + const file = await createCaseMismatchEpub(); + const loader = new DocumentLoader(file); + + const { book, format } = await loader.open(); + expect(format).toBe('EPUB'); + + const doc = await book.sections[0]!.createDocument(); + expect(doc.body.textContent).toContain('Hello from the lowercase chapter entry.'); + }); +}); diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts index 4111fdfc..0475e022 100644 --- a/apps/readest-app/src/libs/document.ts +++ b/apps/readest-app/src/libs/document.ts @@ -167,10 +167,23 @@ export class DocumentLoader { const reader = new ZipReader(new BlobReader(this.file)); const entries = await reader.getEntries(); const map = new Map(entries.map((entry) => [entry.filename, entry])); + const lowercaseMap = new Map(); + for (const entry of entries) { + const lowercaseName = entry.filename.toLowerCase(); + const existing = lowercaseMap.get(lowercaseName); + lowercaseMap.set( + lowercaseName, + existing && existing.filename !== entry.filename ? null : entry, + ); + } + const getEntry = (name: string) => + map.get(name) ?? lowercaseMap.get(name.toLowerCase()) ?? null; const load = (f: (entry: Entry, type?: string) => Promise | null) => - (name: string, ...args: [string?]) => - map.has(name) ? f(map.get(name)!, ...args) : null; + (name: string, ...args: [string?]) => { + const entry = getEntry(name); + return entry ? f(entry, ...args) : null; + }; const loadText = load((entry: Entry) => !entry.directory ? entry.getData(new TextWriter()) : null, @@ -178,7 +191,7 @@ export class DocumentLoader { const loadBlob = load((entry: Entry, type?: string) => !entry.directory ? entry.getData(new BlobWriter(type!)) : null, ); - const getSize = (name: string) => map.get(name)?.uncompressedSize ?? 0; + const getSize = (name: string) => getEntry(name)?.uncompressedSize ?? 0; return { entries, loadText, loadBlob, getSize, getComment, sha1: undefined }; }