mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
feat(fonts): embed selected fonts in EPUB export
Embed each bundled font the book uses (fetched in the webview) with its own @font-face, point body text and headings at the chosen families, and drop the hardcoded Literata injection in the packager. System fonts are referenced by name with a serif fallback since they cannot be embedded.
This commit is contained in:
1 parent
7a2d7c6aff
commit
baf1154683
2 files changed
+63
-35
No files matched your search
@@ -5,9 +5,6 @@ use std::io::{Read, Write};
|
|||||||
use zip::write::SimpleFileOptions;
|
use zip::write::SimpleFileOptions;
|
||||||
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
use zip::{CompressionMethod, ZipArchive, ZipWriter};
|
||||||
|
|
||||||
static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf");
|
|
||||||
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct EpubFile {
|
pub struct EpubFile {
|
||||||
path: String,
|
path: String,
|
||||||
@@ -48,14 +45,6 @@ fn build(files: &[EpubFile]) -> Result<Vec<u8>, String> {
|
|||||||
zip.write_all(&bytes).map_err(|e| e.to_string())?;
|
zip.write_all(&bytes).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (path, bytes) in [
|
|
||||||
("OEBPS/fonts/Literata-VF.ttf", LITERATA),
|
|
||||||
("OEBPS/fonts/Literata-Italic-VF.ttf", LITERATA_ITALIC),
|
|
||||||
] {
|
|
||||||
zip.start_file(path, deflated).map_err(|e| e.to_string())?;
|
|
||||||
zip.write_all(bytes).map_err(|e| e.to_string())?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
|
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+63
-24
@@ -1,6 +1,7 @@
|
|||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
||||||
|
import { fontStack, fontsUsed } from "../model/fonts";
|
||||||
|
|
||||||
export interface EpubFile {
|
export interface EpubFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -38,6 +39,56 @@ function mediaType(path: string): string {
|
|||||||
return "application/octet-stream";
|
return "application/octet-stream";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FontAssets {
|
||||||
|
files: EpubFile[];
|
||||||
|
faces: string;
|
||||||
|
manifest: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64FromBuffer(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = "";
|
||||||
|
const chunk = 0x8000;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunk) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFontBase64(file: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/fonts/${file}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return base64FromBuffer(await res.arrayBuffer());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFontAssets(book: Book): Promise<FontAssets> {
|
||||||
|
const used = fontsUsed(book.settings.fonts).bundled;
|
||||||
|
const files: EpubFile[] = [];
|
||||||
|
const faces: string[] = [];
|
||||||
|
const manifest: string[] = [];
|
||||||
|
let n = 0;
|
||||||
|
for (const font of used) {
|
||||||
|
const variants = [{ file: font.regular, style: "normal" }];
|
||||||
|
if (font.italic) variants.push({ file: font.italic, style: "italic" });
|
||||||
|
for (const variant of variants) {
|
||||||
|
const data = await fetchFontBase64(variant.file);
|
||||||
|
if (!data) continue;
|
||||||
|
const href = `fonts/${variant.file}`;
|
||||||
|
const id = `font-${++n}`;
|
||||||
|
files.push({ path: `OEBPS/${href}`, data, encoding: "base64" });
|
||||||
|
manifest.push(`<item id="${id}" href="${attr(href)}" media-type="font/ttf"/>`);
|
||||||
|
faces.push(
|
||||||
|
`@font-face {\n font-family: "${font.family}";\n font-style: ${variant.style};\n font-weight: ${font.weight};\n src: url("${href}");\n}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { files, faces: faces.join("\n\n"), manifest };
|
||||||
|
}
|
||||||
|
|
||||||
function inline(node: JSONContent): string {
|
function inline(node: JSONContent): string {
|
||||||
if (node.type === "text") {
|
if (node.type === "text") {
|
||||||
let text = esc(node.text ?? "");
|
let text = esc(node.text ?? "");
|
||||||
@@ -240,6 +291,7 @@ function contentOpf(
|
|||||||
chapterIds: string[],
|
chapterIds: string[],
|
||||||
assetPaths: string[],
|
assetPaths: string[],
|
||||||
cover: { href: string; mediaType: string },
|
cover: { href: string; mediaType: string },
|
||||||
|
fontManifest: string[],
|
||||||
): string {
|
): string {
|
||||||
const meta = book.metadata;
|
const meta = book.metadata;
|
||||||
const manifest: string[] = [
|
const manifest: string[] = [
|
||||||
@@ -247,8 +299,7 @@ function contentOpf(
|
|||||||
`<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`,
|
`<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml"/>`,
|
||||||
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
|
`<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="css" href="style.css" media-type="text/css"/>`,
|
||||||
`<item id="font-regular" href="fonts/Literata-VF.ttf" media-type="font/ttf"/>`,
|
...fontManifest,
|
||||||
`<item id="font-italic" href="fonts/Literata-Italic-VF.ttf" media-type="font/ttf"/>`,
|
|
||||||
];
|
];
|
||||||
chapterIds.forEach((_, i) => {
|
chapterIds.forEach((_, i) => {
|
||||||
manifest.push(
|
manifest.push(
|
||||||
@@ -334,23 +385,9 @@ function chapterXhtml(book: Book, index: number, paths: Map<string, string>): st
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function styleCss(): string {
|
function styleCss(book: Book, faces: string): string {
|
||||||
return `@font-face {
|
return `${faces ? faces + "\n\n" : ""}html {
|
||||||
font-family: "Literata";
|
font-family: ${fontStack(book.settings.fonts.body)};
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover {
|
.cover {
|
||||||
@@ -383,6 +420,7 @@ p[data-indent="true"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3 {
|
h1, h2, h3 {
|
||||||
|
font-family: ${fontStack(book.settings.fonts.heading)};
|
||||||
hyphens: none;
|
hyphens: none;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -477,7 +515,7 @@ figure.placement-float-top {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bookToEpub(book: Book): EpubFile[] {
|
export function bookToEpub(book: Book, fontAssets: FontAssets): EpubFile[] {
|
||||||
const { assets, paths } = extractFigures(book);
|
const { assets, paths } = extractFigures(book);
|
||||||
const cover = buildCover(book);
|
const cover = buildCover(book);
|
||||||
const chapterIds = book.chapters.map((c) => c.id);
|
const chapterIds = book.chapters.map((c) => c.id);
|
||||||
@@ -488,12 +526,12 @@ export function bookToEpub(book: Book): EpubFile[] {
|
|||||||
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
|
{ path: "META-INF/container.xml", data: containerXml(), encoding: "utf8" },
|
||||||
{
|
{
|
||||||
path: "OEBPS/content.opf",
|
path: "OEBPS/content.opf",
|
||||||
data: contentOpf(book, chapterIds, assetPaths, cover),
|
data: contentOpf(book, chapterIds, assetPaths, cover, fontAssets.manifest),
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
},
|
},
|
||||||
{ path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" },
|
{ path: "OEBPS/cover.xhtml", data: coverXhtml(book, cover.href), encoding: "utf8" },
|
||||||
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
|
{ path: "OEBPS/nav.xhtml", data: navXhtml(book), encoding: "utf8" },
|
||||||
{ path: "OEBPS/style.css", data: styleCss(), encoding: "utf8" },
|
{ path: "OEBPS/style.css", data: styleCss(book, fontAssets.faces), encoding: "utf8" },
|
||||||
cover.asset,
|
cover.asset,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -505,12 +543,13 @@ export function bookToEpub(book: Book): EpubFile[] {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
files.push(...assets);
|
files.push(...assets, ...fontAssets.files);
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildEpub(book: Book): Promise<Uint8Array> {
|
export async function buildEpub(book: Book): Promise<Uint8Array> {
|
||||||
const files = bookToEpub(book);
|
const fontAssets = await loadFontAssets(book);
|
||||||
|
const files = bookToEpub(book, fontAssets);
|
||||||
const buf = await invoke<ArrayBuffer>("package_epub", { files });
|
const buf = await invoke<ArrayBuffer>("package_epub", { files });
|
||||||
return new Uint8Array(buf);
|
return new Uint8Array(buf);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user