233 lines
7.7 KiB
TypeScript
233 lines
7.7 KiB
TypeScript
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<T extends string>(
|
|
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);
|
|
}
|