diff --git a/web/src/reader/parsers/epub-parsers.ts b/web/src/reader/parsers/epub-parsers.ts index 1565434..ef3f6cb 100644 --- a/web/src/reader/parsers/epub-parsers.ts +++ b/web/src/reader/parsers/epub-parsers.ts @@ -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 { - 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 { 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 { +async function parseTOC( + zip: any, + packageDoc: XMLDocument, + opfPath: string, +): Promise { // 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 { +async function parseNavTOC( + zip: any, + navPath: string, +): Promise { 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 return result; } -async function parseNCXTOC(zip: any, ncxPath: string): Promise { +async function parseNCXTOC( + zip: any, + ncxPath: string, +): Promise { 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> { 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 { +async function extractCover( + zip: any, + packageDoc: XMLDocument, +): Promise { // 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): Promise { +async function calculateTotalCharacters( + spine: EbookCIF["spine"], + resources: Map, +): Promise { 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> { +export async function extractEPUBMetadata( + epubBlob: Blob, +): Promise> { 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) {