This commit is contained in:
pj committed 2026-06-20 23:46:10 -04:00
1 parent c86d5a7d5c
commit 0e0d334f6a
33 files changed
+401 -101

No files matched your search

+6 -3
View File
@@ -1,6 +1,6 @@
import type { JSONContent } from "@tiptap/core";
import { invoke } from "@tauri-apps/api/core";
import { type Book, TRIM_DIMS, bodyNumber, chapterKind } from "../model/book";
import { type Book, type FigurePlacement, TRIM_DIMS, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
export interface EpubFile {
path: string;
@@ -72,7 +72,10 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
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>`;
const placed = placement as FigurePlacement;
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100;
const style = placement === "full-page" ? "" : ` style="width:${width}%"`;
return `<figure class="placement-${attr(placement)}"${style}><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`;
}
function block(node: JSONContent, paths: Map<string, string>): string {
@@ -439,7 +442,7 @@ hr.scene-break::before {
}
figure {
margin: 1.4em 0;
margin: 1.4em auto;
text-align: center;
}
+37
View File
@@ -0,0 +1,37 @@
import type { Book } from "../model/book";
import { exportEpub, exportPdf } from "./exporters";
import { useBook } from "../store/useBook";
import { isDesktop } from "../ipc";
const DESKTOP_ONLY =
'Export runs in the desktop app only — open the window from "pnpm tauri dev" (you are viewing the browser preview).';
const FORMATS: Record<string, { label: string; run: (book: Book) => Promise<void> }> = {
pdf: { label: "PDF", run: exportPdf },
epub: { label: "EPUB", run: exportEpub },
};
function nextPaint(): Promise<void> {
return new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
}
export async function runExport(format: "pdf" | "epub"): Promise<void> {
const { book, setExporting, setNotice } = useBook.getState();
const { label, run } = FORMATS[format];
if (!isDesktop) {
setNotice(DESKTOP_ONLY);
return;
}
if (!book) return;
setExporting(label);
await nextPaint();
try {
await run(book);
} catch (e) {
setNotice(`${label} export failed: ${e}`);
} finally {
setExporting(null);
}
}
+71 -27
View File
@@ -1,5 +1,5 @@
import type { JSONContent } from "@tiptap/core";
import { type Book, type TrimSize, bodyNumber, chapterKind } from "../model/book";
import { type Book, type FigurePlacement, type TrimSize, FIGURE_WIDTH, bodyNumber, chapterKind, isResizablePlacement } from "../model/book";
import type { ImageInput } from "../ipc";
const TRIM: Record<TrimSize, { w: string; h: string }> = {
@@ -57,9 +57,11 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
if (placement === "full-page") {
return `#page(margin: 0pt, numbering: none)[#image(${str(path)}, width: 100%, height: 100%, fit: "cover")]`;
}
const placed = placement as FigurePlacement;
const width = (isResizablePlacement(placed) ? node.attrs?.width : null) ?? FIGURE_WIDTH[placed] ?? 100;
const caption = node.attrs?.caption ? `, caption: [${esc(node.attrs.caption)}]` : "";
const float = placement === "float-top" ? ", placement: top" : "";
return `#figure(image(${str(path)}, width: 100%)${caption}${float})`;
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
}
function block(node: JSONContent, paths: Map<string, string>): string {
@@ -127,24 +129,52 @@ function preamble(book: Book): string {
]
}
#let chapteropener(num, title) = {
#let openchapter(info) = {
pagebreak(weak: true)
[#metadata(info) <chap>]
v(2.1in)
align(center)[
#text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + num)]
#v(0.7em)
#heading(level: 1, numbering: none, outlined: true)[#title]
#if info.kind == "body" {
text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + info.num)]
v(0.7em)
}
#heading(level: 1, numbering: none, outlined: info.toc)[#info.title]
]
v(1.5em)
}
#let pageopener(title) = {
#let pageof(loc) = {
let pat = loc.page-numbering()
if pat == none { pat = "1" }
numbering(pat, ..counter(page).at(loc))
}
#let contents() = {
pagebreak(weak: true)
v(2.1in)
align(center)[
#heading(level: 1, numbering: none, outlined: true)[#title]
]
v(1.5em)
set par(justify: false, first-line-indent: 0pt)
block(below: 2em)[#text(font: "Literata", size: 24pt, weight: "medium")[Contents]]
context {
let items = query(<chap>).filter(it => it.value.toc != false)
let prev = none
for it in items {
let info = it.value
let pg = pageof(it.location())
let body = info.kind == "body"
let gap = if prev != none and prev != info.kind { 1.7em } else { 0.95em }
prev = info.kind
block(width: 100%, above: gap, below: 0.95em, {
set text(size: 11.5pt)
link(it.location())[#grid(
columns: (1.9em, 1fr, auto),
column-gutter: (0.65em, 1em),
align: (right + top, left + top, right + top),
if body { info.num } else { [] },
if body { info.title } else { emph(info.title) },
pg,
)]
})
}
}
}`;
}
@@ -213,23 +243,42 @@ export function extractImages(book: Book): { images: ImageInput[]; paths: Map<st
return collectImages(book.chapters.map((chapter) => chapter.content));
}
function cleanTitle(title: string): string {
return (title || "")
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function normTitle(title: string): string {
return cleanTitle(title).toLowerCase();
}
function openerCall(book: Book, index: number): string {
const chapter = book.chapters[index];
const kind = chapterKind(chapter);
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
const title = cleanTitle(chapter.title) || "Untitled";
const inToc = !(kind === "front" && normTitle(title) === normTitle(book.metadata.title));
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, toc: ${inToc}))`;
}
export function bookToTypst(book: Book, paths: Map<string, string> = new Map(), coverPath?: string): string {
const meta = book.metadata;
const cover = `${coverBlock(book, coverPath)}\n#pagebreak(weak: true)`;
const front = `#set page(numbering: none)
#titlepage(${str(meta.title || "Untitled")}, ${str(meta.subtitle)}, ${str(meta.author)})
#pagebreak(weak: true)
#outline(title: [Contents], depth: 1)
#set page(numbering: "1")
#counter(page).update(1)`;
#set page(numbering: "i")
#counter(page).update(1)
#contents()`;
const firstBody = book.chapters.findIndex((c) => chapterKind(c) === "body");
const body = book.chapters
.map((chapter, i) => {
const opener =
chapterKind(chapter) === "body"
? `#chapteropener(${str(String(bodyNumber(book.chapters, i)))}, [${esc(chapter.title || "Untitled")}])`
: `#pageopener([${esc(chapter.title || "Untitled")}])`;
return `${opener}\n\n${chapterBody(chapter.content, paths)}`;
const reset = i === firstBody ? `#set page(numbering: "1")\n#counter(page).update(1)\n` : "";
return `${reset}${openerCall(book, i)}\n\n${chapterBody(chapter.content, paths)}`;
})
.join("\n\n");
@@ -249,19 +298,14 @@ export function coverToPdfInputs(book: Book): { source: string; images: ImageInp
}
function chapterToTypst(book: Book, index: number, paths: Map<string, string>): string {
const chapter = book.chapters[index];
const opener =
chapterKind(chapter) === "body"
? `#chapteropener(${str(String(bodyNumber(book.chapters, index)))}, [${esc(chapter.title || "Untitled")}])`
: `#pageopener([${esc(chapter.title || "Untitled")}])`;
return `${preamble(book)}
#set page(numbering: "1")
#counter(page).update(1)
${opener}
${openerCall(book, index)}
${chapterBody(chapter.content, paths)}
${chapterBody(book.chapters[index].content, paths)}
`;
}