From c30a59a9ed9143d8ca428f12e87ee7cb9b6620e3 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 9 May 2026 13:37:28 +0800 Subject: [PATCH] fix(epub): accept EPUBs with malformed first ZIP local file header (#4103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/__tests__/libs/document.test.ts | 50 +++++++++++++++++++ apps/readest-app/src/libs/document.ts | 6 ++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/src/__tests__/libs/document.test.ts diff --git a/apps/readest-app/src/__tests__/libs/document.test.ts b/apps/readest-app/src/__tests__/libs/document.test.ts new file mode 100644 index 00000000..adb193f6 --- /dev/null +++ b/apps/readest-app/src/__tests__/libs/document.test.ts @@ -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)['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); +}); diff --git a/apps/readest-app/src/libs/document.ts b/apps/readest-app/src/libs/document.ts index 0475e022..acdab6c8 100644 --- a/apps/readest-app/src/libs/document.ts +++ b/apps/readest-app/src/libs/document.ts @@ -125,7 +125,11 @@ export class DocumentLoader { private async isZip(): Promise { 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 {