448 lines
14 KiB
TypeScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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);
|
|
}
|