fix(epub): accept EPUBs with malformed first ZIP local file header (#4103)

Some EPUB writers (e.g., "ebookredo") emit a non-standard local file
header signature on the first entry — bytes like PK\x03\x02 instead of
PK\x03\x04. The archive is still readable via the End of Central
Directory record, and @zip.js/zip.js handles it without complaint, but
DocumentLoader.isZip() rejected the file at the magic-bytes gate before
zip.js ever ran. The user saw "Unsupported or corrupted book file" on
a perfectly readable EPUB.

Drop the strict 4th-byte equality check. PK\x03 alone identifies a
local file header — no other ZIP record signature starts that way — so
loosening the check is safe and aligns with what tolerant ZIP parsers
already do.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-09 13:37:28 +08:00
committed by GitHub
parent ae42dcb53a
commit c30a59a9ed
2 changed files with 55 additions and 1 deletions
@@ -0,0 +1,50 @@
import { describe, it, expect, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { DocumentLoader } from '@/libs/document';
if (typeof globalThis['CSS'] === 'undefined') {
(globalThis as Record<string, unknown>)['CSS'] = {
escape: (s: string) => s.replace(/([^\w-])/g, '\\$1'),
};
}
if (!customElements.get('foliate-paginator')) {
customElements.define(
'foliate-paginator',
class extends HTMLElement {
override setAttribute() {}
override addEventListener() {}
open() {}
},
);
}
vi.mock('foliate-js/paginator.js', () => ({}));
const loadFixtureBytes = (name: string): Uint8Array => {
const epubPath = resolve(__dirname, `../fixtures/data/${name}`);
return new Uint8Array(readFileSync(epubPath));
};
describe('DocumentLoader.open', () => {
it('opens an EPUB whose first local file header has a non-standard signature byte', async () => {
// Some EPUB writers in the wild produce a malformed first local file header
// signature - PK\x03\x02 instead of the spec-mandated PK\x03\x04.
// The archive is otherwise valid: zip.js reads every entry via the central
// directory at the end of the file. We must not reject it at the magic-bytes gate.
const bytes = loadFixtureBytes('repro-3688.epub');
expect([bytes[0], bytes[1], bytes[2], bytes[3]]).toEqual([0x50, 0x4b, 0x03, 0x04]);
const malformed = bytes.slice();
malformed[3] = 0x02;
const file = new File([malformed], 'malformed-header.epub', {
type: 'application/epub+zip',
});
const result = await new DocumentLoader(file).open();
expect(result.book).toBeTruthy();
expect(result.format).toBe('EPUB');
}, 15000);
});
+5 -1
View File
@@ -125,7 +125,11 @@ export class DocumentLoader {
private async isZip(): Promise<boolean> {
const arr = new Uint8Array(await this.file.slice(0, 4).arrayBuffer());
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03 && arr[3] === 0x04;
// Standard local file header signature is PK\x03\x04, but some non-conformant
// EPUB writers emit malformed bytes (e.g., PK\x03\x02) on the first entry.
// The archive is still readable via the central directory, so don't gate on
// the 4th byte. PK\x03 alone is enough to identify a local file header.
return arr[0] === 0x50 && arr[1] === 0x4b && arr[2] === 0x03;
}
private async isPDF(): Promise<boolean> {