Release EPUB Tools 0.1.0

This commit is contained in:
2026-09-01 02:39:03 +02:00
commit f68dcfe4ea
66 changed files with 10579 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
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];
}
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 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");
return {
package: {
version: root.getAttribute("version") ?? "",
uniqueIdentifier: root.getAttribute("unique-identifier") ?? undefined,
packagePath,
packageDirectory,
metadata,
manifest,
spine,
navigation: [],
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);
}