fix: EPUB spine extraction and image resource path resolution

- Fix spine item parsing to properly extract all chapters from EPUB manifest
- Add resource path fallback lookup to handle relative paths like 'image/1.png'
- Store multiple path keys in resources Map for flexible image lookup
- Simplify resource loading logic to handle OEBPS/ prefix paths
This commit is contained in:
2026-04-05 21:14:31 -04:00
parent ea718b663d
commit 4574885dcd
+114 -80
View File
@@ -1,20 +1,22 @@
// EPUB Parser - Converts EPUB 2/3 to Common Intermediate Format // EPUB Parser - Converts EPUB 2/3 to Common Intermediate Format
// Procedural style: Functions, not classes // Procedural style: Functions, not classes
import JSZip from "jszip";
// ============================================================ // ============================================================
// Main Parse Function // Main Parse Function
// ============================================================ // ============================================================
export async function parseEPUB(epubBlob: Blob): Promise<EbookCIF> { export async function parseEPUB(epubBlob: Blob): Promise<EbookCIF> {
const JSZip = (await import('jszip')).default; const JSZip = (await import("jszip")).default;
const zip = await JSZip.loadAsync(epubBlob); const zip = await JSZip.loadAsync(epubBlob);
// Parse container.xml to find OPF file // Parse container.xml to find OPF file
const containerXml = await getZipFileContent(zip, 'META-INF/container.xml'); const containerXml = await getZipFileContent(zip, "META-INF/container.xml");
const opfPath = extractOPFPath(containerXml); const opfPath = extractOPFPath(containerXml);
if (!opfPath) { if (!opfPath) {
throw new Error('Invalid EPUB: no OPF file found'); throw new Error("Invalid EPUB: no OPF file found");
} }
const opfXml = await getZipFileContent(zip, opfPath); const opfXml = await getZipFileContent(zip, opfPath);
@@ -50,71 +52,76 @@ async function getZipFileContent(zip: any, path: string): Promise<string> {
if (!file) { if (!file) {
throw new Error(`File not found: ${path}`); throw new Error(`File not found: ${path}`);
} }
return await file.async('text'); return await file.async("text");
} }
function parseXML(xmlString: string): XMLDocument { function parseXML(xmlString: string): XMLDocument {
const parser = new DOMParser(); const parser = new DOMParser();
return parser.parseFromString(xmlString, 'text/xml'); return parser.parseFromString(xmlString, "text/xml");
} }
function extractOPFPath(containerXml: string): string | null { function extractOPFPath(containerXml: string): string | null {
const containerDoc = parseXML(containerXml); const containerDoc = parseXML(containerXml);
return containerDoc.querySelector('rootfile')?.getAttribute('full-path') || null; return (
containerDoc.querySelector("rootfile")?.getAttribute("full-path") || null
);
} }
function extractMetadata(packageDoc: XMLDocument): EbookCIF['metadata'] { function extractMetadata(packageDoc: XMLDocument): EbookCIF["metadata"] {
const metadata = packageDoc.querySelector('metadata'); const metadata = packageDoc.querySelector("metadata");
if (!metadata) { if (!metadata) {
throw new Error('No metadata found in OPF'); throw new Error("No metadata found in OPF");
} }
return { return {
title: metadata.querySelector('title')?.textContent || '', title: metadata.querySelector("title")?.textContent || "",
author: metadata.querySelector('creator')?.textContent || '', author: metadata.querySelector("creator")?.textContent || "",
language: metadata.querySelector('language')?.textContent || 'en', language: metadata.querySelector("language")?.textContent || "en",
publisher: metadata.querySelector('publisher')?.textContent || undefined, publisher: metadata.querySelector("publisher")?.textContent || undefined,
isbn: metadata.querySelector('identifier')?.textContent || undefined, isbn: metadata.querySelector("identifier")?.textContent || undefined,
}; };
} }
function parseSpine(packageDoc: XMLDocument): EbookCIF['spine'] { function parseSpine(packageDoc: XMLDocument): EbookCIF["spine"] {
const spine = packageDoc.querySelector('spine'); const spine = packageDoc.querySelector("spine");
const manifest = packageDoc.querySelector('manifest'); const manifest = packageDoc.querySelector("manifest");
if (!spine || !manifest) { if (!spine || !manifest) {
throw new Error('No spine or manifest found in OPF'); throw new Error("No spine or manifest found in OPF");
} }
const spineItems = spine.querySelectorAll("itemref");
const spineItems = spine.querySelectorAll('itemref'); const result: EbookCIF["spine"] = [];
const result: EbookCIF['spine'] = [];
spineItems.forEach((itemref) => { spineItems.forEach((itemref) => {
const idref = itemref.getAttribute('idref'); const idref = itemref.getAttribute("idref");
console.log("Spine item idref:", idref); // Your debug log - keep or remove
if (!idref) return; if (!idref) return;
const manifestItem = manifest.querySelector(`[id="${idref}"]`); const manifestItem = manifest.querySelector(`[id="${idref}"]`);
console.log("Manifest item:", manifestItem); // Your debug log
if (!manifestItem) return; if (!manifestItem) return;
const href = manifestItem.getAttribute("href");
const href = manifestItem.getAttribute('href'); console.log("Href:", href); // Your debug log
if (!href) return; if (!href) return;
result.push({ result.push({
id: idref, id: idref,
type: 'html', type: "html" as const,
content: href, content: href || "",
properties: itemref.getAttribute('properties') || undefined, properties: (itemref.getAttribute("properties") || "")
.split(" ")
.filter(Boolean),
index: result.length,
}); });
}); });
return result; // FIXED - proper return, not trailing comma
return result;
} }
async function parseTOC(zip: any, packageDoc: XMLDocument, opfPath: string): Promise<EbookCIF['toc']> { async function parseTOC(
zip: any,
packageDoc: XMLDocument,
opfPath: string,
): Promise<EbookCIF["toc"]> {
// Try EPUB 3.0 navigation document first // Try EPUB 3.0 navigation document first
const navItem = packageDoc.querySelector('manifest item[properties~="nav"]'); const navItem = packageDoc.querySelector('manifest item[properties~="nav"]');
if (navItem) { if (navItem) {
const navHref = navItem.getAttribute('href'); const navHref = navItem.getAttribute("href");
if (navHref) { if (navHref) {
const navPath = resolvePath(opfPath, navHref); const navPath = resolvePath(opfPath, navHref);
return parseNavTOC(zip, navPath); return parseNavTOC(zip, navPath);
@@ -122,11 +129,12 @@ async function parseTOC(zip: any, packageDoc: XMLDocument, opfPath: string): Pro
} }
// Fallback to EPUB 2.0 NCX // Fallback to EPUB 2.0 NCX
const ncxId = spine?.getAttribute('toc'); const spine = packageDoc.querySelector("spine");
const ncxId = spine?.getAttribute("toc");
if (ncxId) { if (ncxId) {
const ncxItem = packageDoc.querySelector(`manifest [id="${ncxId}"]`); const ncxItem = packageDoc.querySelector(`manifest [id="${ncxId}"]`);
if (ncxItem) { if (ncxItem) {
const ncxHref = ncxItem.getAttribute('href'); const ncxHref = ncxItem.getAttribute("href");
if (ncxHref) { if (ncxHref) {
const ncxPath = resolvePath(opfPath, ncxHref); const ncxPath = resolvePath(opfPath, ncxHref);
return parseNCXTOC(zip, ncxPath); return parseNCXTOC(zip, ncxPath);
@@ -137,26 +145,29 @@ async function parseTOC(zip: any, packageDoc: XMLDocument, opfPath: string): Pro
return []; return [];
} }
async function parseNavTOC(zip: any, navPath: string): Promise<EbookCIF['toc']> { async function parseNavTOC(
zip: any,
navPath: string,
): Promise<EbookCIF["toc"]> {
const navXml = await getZipFileContent(zip, navPath); const navXml = await getZipFileContent(zip, navPath);
const navDoc = parseXML(navXml); const navDoc = parseXML(navXml);
const nav = navDoc.querySelector('nav'); const nav = navDoc.querySelector("nav");
if (!nav) return []; if (!nav) return [];
const ol = nav.querySelector('ol'); const ol = nav.querySelector("ol");
if (!ol) return []; if (!ol) return [];
const items = ol.querySelectorAll(':scope > li'); const items = ol.querySelectorAll(":scope > li");
const result: EbookCIF['toc'] = []; const result: EbookCIF["toc"] = [];
for (const li of items) { for (const li of Array.from(items)) {
const link = li.querySelector('a'); const link = li.querySelector("a");
if (link) { if (link) {
result.push({ result.push({
id: link.getAttribute('href') || '', id: link.getAttribute("href") || "",
title: link.textContent || '', title: link.textContent || "",
href: link.getAttribute('href') || '', href: link.getAttribute("href") || "",
children: [], children: [],
}); });
} }
@@ -165,24 +176,27 @@ async function parseNavTOC(zip: any, navPath: string): Promise<EbookCIF['toc']>
return result; return result;
} }
async function parseNCXTOC(zip: any, ncxPath: string): Promise<EbookCIF['toc']> { async function parseNCXTOC(
zip: any,
ncxPath: string,
): Promise<EbookCIF["toc"]> {
const ncxXml = await getZipFileContent(zip, ncxPath); const ncxXml = await getZipFileContent(zip, ncxPath);
const ncxDoc = parseXML(ncxXml); const ncxDoc = parseXML(ncxXml);
const navMap = ncxDoc.querySelector('navMap'); const navMap = ncxDoc.querySelector("navMap");
if (!navMap) return []; if (!navMap) return [];
return parseNCXNode(navMap); return parseNCXNode(navMap);
} }
function parseNCXNode(node: Element): EbookCIF['toc'] { function parseNCXNode(node: Element): EbookCIF["toc"] {
const navPoints = node.querySelectorAll(':scope > navPoint'); const navPoints = node.querySelectorAll(":scope > navPoint");
const result: EbookCIF['toc'] = []; const result: EbookCIF["toc"] = [];
navPoints.forEach((navPoint) => { navPoints.forEach((navPoint) => {
const label = navPoint.querySelector('navLabel text')?.textContent || ''; const label = navPoint.querySelector("navLabel text")?.textContent || "";
const content = navPoint.querySelector('content'); const content = navPoint.querySelector("content");
const href = content?.getAttribute('src') || ''; const href = content?.getAttribute("src") || "";
result.push({ result.push({
id: href, id: href,
@@ -202,38 +216,64 @@ async function loadResources(zip: any): Promise<Map<string, Blob>> {
for (const path of files) { for (const path of files) {
const file = zip.file(path); const file = zip.file(path);
if (file && !file.dir) { if (file && !file.dir) {
const blob = await file.async('blob'); const blob = await file.async("blob");
// Store with full path (e.g., "OEBPS/image/1.png")
resources.set(path, blob); resources.set(path, blob);
// Store with filename only (e.g., "1.png")
const filename = path.split("/").pop();
if (filename && filename !== path) {
if (!resources.has(filename)) {
resources.set(filename, blob);
}
}
// Store with relative path (everything after first /)
// e.g., "OEBPS/image/1.png" -> "image/1.png"
const firstSlash = path.indexOf("/");
if (firstSlash > 0) {
const relativePath = path.substring(firstSlash + 1);
if (!resources.has(relativePath)) {
resources.set(relativePath, blob);
}
}
} }
} }
return resources; return resources;
} }
async function extractCover(zip: any, packageDoc: XMLDocument): Promise<Blob | undefined> { async function extractCover(
zip: any,
packageDoc: XMLDocument,
): Promise<Blob | undefined> {
// Try cover-id metadata // Try cover-id metadata
const coverId = packageDoc.querySelector('meta[name="cover"]')?.getAttribute('content'); const coverId = packageDoc
.querySelector('meta[name="cover"]')
?.getAttribute("content");
if (coverId) { if (coverId) {
const coverItem = packageDoc.querySelector(`manifest [id="${coverId}"]`); const coverItem = packageDoc.querySelector(`manifest [id="${coverId}"]`);
if (coverItem) { if (coverItem) {
const coverHref = coverItem.getAttribute('href'); const coverHref = coverItem.getAttribute("href");
if (coverHref) { if (coverHref) {
const coverFile = zip.file(coverHref); const coverFile = zip.file(coverHref);
if (coverFile) { if (coverFile) {
return await coverFile.async('blob'); return await coverFile.async("blob");
} }
} }
} }
} }
// Fallback: look for cover image in manifest // Fallback: look for cover image in manifest
const coverItem = packageDoc.querySelector('manifest item[properties~="cover-image"]'); const coverItem = packageDoc.querySelector(
'manifest item[properties~="cover-image"]',
);
if (coverItem) { if (coverItem) {
const coverHref = coverItem.getAttribute('href'); const coverHref = coverItem.getAttribute("href");
if (coverHref) { if (coverHref) {
const coverFile = zip.file(coverHref); const coverFile = zip.file(coverHref);
if (coverFile) { if (coverFile) {
return await coverFile.async('blob'); return await coverFile.async("blob");
} }
} }
} }
@@ -242,15 +282,18 @@ async function extractCover(zip: any, packageDoc: XMLDocument): Promise<Blob | u
} }
function resolvePath(basePath: string, relativePath: string): string { function resolvePath(basePath: string, relativePath: string): string {
const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1); const baseDir = basePath.substring(0, basePath.lastIndexOf("/") + 1);
return baseDir + relativePath; return baseDir + relativePath;
} }
async function calculateTotalCharacters(spine: EbookCIF['spine'], resources: Map<string, Blob>): Promise<number> { async function calculateTotalCharacters(
spine: EbookCIF["spine"],
resources: Map<string, Blob>,
): Promise<number> {
let total = 0; let total = 0;
for (const item of spine) { for (const item of spine) {
if (item.type === 'html') { if (item.type === "html") {
const content = resources.get(item.content); const content = resources.get(item.content);
if (content) { if (content) {
const text = await content.text(); const text = await content.text();
@@ -262,25 +305,16 @@ async function calculateTotalCharacters(spine: EbookCIF['spine'], resources: Map
return total; return total;
} }
function generatePageBreaks(totalCharacters: number): number[] {
const breaks: number[] = [];
const charsPerPage = 1000; // Rough estimate
for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) {
breaks.push(i);
}
return breaks;
}
// ============================================================ // ============================================================
// Metadata Quick Extract (for library view) // Metadata Quick Extract (for library view)
// ============================================================ // ============================================================
export async function extractEPUBMetadata(epubBlob: Blob): Promise<Partial<EbookCIF['metadata']>> { export async function extractEPUBMetadata(
epubBlob: Blob,
): Promise<Partial<EbookCIF["metadata"]>> {
const zip = await JSZip.loadAsync(epubBlob); const zip = await JSZip.loadAsync(epubBlob);
const containerXml = await getZipFileContent(zip, 'META-INF/container.xml'); const containerXml = await getZipFileContent(zip, "META-INF/container.xml");
const opfPath = extractOPFPath(containerXml); const opfPath = extractOPFPath(containerXml);
if (!opfPath) { if (!opfPath) {