mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(cover): improve epub cover extraction with HTML and sidecar fallback
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1770469256790,
|
||||
"when": 1770522775823,
|
||||
"tag": "0000_add_rating_and_book_card_fields",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user