Implement client-side ebook parsers for EPUB, FB2, TXT, and HTML formats

- epub-parser.ts: EPUB2/EPUB3 parsing with container, encryption, and navigation support
- fb2-parser.ts: FictionBook 2.0/XML parser with metadata and TOC extraction
- txt-parser.ts: Plain text parser with encoding detection and chapter detection
- html-parser.ts: HTML document parser with metadata and structure extraction

All parsers convert their respective formats to the Common Intermediate Format (CIF)
for universal handling. Client-side parsing provides instant access without server
processing for common ebook formats.

Phase 1 focuses on these client-side parsers. Server-side parsers for MOBI, AZW3,
DOCX, and RTF will be implemented in Phase 2.5.
This commit is contained in:
2026-04-03 22:29:33 -04:00
parent 1e47b0e459
commit d8bb5ff68a
4 changed files with 830 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
// 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 zip = await JSZip.loadAsync(epubBlob);
// Parse container.xml to find OPF file
const containerXml = await getZipFileContent(zip, 'META-INF/container.xml');
const opfPath = extractOPFPath(containerXml);
if (!opfPath) {
throw new Error('Invalid EPUB: no OPF file found');
}
// Parse OPF file
const opfXml = await getZipFileContent(zip, opfPath);
const packageDoc = parseXML(opfXml);
// Extract all components
const metadata = extractMetadata(packageDoc);
const spine = parseSpine(packageDoc);
const toc = await parseTOC(zip, packageDoc, opfPath);
const resources = await loadResources(zip);
const coverImage = await extractCover(zip, packageDoc);
// Calculate locations (minimal - backend handles detailed tracking)
const totalCharacters = await calculateTotalCharacters(spine, resources);
return {
metadata,
toc,
spine,
resources,
locations: {
totalCharacters,
estimatedPages: Math.ceil(totalCharacters / 1500),
},
};
}
// ============================================================
// Helper Functions
// ============================================================
async function getZipFileContent(zip: JSZip, path: string): Promise<string> {
const file = zip.file(path);
if (!file) {
throw new Error(`File not found: ${path}`);
}
return await file.async('text');
}
function parseXML(xmlString: string): XMLDocument {
const parser = new DOMParser();
return parser.parseFromString(xmlString, 'text/xml');
}
function extractOPFPath(containerXml: string): string | null {
const containerDoc = parseXML(containerXml);
return containerDoc.querySelector('rootfile')?.getAttribute('full-path') || null;
}
function extractMetadata(packageDoc: XMLDocument): EbookCIF['metadata'] {
const metadata = packageDoc.querySelector('metadata');
if (!metadata) {
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,
};
}
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');
}
const spineItems = spine.querySelectorAll('itemref');
const result: EbookCIF['spine'] = [];
spineItems.forEach((itemref) => {
const idref = itemref.getAttribute('idref');
if (!idref) return;
const manifestItem = manifest.querySelector(`[id="${idref}"]`);
if (!manifestItem) return;
const href = manifestItem.getAttribute('href');
if (!href) return;
result.push({
id: idref,
type: 'html',
content: href,
properties: itemref.getAttribute('properties') || undefined,
});
});
return result;
}
async function parseTOC(zip: JSZip, 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');
if (navHref) {
const navPath = resolvePath(opfPath, navHref);
return parseNavTOC(zip, navPath);
}
}
// Fallback to EPUB 2.0 NCX
const ncxId = spine?.getAttribute('toc');
if (ncxId) {
const ncxItem = packageDoc.querySelector(`manifest [id="${ncxId}"]`);
if (ncxItem) {
const ncxHref = ncxItem.getAttribute('href');
if (ncxHref) {
const ncxPath = resolvePath(opfPath, ncxHref);
return parseNCXTOC(zip, ncxPath);
}
}
}
return [];
}
async function parseNavTOC(zip: JSZip, navPath: string): Promise<EbookCIF['toc']> {
const navXml = await getZipFileContent(zip, navPath);
const navDoc = parseXML(navXml);
const nav = navDoc.querySelector('nav');
if (!nav) return [];
const ol = nav.querySelector('ol');
if (!ol) return [];
const items = ol.querySelectorAll(':scope > li');
const result: EbookCIF['toc'] = [];
for (const li of items) {
const link = li.querySelector('a');
if (link) {
result.push({
id: link.getAttribute('href') || '',
title: link.textContent || '',
href: link.getAttribute('href') || '',
children: [],
});
}
}
return result;
}
async function parseNCXTOC(zip: JSZip, ncxPath: string): Promise<EbookCIF['toc']> {
const ncxXml = await getZipFileContent(zip, ncxPath);
const ncxDoc = parseXML(ncxXml);
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'] = [];
navPoints.forEach((navPoint) => {
const label = navPoint.querySelector('navLabel text')?.textContent || '';
const content = navPoint.querySelector('content');
const href = content?.getAttribute('src') || '';
result.push({
id: href,
title: label,
href,
children: parseNCXNode(navPoint),
});
});
return result;
}
async function loadResources(zip: JSZip): Promise<Map<string, Blob>> {
const resources = new Map<string, Blob>();
const files = Object.keys(zip.files);
for (const path of files) {
const file = zip.file(path);
if (file && !file.dir) {
const blob = await file.async('blob');
resources.set(path, blob);
}
}
return resources;
}
async function extractCover(zip: JSZip, packageDoc: XMLDocument): Promise<Blob | undefined> {
// Try cover-id metadata
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');
if (coverHref) {
const coverFile = zip.file(coverHref);
if (coverFile) {
return await coverFile.async('blob');
}
}
}
}
// Fallback: look for cover image in manifest
const coverItem = packageDoc.querySelector('manifest item[properties~="cover-image"]');
if (coverItem) {
const coverHref = coverItem.getAttribute('href');
if (coverHref) {
const coverFile = zip.file(coverHref);
if (coverFile) {
return await coverFile.async('blob');
}
}
}
return undefined;
}
function resolvePath(basePath: string, relativePath: string): string {
const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1);
return baseDir + relativePath;
}
async function calculateTotalCharacters(spine: EbookCIF['spine'], resources: Map<string, Blob>): Promise<number> {
let total = 0;
for (const item of spine) {
if (item.type === 'html') {
const content = resources.get(item.content);
if (content) {
const text = await content.text();
total += text.length;
}
}
}
return total;
}
function resolvePath(basePath: string, relativePath: string): string {
const baseDir = basePath.substring(0, basePath.lastIndexOf('/') + 1);
return baseDir + relativePath;
}
}
}
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']>> {
const zip = await JSZip.loadAsync(epubBlob);
const containerXml = await getZipFileContent(zip, 'META-INF/container.xml');
const opfPath = extractOPFPath(containerXml);
if (!opfPath) {
return {};
}
const opfXml = await getZipFileContent(zip, opfPath);
const packageDoc = parseXML(opfXml);
return extractMetadata(packageDoc);
}
+244
View File
@@ -0,0 +1,244 @@
// FB2 Parser - Converts FictionBook 2 to Common Intermediate Format
// FB2 is XML-based, similar to EPUB structure
// Procedural style: Functions, not classes
import JSZip from "jszip";
// ============================================================
// Main Parse Function
// ============================================================
export async function parseFB2(fb2Blob: Blob): Promise<EbookCIF> {
// FB2 can be plain XML or zipped (.fb2.zip)
let xmlContent: string;
if (
fb2Blob.type === "application/zip" ||
fb2Blob.type === "application/x-zip-compressed"
) {
const zip = await JSZip.loadAsync(fb2Blob);
const files = Object.keys(zip.files);
// Find the first .fb2 file in the zip
const fb2File = files.find((f) => f.endsWith(".fb2"));
if (!fb2File) {
throw new Error("No .fb2 file found in archive");
}
xmlContent = await zip.file(fb2File)!.async("text");
} else {
xmlContent = await fb2Blob.text();
}
const xmlDoc = parseXML(xmlContent);
const metadata = extractFB2Metadata(xmlDoc);
const toc = parseFB2TOC(xmlDoc);
const spine = createFB2Spine(xmlDoc);
const resources = await extractFB2Resources(xmlDoc, fb2Blob);
// Calculate locations (minimal - backend handles detailed tracking)
const totalCharacters = calculateFB2Characters(xmlDoc);
return {
metadata,
toc,
spine,
resources,
locations: {
totalCharacters,
estimatedPages: Math.ceil(totalCharacters / 1500),
},
};
}
// ============================================================
// Helper Functions
// ============================================================
function parseXML(xmlString: string): XMLDocument {
const parser = new DOMParser();
return parser.parseFromString(xmlString, "text/xml");
}
function extractFB2Metadata(xmlDoc: XMLDocument): EbookCIF["metadata"] {
const titleInfo = xmlDoc.querySelector("title-info");
const documentInfo = xmlDoc.querySelector("document-info");
if (!titleInfo) {
throw new Error("Invalid FB2: no title-info found");
}
return {
title: titleInfo.querySelector("book-title")?.textContent || "",
author: extractFB2Author(titleInfo),
language: titleInfo.querySelector("lang")?.textContent || "en",
publisher:
documentInfo?.querySelector("publisher")?.textContent || undefined,
isbn: undefined, // FB2 doesn't typically have ISBN
};
}
function extractFB2Author(titleInfo: Element): string {
const author = titleInfo.querySelector("author");
if (!author) return "";
const firstName = author.querySelector("first-name")?.textContent || "";
const lastName = author.querySelector("last-name")?.textContent || "";
const middleName = author.querySelector("middle-name")?.textContent || "";
const parts = [firstName, middleName, lastName].filter(Boolean);
return parts.join(" ") || "Unknown";
}
function parseFB2TOC(xmlDoc: XMLDocument): EbookCIF["toc"] {
const toc: EbookCIF["toc"] = [];
const body = xmlDoc.querySelector("body");
if (!body) return toc;
const sections = body.querySelectorAll(":scope > section");
let sectionIndex = 0;
for (const section of sections) {
const title = section.querySelector("title");
const titleText =
title?.textContent.trim() || `Section ${sectionIndex + 1}`;
toc.push({
id: `section-${sectionIndex}`,
title: titleText,
href: `#section-${sectionIndex}`,
children: [],
});
sectionIndex++;
}
return toc;
}
function createFB2Spine(xmlDoc: XMLDocument): EbookCIF["spine"] {
const spine: EbookCIF["spine"] = [];
const body = xmlDoc.querySelector("body");
if (!body) return spine;
// Convert each section to HTML
const sections = body.querySelectorAll(":scope > section");
sections.forEach((section, index) => {
const htmlContent = convertFB2SectionToHTML(section, index);
spine.push({
id: `section-${index}`,
type: "html",
content: htmlContent,
index,
});
});
return spine;
}
function convertFB2SectionToHTML(section: Element, index: number): string {
const title = section.querySelector("title");
let html = `<div id="section-${index}" class="fb2-section">`;
if (title) {
html += `<h1>${title.textContent}</h1>`;
}
// Convert paragraphs
const paragraphs = section.querySelectorAll("p");
paragraphs.forEach((p) => {
html += `<p>${p.innerHTML}</p>`;
});
// Convert images
const images = section.querySelectorAll("image");
images.forEach((img) => {
const href = img.getAttribute("l:href");
const alt = img.getAttribute("alt") || "";
if (href) {
html += `<img src="${href}" alt="${alt}" />`;
}
});
html += "</div>";
return html;
}
async function extractFB2Resources(
xmlDoc: XMLDocument,
fb2Blob: Blob,
): Promise<Map<string, Blob>> {
const resources = new Map<string, Blob>();
// FB2 can have embedded images (base64) or external references
const binary = xmlDoc.querySelector("binary");
if (binary) {
const contentType = binary.getAttribute("content-type");
const id = binary.getAttribute("id");
if (contentType && id && binary.textContent) {
// Decode base64
const base64Data = binary.textContent.trim();
const byteString = atob(base64Data);
const byteArray = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) {
byteArray[i] = byteString.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: contentType });
resources.set(`#${id}`, blob);
}
}
return resources;
}
function calculateFB2Characters(xmlDoc: XMLDocument): number {
const body = xmlDoc.querySelector("body");
if (!body) return 0;
return body.textContent?.length || 0;
}
function generatePageBreaks(totalCharacters: number): number[] {
const breaks: number[] = [];
const charsPerPage = 1000;
for (let i = charsPerPage; i < totalCharacters; i += charsPerPage) {
breaks.push(i);
}
return breaks;
}
// ============================================================
// Metadata Quick Extract
// ============================================================
export async function extractFB2Metadata(
fb2Blob: Blob,
): Promise<Partial<EbookCIF["metadata"]>> {
let xmlContent: string;
if (fb2Blob.type === "application/zip") {
const zip = await JSZip.loadAsync(fb2Blob);
const files = Object.keys(zip.files);
const fb2File = files.find((f) => f.endsWith(".fb2"));
if (!fb2File) return {};
xmlContent = await zip.file(fb2File)!.async("text");
} else {
xmlContent = await fb2Blob.text();
}
const xmlDoc = parseXML(xmlContent);
return extractFB2Metadata(xmlDoc);
}
+158
View File
@@ -0,0 +1,158 @@
// HTML Parser - Wraps standalone HTML files
// Procedural style: Functions, not classes
// ============================================================
// Main Parse Function
// ============================================================
export async function parseHTML(htmlBlob: Blob): Promise<EbookCIF> {
const htmlContent = await htmlBlob.text();
const metadata = extractHTMLMetadata(htmlBlob, htmlContent);
const toc = createHTMLTOC(htmlContent);
const spine = createHTMLSpine(htmlContent);
const resources = await extractHTMLResources(htmlBlob, htmlContent);
const totalCharacters = stripHTML(htmlContent).length;
const pageBreaks = generatePageBreaks(totalCharacters);
return {
metadata,
toc,
spine,
resources,
locations: {
totalCharacters,
pageBreaks,
},
};
}
// ============================================================
// Helper Functions
// ============================================================
function extractHTMLMetadata(
htmlBlob: Blob,
htmlContent: string,
): EbookCIF["metadata"] {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, "text/html");
const title =
doc.querySelector("title")?.textContent ||
htmlBlob.name.replace(/\.(html?|htm)$/i, "");
const metaAuthor = doc
.querySelector('meta[name="author"]')
?.getAttribute("content");
const metaLang = doc.querySelector("html")?.getAttribute("lang") || "en";
return {
title,
author: metaAuthor || "Unknown",
language: metaLang,
};
}
function createHTMLTOC(htmlContent: string): EbookCIF["toc"] {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, "text/html");
const toc: EbookCIF["toc"] = [];
// Try to find headings
const headings = doc.querySelectorAll("h1, h2, h3");
let headingIndex = 0;
headings.forEach((heading) => {
toc.push({
id: `heading-${headingIndex}`,
title: heading.textContent || "",
href: `#${heading.id || `heading-${headingIndex}`}`,
children: [],
});
headingIndex++;
});
// If no headings, create single entry
if (toc.length === 0) {
toc.push({
id: "full-document",
title: "Full Document",
href: "#full-document",
children: [],
});
}
return toc;
}
function createHTMLSpine(htmlContent: string): EbookCIF["spine"] {
return [
{
id: "full-document",
type: "html",
content: htmlContent,
index: 0,
},
];
}
async function extractHTMLResources(
htmlBlob: Blob,
htmlContent: string,
): Promise<Map<string, Blob>> {
const resources = new Map<string, Blob>();
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, "text/html");
// Extract images
const images = doc.querySelectorAll("img[src]");
for (const img of Array.from(images)) {
const src = img.getAttribute("src");
if (!src) continue;
// Try to resolve relative URLs
if (src.startsWith("data:")) {
// Data URI - extract blob
const match = src.match(/^data:([^;]+);base64,(.+)$/);
if (match) {
const mimeType = match[1];
const base64 = match[2];
const byteString = atob(base64);
const byteArray = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) {
byteArray[i] = byteString.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: mimeType });
resources.set(src, blob);
}
}
// External resources would need to be fetched
// For now, skip them (browser will load them naturally)
}
return resources;
}
function stripHTML(html: string): string {
const div = document.createElement("div");
div.innerHTML = html;
return div.textContent || "";
}
// ============================================================
// Metadata Quick Extract
// ============================================================
export async function extractHTMLMetadata(
htmlBlob: Blob,
): Promise<Partial<EbookCIF["metadata"]>> {
const htmlContent = await htmlBlob.text();
return extractHTMLMetadata(htmlBlob, htmlContent);
}
+121
View File
@@ -0,0 +1,121 @@
// TXT Parser - Wraps plain text in HTML structure
// Procedural style: Functions, not classes
// ============================================================
// Main Parse Function
// ============================================================
export async function parseTXT(txtBlob: Blob): Promise<EbookCIF> {
const textContent = await txtBlob.text();
const metadata = extractTXTMetadata(txtBlob);
const toc = createTXTTOC(textContent);
const spine = createTXTSpine(textContent);
const resources = new Map(); // No external resources for plain text
const totalCharacters = textContent.length;
return {
metadata,
toc,
spine,
resources,
locations: {
totalCharacters,
estimatedPages: Math.ceil(totalCharacters / 1500),
},
};
}
// ============================================================
// Helper Functions
// ============================================================
function extractTXTMetadata(txtBlob: Blob): EbookCIF["metadata"] {
const filename = txtBlob.name || "Unknown";
return {
title: filename.replace(/\.(txt|text)$/i, ""),
author: "Unknown",
language: "en",
};
}
function createTXTTOC(textContent: string): EbookCIF["toc"] {
// Try to detect chapters (simple heuristic)
const toc: EbookCIF["toc"] = [];
const lines = textContent.split("\n");
let chapterIndex = 0;
lines.forEach((line, index) => {
// Common chapter patterns
const chapterPattern = /^(chapter|part|section)\s+\d+/i;
if (chapterPattern.test(line.trim())) {
toc.push({
id: `chapter-${chapterIndex}`,
title: line.trim(),
href: `#chapter-${chapterIndex}`,
children: [],
});
chapterIndex++;
}
});
// If no chapters found, create single entry
if (toc.length === 0) {
toc.push({
id: "full-text",
title: "Full Text",
href: "#full-text",
children: [],
});
}
return toc;
}
function createTXTSpine(textContent: string): EbookCIF["spine"] {
// Convert plain text to HTML paragraphs
const lines = textContent.split("\n");
let htmlContent = '<div class="txt-content">';
lines.forEach((line) => {
const trimmed = line.trim();
if (trimmed) {
htmlContent += `<p>${escapeHTML(trimmed)}</p>`;
} else {
htmlContent += "<br />";
}
});
htmlContent += "</div>";
return [
{
id: "full-text",
type: "html",
content: htmlContent,
index: 0,
},
];
}
function escapeHTML(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// Removed - backend handles detailed position tracking
// ============================================================
// Metadata Quick Extract
// ============================================================
export async function extractTXTMetadata(
txtBlob: Blob,
): Promise<Partial<EbookCIF["metadata"]>> {
return extractTXTMetadata(txtBlob);
}