diff --git a/src/mime.js b/src/mime.js index ed99c7d..5ef97bc 100644 --- a/src/mime.js +++ b/src/mime.js @@ -22,6 +22,15 @@ const OUTPUT_MIME_TYPES = { ttf: 'font/ttf', otf: 'font/otf', woff: 'font/woff', + epub: 'application/epub+zip', + fb2: 'application/x-fictionbook+xml', + lrf: 'application/octet-stream', + mobi: 'application/x-mobipocket-ebook', + pdb: 'application/vnd.palm', + rb: 'application/octet-stream', + snb: 'application/octet-stream', + tcr: 'application/octet-stream', + azw3: 'application/vnd.amazon.ebook', }; export async function detectInputMime(filePath) { @@ -44,9 +53,19 @@ const UNDETECTABLE_TEXT_FORMATS = { csv: 'text/csv', }; +const UNDETECTABLE_EBOOK_FORMATS = { + fb2: 'application/x-fictionbook+xml', + lrf: 'application/octet-stream', + pdb: 'application/vnd.palm', + rb: 'application/octet-stream', + snb: 'application/octet-stream', + tcr: 'application/octet-stream', +}; + function normalizeFormat(format) { if (format === 'jpg') return 'jpeg'; if (format === 'heif') return 'heic'; + if (format === 'azw3') return 'mobi'; return format; } @@ -61,7 +80,7 @@ export async function resolveInputFormat(filePath, declaredFormat) { const detected = await detectInputMime(filePath); if (!detected) { - const fallbackMime = UNDETECTABLE_TEXT_FORMATS[declaredFormat]; + const fallbackMime = UNDETECTABLE_TEXT_FORMATS[declaredFormat] ?? UNDETECTABLE_EBOOK_FORMATS[declaredFormat]; if (fallbackMime) { return { mime: fallbackMime, valid: true }; } diff --git a/test/mime.test.js b/test/mime.test.js index f46c121..e51f32a 100644 --- a/test/mime.test.js +++ b/test/mime.test.js @@ -113,6 +113,85 @@ describe('outputMimeType — fonts', () => { }); }); +describe('outputMimeType — ebooks', () => { + it('returns the correct MIME type for each ebook target format', () => { + expect(outputMimeType('epub')).toBe('application/epub+zip'); + expect(outputMimeType('fb2')).toBe('application/x-fictionbook+xml'); + expect(outputMimeType('mobi')).toBe('application/x-mobipocket-ebook'); + expect(outputMimeType('azw3')).toBe('application/vnd.amazon.ebook'); + expect(outputMimeType('pdb')).toBe('application/vnd.palm'); + expect(outputMimeType('lrf')).toBe('application/octet-stream'); + expect(outputMimeType('rb')).toBe('application/octet-stream'); + expect(outputMimeType('snb')).toBe('application/octet-stream'); + expect(outputMimeType('tcr')).toBe('application/octet-stream'); + }); +}); + +describe('resolveInputFormat — azw3/mobi collision', () => { + function buildMobiFamilyBuffer() { + // PDB header: 8-byte "BOOKMOBI" type+creator magic at offset 60, shared by both + // real .mobi and real .azw3 files (confirmed in file-type's source). + const buffer = Buffer.alloc(68); + buffer.write('BOOKMOBI', 60, 'ascii'); + return buffer; + } + + it('confirms file-type reports mobi-family content as ext "mobi" regardless of which of the two formats it is (documents the collision this fix works around)', async () => { + const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-mobi-family.bin'); + await fs.writeFile(fixturePath, buildMobiFamilyBuffer()); + + const detected = await detectInputMime(fixturePath); + expect(detected).toEqual({ ext: 'mobi', mime: 'application/x-mobipocket-ebook' }); + + await fs.unlink(fixturePath); + }); + + it('accepts a real mobi-family file declared as mobi', async () => { + const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-mobi-family.bin'); + await fs.writeFile(fixturePath, buildMobiFamilyBuffer()); + + const result = await resolveInputFormat(fixturePath, 'mobi'); + expect(result).toEqual({ mime: 'application/x-mobipocket-ebook', valid: true }); + + await fs.unlink(fixturePath); + }); + + it('accepts a real mobi-family file declared as azw3 despite file-type reporting it as mobi', async () => { + const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-mobi-family.bin'); + await fs.writeFile(fixturePath, buildMobiFamilyBuffer()); + + const result = await resolveInputFormat(fixturePath, 'azw3'); + expect(result).toEqual({ mime: 'application/x-mobipocket-ebook', valid: true }); + + await fs.unlink(fixturePath); + }); +}); + +describe('resolveInputFormat — undetectable ebook formats', () => { + const undetectableFormats = { + fb2: 'application/x-fictionbook+xml', + lrf: 'application/octet-stream', + pdb: 'application/vnd.palm', + rb: 'application/octet-stream', + snb: 'application/octet-stream', + tcr: 'application/octet-stream', + }; + + it.each(Object.entries(undetectableFormats))( + 'trusts the declared format for undetectable %s files', + async (declaredFormat, expectedMime) => { + const fixturePath = path.join(import.meta.dirname, 'fixtures', `sample.${declaredFormat}`); + await fs.writeFile(fixturePath, 'arbitrary bytes with no recognizable magic number'); + + const result = await resolveInputFormat(fixturePath, declaredFormat); + + expect(result).toEqual({ mime: expectedMime, valid: true }); + + await fs.unlink(fixturePath); + } + ); +}); + describe('resolveInputFormat — dfont', () => { it('confirms file-type alone misidentifies the dfont fixture as ico (documents the bug this fix works around)', async () => { const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.dfont');