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