import type { EpubMetadata, EpubPackage, ManifestItem, NavigationItem, SpineItem, } from "./types"; import { directoryOf, resolvePackageHref, splitReference } from "./paths"; import { directChildrenByLocalName, elementsByLocalName, parseXml, } from "./xml"; function text(element: Element | undefined): string { return element?.textContent?.trim() ?? ""; } function firstDirect(parent: Element, name: string): Element | undefined { return directChildrenByLocalName(parent, name)[0]; } function enumeration( value: string | null | undefined, allowed: readonly T[], fallback: T, ): T { const normalized = value?.trim().toLowerCase() as T | undefined; return normalized && allowed.includes(normalized) ? normalized : fallback; } export function parsePackageDocument( packageXml: string, packagePath: string, ): { package: EpubPackage; document: XMLDocument } { const document = parseXml(packageXml, packagePath); const root = document.documentElement; if (root.localName !== "package") throw new Error("The rootfile is not an EPUB package document."); const metadataElement = firstDirect(root, "metadata"); const manifestElement = firstDirect(root, "manifest"); const spineElement = firstDirect(root, "spine"); if (!metadataElement || !manifestElement || !spineElement) throw new Error( "The package document must contain metadata, manifest, and spine elements.", ); const metadata: EpubMetadata = { title: text(elementsByLocalName(metadataElement, "title")[0]), creators: elementsByLocalName(metadataElement, "creator") .map((item) => text(item)) .filter(Boolean), language: text(elementsByLocalName(metadataElement, "language")[0]), identifier: text(elementsByLocalName(metadataElement, "identifier")[0]), publisher: text(elementsByLocalName(metadataElement, "publisher")[0]), date: text(elementsByLocalName(metadataElement, "date")[0]), description: text(elementsByLocalName(metadataElement, "description")[0]), rights: text(elementsByLocalName(metadataElement, "rights")[0]), subjects: elementsByLocalName(metadataElement, "subject") .map((item) => text(item)) .filter(Boolean), }; const uniqueIdentifier = root.getAttribute("unique-identifier") ?? undefined; const identifierElements = elementsByLocalName(metadataElement, "identifier"); const uniqueIdentifierValue = identifierElements .find((element) => element.getAttribute("id") === uniqueIdentifier) ?.textContent?.trim() ?? metadata.identifier; const packageDirectory = directoryOf(packagePath); const manifest: ManifestItem[] = directChildrenByLocalName( manifestElement, "item", ).map((element) => { const href = element.getAttribute("href") ?? ""; return { id: element.getAttribute("id") ?? "", href, path: resolvePackageHref(packagePath, href) ?? "", mediaType: element.getAttribute("media-type") ?? "", properties: (element.getAttribute("properties") ?? "") .split(/\s+/u) .filter(Boolean), fallback: element.getAttribute("fallback") ?? undefined, mediaOverlay: element.getAttribute("media-overlay") ?? undefined, }; }); const byId = new Map(manifest.map((item) => [item.id, item])); const spine: SpineItem[] = directChildrenByLocalName( spineElement, "itemref", ).map((element) => { const idref = element.getAttribute("idref") ?? ""; return { idref, linear: element.getAttribute("linear") !== "no", properties: (element.getAttribute("properties") ?? "") .split(/\s+/u) .filter(Boolean), item: byId.get(idref), }; }); const coverId = elementsByLocalName(metadataElement, "meta") .find((element) => element.getAttribute("name") === "cover") ?.getAttribute("content"); const cover = manifest.find((item) => item.properties.includes("cover-image")) ?? (coverId ? byId.get(coverId) : undefined); const nav = manifest.find((item) => item.properties.includes("nav")); const ncxId = spineElement.getAttribute("toc") ?? undefined; const ncx = (ncxId ? byId.get(ncxId) : undefined) ?? manifest.find((item) => item.mediaType === "application/x-dtbncx+xml"); const rendition = new Map( elementsByLocalName(metadataElement, "meta") .map( (element) => [element.getAttribute("property") ?? "", text(element)] as const, ) .filter(([property]) => property.startsWith("rendition:")), ); return { package: { version: root.getAttribute("version") ?? "", uniqueIdentifier, uniqueIdentifierValue, packagePath, packageDirectory, metadata, manifest, spine, navigation: [], direction: enumeration( root.getAttribute("dir"), ["ltr", "rtl", "auto"], "auto", ), pageProgressionDirection: enumeration( spineElement.getAttribute("page-progression-direction"), ["ltr", "rtl", "default"], "default", ), rendition: { layout: enumeration( rendition.get("rendition:layout"), ["reflowable", "pre-paginated"], "reflowable", ), orientation: enumeration( rendition.get("rendition:orientation"), ["auto", "landscape", "portrait"], "auto", ), spread: enumeration( rendition.get("rendition:spread"), ["auto", "none", "landscape", "portrait", "both"], "auto", ), }, cover, nav, ncx, }, document, }; } function navigationChildren(list: Element, basePath: string): NavigationItem[] { return directChildrenByLocalName(list, "li").map((item) => { const anchor = directChildrenByLocalName(item, "a")[0]; const labelNode = anchor ?? directChildrenByLocalName(item, "span")[0]; const href = anchor?.getAttribute("href") ?? ""; const nested = directChildrenByLocalName(item, "ol")[0]; let path: string | undefined; try { path = href ? resolvePackageHref(basePath, href) : undefined; } catch { path = undefined; } return { label: text(labelNode) || "Untitled", href, path, fragment: splitReference(href).fragment || undefined, children: nested ? navigationChildren(nested, basePath) : [], }; }); } export function parseNavigationDocument( xml: string, path: string, ): NavigationItem[] { const document = parseXml(xml, path); const navs = elementsByLocalName(document, "nav"); const toc = navs.find((element) => ( element.getAttribute("epub:type") ?? element.getAttributeNS("http://www.idpf.org/2007/ops", "type") ?? "" ) .split(/\s+/u) .includes("toc"), ) ?? navs[0]; if (!toc) return []; const list = elementsByLocalName(toc, "ol")[0]; return list ? navigationChildren(list, path) : []; } export function parseNcxDocument(xml: string, path: string): NavigationItem[] { const document = parseXml(xml, path); const navMap = elementsByLocalName(document, "navMap")[0]; if (!navMap) return []; const parsePoints = (parent: Element): NavigationItem[] => directChildrenByLocalName(parent, "navPoint").map((point) => { const label = text(elementsByLocalName(point, "text")[0]) || "Untitled"; const href = elementsByLocalName(point, "content")[0]?.getAttribute("src") ?? ""; let target: string | undefined; try { target = href ? resolvePackageHref(path, href) : undefined; } catch { target = undefined; } return { label, href, path: target, fragment: splitReference(href).fragment || undefined, children: parsePoints(point), }; }); return parsePoints(navMap); }