mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
feat(epub): add EPUB3 generator and zip packager
This commit is contained in:
1 parent
4443eda401
commit
b1bd42fe2f
5 files changed
+730
No files matched your search
@@ -0,0 +1,412 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Book } from "../model/book";
|
||||
|
||||
export interface EpubFile {
|
||||
path: string;
|
||||
data: string;
|
||||
encoding: "utf8" | "base64";
|
||||
}
|
||||
|
||||
function esc(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function attr(text: string): string {
|
||||
return esc(text).replace(/"/g, """);
|
||||
}
|
||||
|
||||
function slug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function imageExtension(dataUrl: string): string {
|
||||
const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl);
|
||||
const kind = (match?.[1] ?? "png").toLowerCase();
|
||||
return kind === "jpeg" ? "jpg" : kind;
|
||||
}
|
||||
|
||||
function mediaType(path: string): string {
|
||||
if (path.endsWith(".jpg")) return "image/jpeg";
|
||||
if (path.endsWith(".png")) return "image/png";
|
||||
if (path.endsWith(".gif")) return "image/gif";
|
||||
if (path.endsWith(".webp")) return "image/webp";
|
||||
if (path.endsWith(".svg")) return "image/svg+xml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function inline(node: JSONContent): string {
|
||||
if (node.type === "text") {
|
||||
let text = esc(node.text ?? "");
|
||||
const marks = (node.marks ?? []).map((m) => m.type);
|
||||
if (marks.includes("bold")) text = `<strong>${text}</strong>`;
|
||||
if (marks.includes("italic")) text = `<em>${text}</em>`;
|
||||
return text;
|
||||
}
|
||||
if (node.type === "hardBreak") return "<br/>";
|
||||
return "";
|
||||
}
|
||||
|
||||
function inlines(content: JSONContent[] = []): string {
|
||||
return content.map(inline).join("");
|
||||
}
|
||||
|
||||
function listItem(item: JSONContent): string {
|
||||
const inner = (item.content ?? [])
|
||||
.filter((c) => c.type === "paragraph")
|
||||
.map((p) => inlines(p.content))
|
||||
.join(" ");
|
||||
return `<li>${inner}</li>`;
|
||||
}
|
||||
|
||||
function figure(node: JSONContent, paths: Map<string, string>): string {
|
||||
const path = paths.get(node.attrs?.src);
|
||||
if (!path) return "";
|
||||
const placement = node.attrs?.placement ?? "inline";
|
||||
const alt = attr(node.attrs?.alt ?? "");
|
||||
const caption = node.attrs?.caption
|
||||
? `<figcaption>${esc(node.attrs.caption)}</figcaption>`
|
||||
: "";
|
||||
return `<figure class="placement-${attr(placement)}"><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`;
|
||||
}
|
||||
|
||||
function block(node: JSONContent, paths: Map<string, string>): string {
|
||||
switch (node.type) {
|
||||
case "paragraph":
|
||||
return `<p>${inlines(node.content)}</p>`;
|
||||
case "heading":
|
||||
return node.attrs?.level === 3
|
||||
? `<h3>${inlines(node.content)}</h3>`
|
||||
: `<h2>${inlines(node.content)}</h2>`;
|
||||
case "blockquote":
|
||||
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
|
||||
case "bulletList":
|
||||
return `<ul>${(node.content ?? []).map(listItem).join("")}</ul>`;
|
||||
case "orderedList":
|
||||
return `<ol>${(node.content ?? []).map(listItem).join("")}</ol>`;
|
||||
case "horizontalRule":
|
||||
return `<hr class="scene-break"/>`;
|
||||
case "figure":
|
||||
return figure(node, paths);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function chapterBody(content: JSONContent, paths: Map<string, string>): string {
|
||||
return (content.content ?? [])
|
||||
.map((n) => block(n, paths))
|
||||
.filter((s) => s.length > 0)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function extractFigures(book: Book): { assets: EpubFile[]; paths: Map<string, string> } {
|
||||
const paths = new Map<string, string>();
|
||||
const assets: EpubFile[] = [];
|
||||
|
||||
const visit = (node: JSONContent) => {
|
||||
if (node.type === "figure") {
|
||||
const src: string | undefined = node.attrs?.src;
|
||||
if (src && src.startsWith("data:") && !paths.has(src)) {
|
||||
const path = `assets/figure-${assets.length + 1}.${imageExtension(src)}`;
|
||||
paths.set(src, path);
|
||||
assets.push({
|
||||
path: `OEBPS/${path}`,
|
||||
data: src.slice(src.indexOf(",") + 1),
|
||||
encoding: "base64",
|
||||
});
|
||||
}
|
||||
}
|
||||
(node.content ?? []).forEach(visit);
|
||||
};
|
||||
|
||||
book.chapters.forEach((chapter) => visit(chapter.content));
|
||||
return { assets, paths };
|
||||
}
|
||||
|
||||
function identifier(book: Book): string {
|
||||
if (book.metadata.isbn) return book.metadata.isbn;
|
||||
const seed = slug(book.metadata.title || "untitled") || "untitled";
|
||||
return `urn:uuid:margin-${seed}`;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
function contentOpf(book: Book, chapterIds: string[], assetPaths: string[]): string {
|
||||
const meta = book.metadata;
|
||||
const manifest: string[] = [
|
||||
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
|
||||
`<item id="css" href="style.css" media-type="text/css"/>`,
|
||||
`<item id="font-regular" href="fonts/Literata-VF.ttf" media-type="font/ttf"/>`,
|
||||
`<item id="font-italic" href="fonts/Literata-Italic-VF.ttf" media-type="font/ttf"/>`,
|
||||
];
|
||||
chapterIds.forEach((_, i) => {
|
||||
manifest.push(
|
||||
`<item id="chapter-${i + 1}" href="chapter-${i + 1}.xhtml" media-type="application/xhtml+xml"/>`,
|
||||
);
|
||||
});
|
||||
assetPaths.forEach((path, i) => {
|
||||
manifest.push(
|
||||
`<item id="asset-${i + 1}" href="${attr(path)}" media-type="${mediaType(path)}"/>`,
|
||||
);
|
||||
});
|
||||
|
||||
const spine = [`<itemref idref="nav" linear="no"/>`]
|
||||
.concat(chapterIds.map((_, i) => `<itemref idref="chapter-${i + 1}"/>`))
|
||||
.join("\n ");
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="book-id" xml:lang="${attr(meta.language || "en")}">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="book-id">${esc(identifier(book))}</dc:identifier>
|
||||
<dc:title>${esc(meta.title || "Untitled")}</dc:title>
|
||||
<dc:creator>${esc(meta.author || "")}</dc:creator>
|
||||
<dc:language>${esc(meta.language || "en")}</dc:language>
|
||||
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
${manifest.join("\n ")}
|
||||
</manifest>
|
||||
<spine>
|
||||
${spine}
|
||||
</spine>
|
||||
</package>`;
|
||||
}
|
||||
|
||||
function navXhtml(book: Book): string {
|
||||
const items = book.chapters
|
||||
.map(
|
||||
(chapter, i) =>
|
||||
` <li><a href="chapter-${i + 1}.xhtml">${esc(chapter.title || `Chapter ${i + 1}`)}</a></li>`,
|
||||
)
|
||||
.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" lang="${attr(book.metadata.language || "en")}">
|
||||
<head>
|
||||
<title>${esc(book.metadata.title || "Untitled")}</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<nav epub:type="toc" id="toc">
|
||||
<h1>Contents</h1>
|
||||
<ol>
|
||||
${items}
|
||||
</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function chapterXhtml(book: Book, index: number, paths: Map<string, string>): string {
|
||||
const chapter = book.chapters[index];
|
||||
const title = chapter.title || `Chapter ${index + 1}`;
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${attr(book.metadata.language || "en")}">
|
||||
<head>
|
||||
<title>${esc(title)}</title>
|
||||
<link rel="stylesheet" type="text/css" href="style.css"/>
|
||||
</head>
|
||||
<body epub:type="chapter">
|
||||
<header class="chapter-opener">
|
||||
<p class="eyebrow">Chapter ${index + 1}</p>
|
||||
<h1>${esc(title)}</h1>
|
||||
</header>
|
||||
${chapterBody(chapter.content, paths)}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function styleCss(): string {
|
||||
return `@font-face {
|
||||
font-family: "Literata";
|
||||
font-style: normal;
|
||||
font-weight: 300 700;
|
||||
src: url("fonts/Literata-VF.ttf");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Literata";
|
||||
font-style: italic;
|
||||
font-weight: 300 700;
|
||||
src: url("fonts/Literata-Italic-VF.ttf");
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: "Literata", Georgia, serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0 5%;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
text-align: justify;
|
||||
text-indent: 1.3em;
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
-epub-hyphens: auto;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
text-indent: 0;
|
||||
hyphens: none;
|
||||
line-height: 1.25;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
h2, h3 {
|
||||
margin: 1.4em 0 0.6em;
|
||||
}
|
||||
|
||||
p + p {
|
||||
text-indent: 1.3em;
|
||||
}
|
||||
|
||||
.chapter-opener {
|
||||
text-align: center;
|
||||
margin: 3em 0 2.2em;
|
||||
}
|
||||
|
||||
.chapter-opener p,
|
||||
header + p,
|
||||
h2 + p,
|
||||
h3 + p,
|
||||
blockquote + p,
|
||||
figure + p,
|
||||
hr + p {
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.28em;
|
||||
font-size: 0.72em;
|
||||
margin: 0 0 0.7em;
|
||||
}
|
||||
|
||||
.chapter-opener h1 {
|
||||
font-size: 1.7em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1.2em 1.6em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
text-indent: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 1em 0;
|
||||
padding-left: 1.6em;
|
||||
}
|
||||
|
||||
li {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
hr.scene-break {
|
||||
border: 0;
|
||||
margin: 1.6em 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
hr.scene-break::before {
|
||||
content: "\\2042";
|
||||
letter-spacing: 0.4em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 1.4em 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
figure img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
font-size: 0.82em;
|
||||
font-style: italic;
|
||||
text-indent: 0;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
figure.placement-full-width img,
|
||||
figure.placement-full-page img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
figure.placement-full-page {
|
||||
margin: 0;
|
||||
page-break-before: always;
|
||||
page-break-after: always;
|
||||
}
|
||||
|
||||
figure.placement-float-top {
|
||||
float: top;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
export function bookToEpub(book: Book): EpubFile[] {
|
||||
const { assets, paths } = extractFigures(book);
|
||||
const chapterIds = book.chapters.map((c) => c.id);
|
||||
const assetPaths = Array.from(paths.values());
|
||||
|
||||
const files: EpubFile[] = [
|
||||
{ path: "mimetype", data: "application/epub+zip", encoding: "utf8" },
|
||||
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
|
||||
{ path: "OEBPS/content.opf", data: contentOpf(book, chapterIds, assetPaths), encoding: "utf8" },
|
||||
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
|
||||
{ path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" },
|
||||
];
|
||||
|
||||
book.chapters.forEach((_, i) => {
|
||||
files.push({
|
||||
path: `OEBPS/chapter-${i + 1}.xhtml`,
|
||||
data: chapterXhtml(book, i, paths),
|
||||
encoding: "utf8",
|
||||
});
|
||||
});
|
||||
|
||||
files.push(...assets);
|
||||
return files;
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function fontFile(url: string, path: string): Promise<EpubFile> {
|
||||
const response = await fetch(url);
|
||||
const buffer = await response.arrayBuffer();
|
||||
return { path, data: toBase64(new Uint8Array(buffer)), encoding: "base64" };
|
||||
}
|
||||
|
||||
export async function buildEpub(book: Book): Promise<Uint8Array> {
|
||||
const files = bookToEpub(book);
|
||||
files.push(
|
||||
await fontFile("/fonts/Literata-VF.ttf", "OEBPS/fonts/Literata-VF.ttf"),
|
||||
await fontFile("/fonts/Literata-Italic-VF.ttf", "OEBPS/fonts/Literata-Italic-VF.ttf"),
|
||||
);
|
||||
const buf = await invoke<ArrayBuffer>("package_epub", { files });
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
Reference in new issue
Block a user