mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
feat(fonts): honor selected fonts in PDF export
Interpolate the book's body and heading families into the Typst preamble, title page, cover, and contents. Embed the needed bundled fonts and load any chosen system fonts (via fontdb) into the Typst engine so PDF preview and export match the on-screen fonts.
This commit is contained in:
1 parent
9a38976000
commit
7a2d7c6aff
5 files changed
+93
-21
No files matched your search
+48
-3
@@ -10,6 +10,29 @@ static LITERATA: &[u8] = include_bytes!("../../public/fonts/Literata-VF.ttf");
|
|||||||
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
|
static LITERATA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Literata-Italic-VF.ttf");
|
||||||
static HANKEN: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-VF.ttf");
|
static HANKEN: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-VF.ttf");
|
||||||
static HANKEN_ITALIC: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-Italic-VF.ttf");
|
static HANKEN_ITALIC: &[u8] = include_bytes!("../../public/fonts/HankenGrotesk-Italic-VF.ttf");
|
||||||
|
static EB_GARAMOND: &[u8] = include_bytes!("../../public/fonts/EBGaramond-VF.ttf");
|
||||||
|
static EB_GARAMOND_ITALIC: &[u8] = include_bytes!("../../public/fonts/EBGaramond-Italic-VF.ttf");
|
||||||
|
static LORA: &[u8] = include_bytes!("../../public/fonts/Lora-VF.ttf");
|
||||||
|
static LORA_ITALIC: &[u8] = include_bytes!("../../public/fonts/Lora-Italic-VF.ttf");
|
||||||
|
static SOURCE_SERIF: &[u8] = include_bytes!("../../public/fonts/SourceSerif4-VF.ttf");
|
||||||
|
static SOURCE_SERIF_ITALIC: &[u8] = include_bytes!("../../public/fonts/SourceSerif4-Italic-VF.ttf");
|
||||||
|
static FRAUNCES: &[u8] = include_bytes!("../../public/fonts/Fraunces-VF.ttf");
|
||||||
|
static FRAUNCES_ITALIC: &[u8] = include_bytes!("../../public/fonts/Fraunces-Italic-VF.ttf");
|
||||||
|
|
||||||
|
static EB_GARAMOND_FONTS: [&[u8]; 2] = [EB_GARAMOND, EB_GARAMOND_ITALIC];
|
||||||
|
static LORA_FONTS: [&[u8]; 2] = [LORA, LORA_ITALIC];
|
||||||
|
static SOURCE_SERIF_FONTS: [&[u8]; 2] = [SOURCE_SERIF, SOURCE_SERIF_ITALIC];
|
||||||
|
static FRAUNCES_FONTS: [&[u8]; 2] = [FRAUNCES, FRAUNCES_ITALIC];
|
||||||
|
|
||||||
|
fn bundled_font_bytes(id: &str) -> &'static [&'static [u8]] {
|
||||||
|
match id {
|
||||||
|
"eb-garamond" => &EB_GARAMOND_FONTS,
|
||||||
|
"lora" => &LORA_FONTS,
|
||||||
|
"source-serif" => &SOURCE_SERIF_FONTS,
|
||||||
|
"fraunces" => &FRAUNCES_FONTS,
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ImageInput {
|
pub struct ImageInput {
|
||||||
@@ -17,7 +40,12 @@ pub struct ImageInput {
|
|||||||
data: String,
|
data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compile(source: String, images: &[ImageInput]) -> Result<(Vec<u8>, String), String> {
|
fn compile(
|
||||||
|
source: String,
|
||||||
|
images: &[ImageInput],
|
||||||
|
bundled_fonts: &[String],
|
||||||
|
system_fonts: &[String],
|
||||||
|
) -> Result<(Vec<u8>, String), String> {
|
||||||
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
|
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
|
||||||
for image in images {
|
for image in images {
|
||||||
let bytes = STANDARD
|
let bytes = STANDARD
|
||||||
@@ -26,9 +54,24 @@ fn compile(source: String, images: &[ImageInput]) -> Result<(Vec<u8>, String), S
|
|||||||
binaries.push((image.path.as_str(), bytes));
|
binaries.push((image.path.as_str(), bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut fonts: Vec<Vec<u8>> = vec![
|
||||||
|
LITERATA.to_vec(),
|
||||||
|
LITERATA_ITALIC.to_vec(),
|
||||||
|
HANKEN.to_vec(),
|
||||||
|
HANKEN_ITALIC.to_vec(),
|
||||||
|
];
|
||||||
|
for id in bundled_fonts {
|
||||||
|
for bytes in bundled_font_bytes(id) {
|
||||||
|
fonts.push(bytes.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for family in system_fonts {
|
||||||
|
fonts.extend(crate::fonts::system_font_bytes(family));
|
||||||
|
}
|
||||||
|
|
||||||
let engine = TypstEngine::builder()
|
let engine = TypstEngine::builder()
|
||||||
.main_file(source)
|
.main_file(source)
|
||||||
.fonts([LITERATA, LITERATA_ITALIC, HANKEN, HANKEN_ITALIC])
|
.fonts(fonts)
|
||||||
.with_static_file_resolver(binaries)
|
.with_static_file_resolver(binaries)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -50,8 +93,10 @@ pub fn compile_pdf(
|
|||||||
source: String,
|
source: String,
|
||||||
images: Vec<ImageInput>,
|
images: Vec<ImageInput>,
|
||||||
emit_warnings: bool,
|
emit_warnings: bool,
|
||||||
|
bundled_fonts: Vec<String>,
|
||||||
|
system_fonts: Vec<String>,
|
||||||
) -> Result<tauri::ipc::Response, String> {
|
) -> Result<tauri::ipc::Response, String> {
|
||||||
let (bytes, warnings) = compile(source, &images)?;
|
let (bytes, warnings) = compile(source, &images, &bundled_fonts, &system_fonts)?;
|
||||||
if emit_warnings && !warnings.is_empty() {
|
if emit_warnings && !warnings.is_empty() {
|
||||||
app.emit("pdf-warnings", warnings).ok();
|
app.emit("pdf-warnings", warnings).ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ function PdfDock() {
|
|||||||
clearTimeout(timer.current);
|
clearTimeout(timer.current);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
timer.current = setTimeout(() => {
|
timer.current = setTimeout(() => {
|
||||||
const { source, images } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
const { source, images, fonts } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx);
|
||||||
compilePdf(source, images)
|
compilePdf(source, images, false, fonts)
|
||||||
.then((bytes) => {
|
.then((bytes) => {
|
||||||
setPdf(bytes);
|
setPdf(bytes);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ const PDF = [{ name: "PDF", extensions: ["pdf"] }];
|
|||||||
const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
|
const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
|
||||||
|
|
||||||
export async function exportPdf(book: Book): Promise<void> {
|
export async function exportPdf(book: Book): Promise<void> {
|
||||||
const { source, images } = bookToPdfInputs(book);
|
const { source, images, fonts } = bookToPdfInputs(book);
|
||||||
const bytes = await compilePdf(source, images, true);
|
const bytes = await compilePdf(source, images, true, fonts);
|
||||||
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
|
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-13
@@ -1,7 +1,18 @@
|
|||||||
import type { JSONContent } from "@tiptap/core";
|
import type { JSONContent } from "@tiptap/core";
|
||||||
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
|
||||||
|
import { fontFamilyName, fontsUsed } from "../model/fonts";
|
||||||
import type { ImageInput } from "../ipc";
|
import type { ImageInput } from "../ipc";
|
||||||
|
|
||||||
|
export interface PdfFonts {
|
||||||
|
bundled: string[];
|
||||||
|
system: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pdfFonts(book: Book): PdfFonts {
|
||||||
|
const used = fontsUsed(book.settings.fonts);
|
||||||
|
return { bundled: used.bundled.map((f) => f.id), system: used.system };
|
||||||
|
}
|
||||||
|
|
||||||
const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
||||||
"6x9": { w: "6in", h: "9in" },
|
"6x9": { w: "6in", h: "9in" },
|
||||||
"5.5x8.5": { w: "5.5in", h: "8.5in" },
|
"5.5x8.5": { w: "5.5in", h: "8.5in" },
|
||||||
@@ -104,6 +115,8 @@ function preamble(book: Book): string {
|
|||||||
const trim = TRIM[book.settings.trim];
|
const trim = TRIM[book.settings.trim];
|
||||||
const meta = book.metadata;
|
const meta = book.metadata;
|
||||||
const bleed = book.settings.bleed ? "0.125in" : "0in";
|
const bleed = book.settings.bleed ? "0.125in" : "0in";
|
||||||
|
const body = fontFamilyName(book.settings.fonts.body);
|
||||||
|
const heading = fontFamilyName(book.settings.fonts.heading);
|
||||||
return `#set document(title: ${str(meta.title || "Untitled")}, author: ${str(meta.author)})
|
return `#set document(title: ${str(meta.title || "Untitled")}, author: ${str(meta.author)})
|
||||||
#set page(
|
#set page(
|
||||||
width: ${trim.w} + ${bleed},
|
width: ${trim.w} + ${bleed},
|
||||||
@@ -111,9 +124,9 @@ function preamble(book: Book): string {
|
|||||||
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
|
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
|
||||||
binding: left,
|
binding: left,
|
||||||
)
|
)
|
||||||
#set text(font: "Literata", size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
|
#set text(font: ${str(body)}, size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
|
||||||
#set par(justify: true, leading: 0.72em, spacing: 0.72em)
|
#set par(justify: true, leading: 0.72em, spacing: 0.72em)
|
||||||
#show heading: set text(font: "Literata", weight: "medium")
|
#show heading: set text(font: ${str(heading)}, weight: "medium")
|
||||||
#show heading.where(level: 1): set text(size: 22pt)
|
#show heading.where(level: 1): set text(size: 22pt)
|
||||||
#show heading.where(level: 2): set block(above: 1.4em, below: 0.6em)
|
#show heading.where(level: 2): set block(above: 1.4em, below: 0.6em)
|
||||||
#show heading.where(level: 3): set block(above: 1.2em, below: 0.5em)
|
#show heading.where(level: 3): set block(above: 1.2em, below: 0.5em)
|
||||||
@@ -125,10 +138,10 @@ function preamble(book: Book): string {
|
|||||||
#let titlepage(title, subtitle, author) = {
|
#let titlepage(title, subtitle, author) = {
|
||||||
v(2.4in)
|
v(2.4in)
|
||||||
align(center)[
|
align(center)[
|
||||||
#text(font: "Literata", size: 30pt, weight: "medium")[#title]
|
#text(font: ${str(heading)}, size: 30pt, weight: "medium")[#title]
|
||||||
#if subtitle != "" {
|
#if subtitle != "" {
|
||||||
v(0.6em)
|
v(0.6em)
|
||||||
text(font: "Literata", size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle]
|
text(font: ${str(body)}, size: 15pt, style: "italic", fill: rgb("#6b6458"))[#subtitle]
|
||||||
}
|
}
|
||||||
#if author != "" {
|
#if author != "" {
|
||||||
v(1.7em)
|
v(1.7em)
|
||||||
@@ -164,7 +177,7 @@ function preamble(book: Book): string {
|
|||||||
set par(justify: false, first-line-indent: 0pt)
|
set par(justify: false, first-line-indent: 0pt)
|
||||||
block(below: 2em)[
|
block(below: 2em)[
|
||||||
#show heading: set block(above: 0pt, below: 0pt)
|
#show heading: set block(above: 0pt, below: 0pt)
|
||||||
#heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: "Literata", size: 24pt, weight: "medium")[Contents]]
|
#heading(level: 1, numbering: none, outlined: false, bookmarked: true)[#text(font: ${str(heading)}, size: 24pt, weight: "medium")[Contents]]
|
||||||
]
|
]
|
||||||
context {
|
context {
|
||||||
let items = query(<chap>).filter(it => it.value.toc != false)
|
let items = query(<chap>).filter(it => it.value.toc != false)
|
||||||
@@ -200,13 +213,15 @@ function imageExtension(dataUrl: string): string {
|
|||||||
function coverBlock(book: Book, coverPath?: string): string {
|
function coverBlock(book: Book, coverPath?: string): string {
|
||||||
const c = book.cover;
|
const c = book.cover;
|
||||||
const meta = book.metadata;
|
const meta = book.metadata;
|
||||||
|
const body = fontFamilyName(book.settings.fonts.body);
|
||||||
|
const heading = fontFamilyName(book.settings.fonts.heading);
|
||||||
|
|
||||||
if (c.kind === "image" && coverPath) {
|
if (c.kind === "image" && coverPath) {
|
||||||
return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
|
return `#page(margin: 0pt, numbering: none)[#image(${str(coverPath)}, width: 100%, height: 100%, fit: "cover")]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subtitle = meta.subtitle
|
const subtitle = meta.subtitle
|
||||||
? `#v(0.55em)\n #text(font: "Literata", size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
|
? `#v(0.55em)\n #text(font: ${str(body)}, size: 14pt, style: "italic")[${esc(meta.subtitle)}]`
|
||||||
: "";
|
: "";
|
||||||
const author = meta.author
|
const author = meta.author
|
||||||
? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)`
|
? `#v(1fr)\n #text(font: "Hanken Grotesk", size: 11pt, weight: "semibold", tracking: 0.24em)[#upper[${esc(meta.author)}]]\n #v(0.55in)`
|
||||||
@@ -216,7 +231,7 @@ function coverBlock(book: Book, coverPath?: string): string {
|
|||||||
#set align(center)
|
#set align(center)
|
||||||
#set text(fill: rgb(${str(c.ink)}))
|
#set text(fill: rgb(${str(c.ink)}))
|
||||||
#v(1fr)
|
#v(1fr)
|
||||||
#text(font: "Literata", size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
|
#text(font: ${str(heading)}, size: 30pt, weight: "medium")[${esc(meta.title || "Untitled")}]
|
||||||
#v(0.5em)
|
#v(0.5em)
|
||||||
#line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
|
#line(length: 16%, stroke: 0.6pt + rgb(${str(c.ink)}))
|
||||||
${subtitle}
|
${subtitle}
|
||||||
@@ -334,16 +349,16 @@ export function bookToTypst(book: Book, paths: Map<string, string> = new Map(),
|
|||||||
return `${preamble(book)}\n\n${cover}\n\n${front}\n\n${body}\n`;
|
return `${preamble(book)}\n\n${cover}\n\n${front}\n\n${body}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
export function bookToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||||
const { images, paths } = extractImages(book);
|
const { images, paths } = extractImages(book);
|
||||||
const coverPath = pushCoverImage(book, images);
|
const coverPath = pushCoverImage(book, images);
|
||||||
return { source: bookToTypst(book, paths, coverPath), images };
|
return { source: bookToTypst(book, paths, coverPath), images, fonts: pdfFonts(book) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[] } {
|
export function coverToPdfInputs(book: Book): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||||
const images: ImageInput[] = [];
|
const images: ImageInput[] = [];
|
||||||
const coverPath = pushCoverImage(book, images);
|
const coverPath = pushCoverImage(book, images);
|
||||||
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images };
|
return { source: `${preamble(book)}\n\n${coverBlock(book, coverPath)}\n`, images, fonts: pdfFonts(book) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
|
||||||
@@ -358,8 +373,8 @@ ${chapterBody(book.chapters[index].content, paths)}
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[] } {
|
export function chapterToPdfInputs(book: Book, index: number): { source: string; images: ImageInput[]; fonts: PdfFonts } {
|
||||||
const chapter = book.chapters[index];
|
const chapter = book.chapters[index];
|
||||||
const { images, paths } = collectImages([chapter.content]);
|
const { images, paths } = collectImages([chapter.content]);
|
||||||
return { source: chapterToTypst(book, index, paths), images };
|
return { source: chapterToTypst(book, index, paths), images, fonts: pdfFonts(book) };
|
||||||
}
|
}
|
||||||
+13
-1
@@ -7,12 +7,24 @@ export interface ImageInput {
|
|||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PdfFonts {
|
||||||
|
bundled: string[];
|
||||||
|
system: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function compilePdf(
|
export async function compilePdf(
|
||||||
source: string,
|
source: string,
|
||||||
images: ImageInput[] = [],
|
images: ImageInput[] = [],
|
||||||
emitWarnings = false,
|
emitWarnings = false,
|
||||||
|
fonts: PdfFonts = { bundled: [], system: [] },
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images, emitWarnings });
|
const buffer = await invoke<ArrayBuffer>("compile_pdf", {
|
||||||
|
source,
|
||||||
|
images,
|
||||||
|
emitWarnings,
|
||||||
|
bundledFonts: fonts.bundled,
|
||||||
|
systemFonts: fonts.system,
|
||||||
|
});
|
||||||
return new Uint8Array(buffer);
|
return new Uint8Array(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user