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.
This commit is contained in:
Huang Xin
2026-04-29 03:10:10 +08:00
committed by GitHub
parent dab92c8a46
commit 234ecc3113
2 changed files with 80 additions and 3 deletions
@@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`),
);
await writer.add(
'OPS/content.opf',
new TextReader(`<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" unique-identifier="bookid" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="bookid">case-mismatch</dc:identifier>
<dc:title>Case mismatch</dc:title>
<dc:language>en</dc:language>
</metadata>
<manifest>
<item id="chapter1" href="Text/Chapter1.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine>
<itemref idref="chapter1"/>
</spine>
</package>`),
);
await writer.add(
'OPS/text/chapter1.xhtml',
new TextReader(`<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>Case mismatch</title>
</head>
<body>
<p>Hello from the lowercase chapter entry.</p>
</body>
</html>`),
);
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.');
});
});
+16 -3
View File
@@ -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<string, Entry | null>();
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<string | Blob> | 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 };
}