fix(cover): improve epub cover extraction with HTML and sidecar fallback

This commit is contained in:
neon
2026-02-07 08:59:25 -06:00
parent 448c6b05bb
commit 9923d3871f
3 changed files with 61 additions and 14 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1770469256790,
"when": 1770522775823,
"tag": "0000_add_rating_and_book_card_fields",
"breakpoints": true
}
+31 -4
View File
@@ -23,6 +23,23 @@ function findInZip(zip: unzipper.CentralDirectory, rawHref: string, opfDir: stri
});
}
/** Extract the first <img src> from an HTML cover page and resolve it to a zip entry. */
async function imageFromHtmlCoverPage(zip: unzipper.CentralDirectory, htmlItem: Record<string, unknown>, opfDir: string): Promise<Buffer | null> {
const htmlHref = String(htmlItem['@_href'] ?? '');
if (!htmlHref) return null;
const htmlFile = findInZip(zip, htmlHref, opfDir);
if (!htmlFile) return null;
const html = (await htmlFile.buffer()).toString('utf-8');
const srcMatch = html.match(/<img[^>]+src=["']([^"']+)["']/i);
if (!srcMatch) return null;
const imgSrc = srcMatch[1];
// Resolve img src relative to the HTML file's directory
const htmlDir = htmlHref.includes('/') ? htmlHref.substring(0, htmlHref.lastIndexOf('/') + 1) : '';
const imgFile = findInZip(zip, imgSrc, resolvePath(opfDir, htmlDir));
if (!imgFile) return null;
return imgFile.buffer();
}
/**
* Extract the cover image bytes from an EPUB file.
* Tries EPUB3 cover-image property, EPUB2 meta cover, guide reference, and id-based fallbacks.
@@ -70,7 +87,12 @@ export async function extractEpubCover(absolutePath: string): Promise<Buffer | n
const coverMeta = metaList.find((m) => String(m['@_name'] ?? '').toLowerCase() === 'cover');
if (coverMeta) {
const coverId = String(coverMeta['@_content'] ?? '');
coverItem = itemList.find((i) => String(i['@_id'] ?? '') === coverId);
const found = itemList.find((i) => String(i['@_id'] ?? '') === coverId);
if (found) {
const mt = String(found['@_media-type'] ?? '').toLowerCase();
if (!mt.startsWith('image/')) return imageFromHtmlCoverPage(zip, found, opfDir);
coverItem = found;
}
}
}
@@ -90,20 +112,25 @@ export async function extractEpubCover(absolutePath: string): Promise<Buffer | n
const itemHref = decodeURIComponent(String(i['@_href'] ?? '').split('#')[0]);
return itemHref === guideHref || resolvePath(opfDir, itemHref) === resolvePath(opfDir, guideHref);
});
// If guide points to an HTML page, skip - we only want image items
// If guide points to an HTML cover page, follow the <img> inside it
if (coverItem) {
const mt = String(coverItem['@_media-type'] ?? '').toLowerCase();
if (!mt.startsWith('image/')) coverItem = undefined;
if (!mt.startsWith('image/')) return imageFromHtmlCoverPage(zip, coverItem, opfDir);
}
}
}
// Fallback: item whose id is "cover" or "cover-image"
if (!coverItem) {
coverItem = itemList.find((i) => {
const found = itemList.find((i) => {
const id = String(i['@_id'] ?? '').toLowerCase();
return id === 'cover' || id === 'cover-image';
});
if (found) {
const mt = String(found['@_media-type'] ?? '').toLowerCase();
if (!mt.startsWith('image/')) return imageFromHtmlCoverPage(zip, found, opfDir);
coverItem = found;
}
}
// Fallback: item whose href contains "cover" and is an image
+29 -9
View File
@@ -1,5 +1,5 @@
import { mkdir, writeFile } from 'fs/promises';
import { join } from 'path';
import { mkdir, readFile, rm, writeFile } from 'fs/promises';
import { basename, dirname, extname, join } from 'path';
import sharp from 'sharp';
import { extractCb7Cover } from './cover-cb7';
@@ -40,12 +40,24 @@ export async function extractCover(absolutePath: string, format: string): Promis
}
}
/**
* Extract cover from a book file and save it under {booksPath}/covers/{bookId}/cover.{ext}.
* Returns the saved file path, or null if no cover was found.
*/
async function readSidecarCover(absolutePath: string): Promise<Buffer | null> {
const dir = dirname(absolutePath);
const stem = basename(absolutePath, extname(absolutePath));
const candidates = ['cover.jpg', 'cover.jpeg', 'cover.png', `${stem}.jpg`, `${stem}.jpeg`, `${stem}.png`];
for (const name of candidates) {
try {
const buf = await readFile(join(dir, name));
if (buf.length > 0) return buf;
} catch {
// not found
}
}
return null;
}
export async function extractAndSaveCover(absolutePath: string, format: string, bookId: number, booksPath: string): Promise<string | null> {
const bytes = await extractCover(absolutePath, format);
let bytes = await extractCover(absolutePath, format);
if (!bytes || bytes.length === 0) bytes = await readSidecarCover(absolutePath);
if (!bytes || bytes.length === 0) return null;
const dir = join(booksPath, 'covers', String(bookId));
@@ -55,8 +67,16 @@ export async function extractAndSaveCover(absolutePath: string, format: string,
const filePath = join(dir, `cover.${ext}`);
await writeFile(filePath, bytes);
const thumbnail = await sharp(bytes).resize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, { fit: 'cover', position: 'top' }).jpeg({ quality: 90 }).toBuffer();
await writeFile(join(dir, 'thumbnail.jpg'), thumbnail);
try {
const thumbnail = await sharp(bytes)
.resize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, { fit: 'cover', position: 'top' })
.jpeg({ quality: 90 })
.toBuffer();
await writeFile(join(dir, 'thumbnail.jpg'), thumbnail);
} catch (err) {
await rm(dir, { recursive: true, force: true });
throw err;
}
return filePath;
}