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
// Procedural style: Functions, not classes
import JSZip from "jszip";
// ============================================================
// Main Parse Function
// ============================================================
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);
// 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);
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);
@@ -50,71 +52,76 @@ async function getZipFileContent(zip: any, path: string): Promise<string> {
if (!file) {
throw new Error(`File not found: ${path}`);
}
return await file.async('text');
return await file.async("text");
}
function parseXML(xmlString: string): XMLDocument {
const parser = new DOMParser();
return parser.parseFromString(xmlString, 'text/xml');
return parser.parseFromString(xmlString, "text/xml");
}
function extractOPFPath(containerXml: string): string | null {
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'] {
const metadata = packageDoc.querySelector('metadata');
function extractMetadata(packageDoc: XMLDocument): EbookCIF["metadata"] {
const metadata = packageDoc.querySelector("metadata");
if (!metadata) {
throw new Error('No metadata found in OPF');
throw new Error("No metadata found in OPF");
}
return {
title: metadata.querySelector('title')?.textContent || '',
author: metadata.querySelector('creator')?.textContent || '',
language: metadata.querySelector('language')?.textContent || 'en',
publisher: metadata.querySelector('publisher')?.textContent || undefined,
isbn: metadata.querySelector('identifier')?.textContent || undefined,
title: metadata.querySelector("title")?.textContent || "",
author: metadata.querySelector("creator")?.textContent || "",
language: metadata.querySelector("language")?.textContent || "en",
publisher: metadata.querySelector("publisher")?.textContent || undefined,
isbn: metadata.querySelector("identifier")?.textContent || undefined,
};
}
function parseSpine(packageDoc: XMLDocument): EbookCIF['spine'] {
const spine = packageDoc.querySelector('spine');
const manifest = packageDoc.querySelector('manifest');
function parseSpine(packageDoc: XMLDocument): EbookCIF["spine"] {
const spine = packageDoc.querySelector("spine");
const manifest = packageDoc.querySelector("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 result: EbookCIF['spine'] = [];
const spineItems = spine.querySelectorAll("itemref");
const result: EbookCIF["spine"] = [];
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;
const manifestItem = manifest.querySelector(`[id="${idref}"]`);
console.log("Manifest item:", manifestItem); // Your debug log
if (!manifestItem) return;
const href = manifestItem.getAttribute('href');
const href = manifestItem.getAttribute("href");
console.log("Href:", href); // Your debug log
if (!href) return;
result.push({
id: idref,
type: 'html',
content: href,
properties: itemref.getAttribute('properties') || undefined,
type: "html" as const,
content: href || "",
properties: (itemref.getAttribute("properties") || "")
.split(" ")
.filter(Boolean),
index: result.length,
});
});
return result;
return result; // FIXED - proper return, not trailing comma
}
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
const navItem = packageDoc.querySelector('manifest item[properties~="nav"]');
if (navItem) {
const navHref = navItem.getAttribute('href');
const navHref = navItem.getAttribute("href");
if (navHref) {
const navPath = resolvePath(opfPath, navHref);
return parseNavTOC(zip, navPath);
@@ -122,11 +129,12 @@ async function parseTOC(zip: any, packageDoc: XMLDocument, opfPath: string): Pro
}
// Fallback to EPUB 2.0 NCX
const ncxId = spine?.getAttribute('toc');
const spine = packageDoc.querySelector("spine");
const ncxId = spine?.getAttribute("toc");
if (ncxId) {
const ncxItem = packageDoc.querySelector(`manifest [id="${ncxId}"]`);
if (ncxItem) {
const ncxHref = ncxItem.getAttribute('href');
const ncxHref = ncxItem.getAttribute("href");
if (ncxHref) {
const ncxPath = resolvePath(opfPath, ncxHref);
return parseNCXTOC(zip, ncxPath);
@@ -137,26 +145,29 @@ async function parseTOC(zip: any, packageDoc: XMLDocument, opfPath: string): Pro
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 navDoc = parseXML(navXml);
const nav = navDoc.querySelector('nav');
const nav = navDoc.querySelector("nav");
if (!nav) return [];
const ol = nav.querySelector('ol');
const ol = nav.querySelector("ol");
if (!ol) return [];
const items = ol.querySelectorAll(':scope > li');
const result: EbookCIF['toc'] = [];
const items = ol.querySelectorAll(":scope > li");
const result: EbookCIF["toc"] = [];
for (const li of items) {
const link = li.querySelector('a');
for (const li of Array.from(items)) {
const link = li.querySelector("a");
if (link) {
result.push({
id: link.getAttribute('href') || '',
title: link.textContent || '',
href: link.getAttribute('href') || '',
id: link.getAttribute("href") || "",
title: link.textContent || "",
href: link.getAttribute("href") || "",
children: [],
});
}
@@ -165,24 +176,27 @@ async function parseNavTOC(zip: any, navPath: string): Promise<EbookCIF['toc']>
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 ncxDoc = parseXML(ncxXml);
const navMap = ncxDoc.querySelector('navMap');
const navMap = ncxDoc.querySelector("navMap");
if (!navMap) return [];
return parseNCXNode(navMap);
}
function parseNCXNode(node: Element): EbookCIF['toc'] {
const navPoints = node.querySelectorAll(':scope > navPoint');
const result: EbookCIF['toc'] = [];
function parseNCXNode(node: Element): EbookCIF["toc"] {
const navPoints = node.querySelectorAll(":scope > navPoint");
const result: EbookCIF["toc"] = [];
navPoints.forEach((navPoint) => {
const label = navPoint.querySelector('navLabel text')?.textContent || '';
const content = navPoint.querySelector('content');
const href = content?.getAttribute('src') || '';
const label = navPoint.querySelector("navLabel text")?.textContent || "";
const content = navPoint.querySelector("content");
const href = content?.getAttribute("src") || "";
result.push({
id: href,
@@ -202,38 +216,64 @@ async function loadResources(zip: any): Promise<Map<string, Blob>> {
for (const path of files) {
const file = zip.file(path);
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);
// 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;
}
async function extractCover(zip: any, packageDoc: XMLDocument): Promise<Blob | undefined> {
async function extractCover(
zip: any,
packageDoc: XMLDocument,
): Promise<Blob | undefined> {
// Try cover-id metadata
const coverId = packageDoc.querySelector('meta[name="cover"]')?.getAttribute('content');
const coverId = packageDoc
.querySelector('meta[name="cover"]')
?.getAttribute("content");
if (coverId) {
const coverItem = packageDoc.querySelector(`manifest [id="${coverId}"]`);
if (coverItem) {
const coverHref = coverItem.getAttribute('href');
const coverHref = coverItem.getAttribute("href");
if (coverHref) {
const coverFile = zip.file(coverHref);
if (coverFile) {
return await coverFile.async('blob');
return await coverFile.async("blob");
}
}
}
}
// 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) {
const coverHref = coverItem.getAttribute('href');
const coverHref = coverItem.getAttribute("href");
if (coverHref) {
const coverFile = zip.file(coverHref);
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 {
const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1);
const baseDir = basePath.substring(0, basePath.lastIndexOf("/") + 1);
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;
for (const item of spine) {
if (item.type === 'html') {
if (item.type === "html") {
const content = resources.get(item.content);
if (content) {
const text = await content.text();
@@ -262,25 +305,16 @@ async function calculateTotalCharacters(spine: EbookCIF['spine'], resources: Map
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)
// ============================================================
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 containerXml = await getZipFileContent(zip, 'META-INF/container.xml');
const containerXml = await getZipFileContent(zip, "META-INF/container.xml");
const opfPath = extractOPFPath(containerXml);
if (!opfPath) {