rspace-online/modules/rpubs/epub-gen.ts

448 lines
14 KiB
TypeScript

/**
* EPUB3 generator — reflowable (from markdown) + fixed-layout (from Typst PNGs).
*
* Produces valid EPUB3 packages using JSZip (already a dep). No external tools.
*
* Reflowable mode: markdown sections → styled xhtml chapters. Resizable text,
* tiny file, reads well on any device.
*
* Fixed-layout mode: each page of the compiled Typst PDF is embedded as a PNG
* image in a pre-paginated xhtml page — preserves the pocket-book design 1:1.
*/
import JSZip from "jszip";
import { marked } from "marked";
import { randomUUID } from "node:crypto";
import type { ParsedDocument, DocumentSection, DocumentBlock } from "./parse-document";
import type { PageImage } from "./typst-compile";
import { getFormat } from "./formats";
// ── Shared helpers ──
function esc(s: string): string {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function slug(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "doc";
}
function containerXml(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`;
}
interface ManifestItem {
id: string;
href: string;
mediaType: string;
properties?: string;
inSpine?: boolean;
spineProperties?: string;
}
function buildPackageOpf(opts: {
uuid: string;
title: string;
author: string;
language: string;
description?: string;
items: ManifestItem[];
fixedLayout?: boolean;
coverImageId?: string;
}): string {
const { uuid, title, author, language, description, items, fixedLayout, coverImageId } = opts;
const now = new Date().toISOString().replace(/\.\d+Z$/, "Z");
const manifest = items
.map((i) => {
const attrs = [
`id="${esc(i.id)}"`,
`href="${esc(i.href)}"`,
`media-type="${esc(i.mediaType)}"`,
];
if (i.properties) attrs.push(`properties="${esc(i.properties)}"`);
return ` <item ${attrs.join(" ")}/>`;
})
.join("\n");
const spine = items
.filter((i) => i.inSpine)
.map((i) => {
const attrs = [`idref="${esc(i.id)}"`];
if (i.spineProperties) attrs.push(`properties="${esc(i.spineProperties)}"`);
return ` <itemref ${attrs.join(" ")}/>`;
})
.join("\n");
const layoutMeta = fixedLayout
? `
<meta property="rendition:layout">pre-paginated</meta>
<meta property="rendition:orientation">auto</meta>
<meta property="rendition:spread">auto</meta>`
: "";
const coverMeta = coverImageId
? `\n <meta name="cover" content="${esc(coverImageId)}"/>`
: "";
return `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id" xml:lang="${esc(language)}">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="pub-id">urn:uuid:${esc(uuid)}</dc:identifier>
<dc:title>${esc(title)}</dc:title>
<dc:creator>${esc(author)}</dc:creator>
<dc:language>${esc(language)}</dc:language>
${description ? `<dc:description>${esc(description)}</dc:description>` : ""}
<meta property="dcterms:modified">${now}</meta>${layoutMeta}${coverMeta}
</metadata>
<manifest>
${manifest}
</manifest>
<spine>
${spine}
</spine>
</package>`;
}
function buildNav(items: { href: string; title: string }[], language: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${esc(language)}">
<head><meta charset="utf-8"/><title>Contents</title></head>
<body>
<nav epub:type="toc" id="toc"><h1>Contents</h1>
<ol>
${items.map((i) => ` <li><a href="${esc(i.href)}">${esc(i.title)}</a></li>`).join("\n")}
</ol>
</nav>
</body>
</html>`;
}
function buildNcx(opts: { uuid: string; title: string; items: { href: string; title: string }[] }): string {
const { uuid, title, items } = opts;
return `<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head>
<meta name="dtb:uid" content="urn:uuid:${esc(uuid)}"/>
<meta name="dtb:depth" content="1"/>
<meta name="dtb:totalPageCount" content="0"/>
<meta name="dtb:maxPageNumber" content="0"/>
</head>
<docTitle><text>${esc(title)}</text></docTitle>
<navMap>
${items
.map(
(it, i) => ` <navPoint id="np-${i + 1}" playOrder="${i + 1}">
<navLabel><text>${esc(it.title)}</text></navLabel>
<content src="${esc(it.href)}"/>
</navPoint>`,
)
.join("\n")}
</navMap>
</ncx>`;
}
async function finalizeZip(zip: JSZip): Promise<Buffer> {
const data = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 9 },
mimeType: "application/epub+zip",
});
return data as Buffer;
}
function seedEpubSkeleton(): JSZip {
const zip = new JSZip();
// mimetype MUST be first and uncompressed.
zip.file("mimetype", "application/epub+zip", { compression: "STORE" });
zip.folder("META-INF")!.file("container.xml", containerXml());
return zip;
}
// ── Reflowable ──
function renderBlockToHtml(block: DocumentBlock): string {
switch (block.type) {
case "paragraph":
return `<p>${marked.parseInline(block.text) as string}</p>`;
case "quote": {
const inner = marked.parseInline(block.text) as string;
const attrib = block.attribution
? `<footer>— ${esc(block.attribution)}</footer>`
: "";
return `<blockquote><p>${inner}</p>${attrib}</blockquote>`;
}
case "list": {
const tag = block.ordered ? "ol" : "ul";
const items = block.items
.map((i) => `<li>${marked.parseInline(i) as string}</li>`)
.join("");
return `<${tag}>${items}</${tag}>`;
}
case "code": {
const lang = block.language ? ` class="language-${esc(block.language)}"` : "";
return `<pre><code${lang}>${esc(block.code)}</code></pre>`;
}
case "separator":
return `<hr/>`;
}
}
function renderSectionXhtml(section: DocumentSection, language: string): string {
const body = [
section.heading
? `<h${Math.min(section.level || 2, 6)}>${esc(section.heading)}</h${Math.min(section.level || 2, 6)}>`
: "",
...section.blocks.map(renderBlockToHtml),
]
.filter(Boolean)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${esc(language)}">
<head>
<meta charset="utf-8"/>
<title>${esc(section.heading || "Section")}</title>
<link rel="stylesheet" type="text/css" href="styles/book.css"/>
</head>
<body>
${body}
</body>
</html>`;
}
const REFLOWABLE_CSS = `@namespace epub "http://www.idpf.org/2007/ops";
html, body { margin: 0; padding: 0; }
body { font-family: Georgia, "Times New Roman", serif; line-height: 1.55; padding: 0 1em; }
h1, h2, h3, h4 { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; line-height: 1.25; margin: 1.2em 0 0.5em; }
h1 { font-size: 1.6em; }
h2 { font-size: 1.35em; }
h3 { font-size: 1.15em; }
p { margin: 0.6em 0; text-align: justify; hyphens: auto; -webkit-hyphens: auto; }
blockquote { margin: 1em 1.5em; font-style: italic; color: #444; border-left: 2px solid #999; padding-left: 0.8em; }
blockquote footer { font-style: normal; font-size: 0.9em; color: #666; margin-top: 0.3em; }
pre { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.85em; background: #f4f4f4; padding: 0.6em 0.8em; border-radius: 3px; overflow-x: auto; white-space: pre-wrap; }
code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; }
ul, ol { margin: 0.6em 0 0.6em 1.5em; }
li { margin: 0.2em 0; }
hr { border: none; border-top: 1px solid #ccc; margin: 2em auto; width: 40%; }
img { max-width: 100%; height: auto; }
.title-page { text-align: center; padding-top: 20%; }
.title-page h1 { font-size: 2.2em; margin-bottom: 0.3em; }
.title-page .author { font-style: italic; color: #555; margin-top: 1em; }`;
export async function buildReflowableEpub(opts: {
document: ParsedDocument;
language?: string;
description?: string;
}): Promise<Buffer> {
const { document, language = "en", description } = opts;
const uuid = randomUUID();
const zip = seedEpubSkeleton();
const oebps = zip.folder("OEBPS")!;
oebps.folder("styles")!.file("book.css", REFLOWABLE_CSS);
// Title page
const titlePage = `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="${esc(language)}">
<head><meta charset="utf-8"/><title>${esc(document.title)}</title><link rel="stylesheet" type="text/css" href="styles/book.css"/></head>
<body><div class="title-page"><h1>${esc(document.title)}</h1>${document.author ? `<div class="author">${esc(document.author)}</div>` : ""}</div></body>
</html>`;
oebps.file("title.xhtml", titlePage);
const navItems: { href: string; title: string }[] = [
{ href: "title.xhtml", title: document.title },
];
const manifestItems: ManifestItem[] = [
{ id: "css", href: "styles/book.css", mediaType: "text/css" },
{ id: "title", href: "title.xhtml", mediaType: "application/xhtml+xml", inSpine: true },
];
document.sections.forEach((section, idx) => {
const href = `sections/section-${String(idx + 1).padStart(3, "0")}.xhtml`;
oebps.folder("sections")!.file(
`section-${String(idx + 1).padStart(3, "0")}.xhtml`,
renderSectionXhtml(section, language),
);
manifestItems.push({
id: `sec${idx + 1}`,
href,
mediaType: "application/xhtml+xml",
inSpine: true,
});
if (section.heading) navItems.push({ href, title: section.heading });
});
// nav.xhtml (EPUB3) + toc.ncx (back-compat)
oebps.file("nav.xhtml", buildNav(navItems, language));
manifestItems.push({
id: "nav",
href: "nav.xhtml",
mediaType: "application/xhtml+xml",
properties: "nav",
});
oebps.file("toc.ncx", buildNcx({ uuid, title: document.title, items: navItems }));
manifestItems.push({ id: "ncx", href: "toc.ncx", mediaType: "application/x-dtbncx+xml" });
oebps.file(
"content.opf",
buildPackageOpf({
uuid,
title: document.title,
author: document.author || "Unknown",
language,
description,
items: manifestItems,
}),
);
return finalizeZip(zip);
}
// ── Fixed-layout ──
const FIXED_CSS = `@namespace epub "http://www.idpf.org/2007/ops";
html, body { margin: 0; padding: 0; overflow: hidden; }
.page { position: absolute; inset: 0; width: 100%; height: 100%; }
.page img { width: 100%; height: 100%; object-fit: contain; display: block; }`;
function renderFixedPageXhtml(opts: {
imagePath: string;
width: number;
height: number;
pageNum: number;
language: string;
}): string {
const { imagePath, width, height, pageNum, language } = opts;
return `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="${esc(language)}">
<head>
<meta charset="utf-8"/>
<title>Page ${pageNum}</title>
<meta name="viewport" content="width=${width}, height=${height}"/>
<link rel="stylesheet" type="text/css" href="styles/page.css"/>
</head>
<body>
<div class="page"><img src="${esc(imagePath)}" alt="Page ${pageNum}"/></div>
</body>
</html>`;
}
export async function buildFixedLayoutEpub(opts: {
document: ParsedDocument;
pages: PageImage[];
formatId: string;
language?: string;
description?: string;
}): Promise<Buffer> {
const { document, pages, formatId, language = "en", description } = opts;
if (pages.length === 0) {
throw new Error("No pages to package");
}
const uuid = randomUUID();
const zip = seedEpubSkeleton();
const oebps = zip.folder("OEBPS")!;
oebps.folder("styles")!.file("page.css", FIXED_CSS);
const manifestItems: ManifestItem[] = [
{ id: "css", href: "styles/page.css", mediaType: "text/css" },
];
const navItems: { href: string; title: string }[] = [];
pages.forEach((page, i) => {
const num = i + 1;
const pad = String(num).padStart(4, "0");
const imgHref = `images/page-${pad}.png`;
const xhtmlHref = `pages/page-${pad}.xhtml`;
oebps.folder("images")!.file(`page-${pad}.png`, page.png);
oebps.folder("pages")!.file(
`page-${pad}.xhtml`,
renderFixedPageXhtml({
imagePath: `../${imgHref}`,
width: page.widthPx,
height: page.heightPx,
pageNum: num,
language,
}),
);
manifestItems.push({
id: `img-${pad}`,
href: imgHref,
mediaType: "image/png",
properties: i === 0 ? "cover-image" : undefined,
});
manifestItems.push({
id: `page-${pad}`,
href: xhtmlHref,
mediaType: "application/xhtml+xml",
inSpine: true,
spineProperties: i === 0 ? undefined : num % 2 === 0 ? "page-spread-left" : "page-spread-right",
});
if (i === 0) navItems.push({ href: xhtmlHref, title: document.title });
});
// Supplemental TOC entries for long docs
if (pages.length > 4) {
navItems.push({
href: `pages/page-${String(Math.floor(pages.length / 2)).padStart(4, "0")}.xhtml`,
title: "Middle",
});
navItems.push({
href: `pages/page-${String(pages.length).padStart(4, "0")}.xhtml`,
title: "End",
});
}
oebps.file("nav.xhtml", buildNav(navItems, language));
manifestItems.push({
id: "nav",
href: "nav.xhtml",
mediaType: "application/xhtml+xml",
properties: "nav",
});
oebps.file("toc.ncx", buildNcx({ uuid, title: document.title, items: navItems }));
manifestItems.push({ id: "ncx", href: "toc.ncx", mediaType: "application/x-dtbncx+xml" });
const fmt = getFormat(formatId);
const fmtDesc = fmt ? `${fmt.name} fixed-layout EPUB from rPubs` : undefined;
oebps.file(
"content.opf",
buildPackageOpf({
uuid,
title: document.title,
author: document.author || "Unknown",
language,
description: description || fmtDesc,
items: manifestItems,
fixedLayout: true,
coverImageId: "img-0001",
}),
);
return finalizeZip(zip);
}