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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user