/**
* 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, "'");
}
function slug(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || "doc";
}
function containerXml(): string {
return `
`;
}
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 ` `;
})
.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 ` `;
})
.join("\n");
const layoutMeta = fixedLayout
? `
pre-paginated
auto
auto`
: "";
const coverMeta = coverImageId
? `\n `
: "";
return `
urn:uuid:${esc(uuid)}
${esc(title)}
${esc(author)}
${esc(language)}
${description ? `${esc(description)}` : ""}
${now}${layoutMeta}${coverMeta}
${manifest}
${spine}
`;
}
function buildNav(items: { href: string; title: string }[], language: string): string {
return `
Contents
`;
}
function buildNcx(opts: { uuid: string; title: string; items: { href: string; title: string }[] }): string {
const { uuid, title, items } = opts;
return `
${esc(title)}
${items
.map(
(it, i) => `
${esc(it.title)}
`,
)
.join("\n")}
`;
}
async function finalizeZip(zip: JSZip): Promise {
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 `${marked.parseInline(block.text) as string}
`;
case "quote": {
const inner = marked.parseInline(block.text) as string;
const attrib = block.attribution
? ``
: "";
return `${inner}
${attrib}
`;
}
case "list": {
const tag = block.ordered ? "ol" : "ul";
const items = block.items
.map((i) => `${marked.parseInline(i) as string}`)
.join("");
return `<${tag}>${items}${tag}>`;
}
case "code": {
const lang = block.language ? ` class="language-${esc(block.language)}"` : "";
return `${esc(block.code)}
`;
}
case "separator":
return `
`;
}
}
function renderSectionXhtml(section: DocumentSection, language: string): string {
const body = [
section.heading
? `${esc(section.heading)}`
: "",
...section.blocks.map(renderBlockToHtml),
]
.filter(Boolean)
.join("\n");
return `
${esc(section.heading || "Section")}
${body}
`;
}
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 {
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 = `
${esc(document.title)}
${esc(document.title)}
${document.author ? `
${esc(document.author)}
` : ""}
`;
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 `
Page ${pageNum}
})
`;
}
export async function buildFixedLayoutEpub(opts: {
document: ParsedDocument;
pages: PageImage[];
formatId: string;
language?: string;
description?: string;
}): Promise {
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);
}