From 9eabef90415f3778052d83e277dc00be78f27086 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 22 Jun 2026 13:28:16 -0400 Subject: [PATCH] feat(export): surface typst warnings and glyph-coverage notice Typst compile warnings were discarded. Capture them via the Warned result and emit a pdf-warnings event (only from the export path, not the live preview) that App shows as a non-fatal toast; the export still succeeds. Also scan book text before reporting success and warn when it contains scripts the embedded Latin fonts cannot render (CJK, Arabic, Hebrew, Devanagari, Thai, emoji). Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk --- src-tauri/src/pdf.rs | 31 ++++++++++++++++++++++++------- src/App.tsx | 4 ++++ src/export/exporters.ts | 2 +- src/export/run.ts | 6 ++++++ src/export/typst.ts | 28 ++++++++++++++++++++++++++++ src/ipc.ts | 8 ++++++-- 6 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/pdf.rs b/src-tauri/src/pdf.rs index 351a717..36a9293 100644 --- a/src-tauri/src/pdf.rs +++ b/src-tauri/src/pdf.rs @@ -1,7 +1,8 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD; use serde::Deserialize; -use typst::diag::{Severity, SourceDiagnostic}; +use tauri::Emitter; +use typst::diag::{Severity, SourceDiagnostic, Warned}; use typst::layout::PagedDocument; use typst_as_lib::TypstEngine; @@ -16,7 +17,7 @@ pub struct ImageInput { data: String, } -fn compile(source: String, images: &[ImageInput]) -> Result, String> { +fn compile(source: String, images: &[ImageInput]) -> Result<(Vec, String), String> { let mut binaries: Vec<(&str, Vec)> = Vec::with_capacity(images.len()); for image in images { let bytes = STANDARD @@ -31,14 +32,30 @@ fn compile(source: String, images: &[ImageInput]) -> Result, String> { .with_static_file_resolver(binaries) .build(); - let document: PagedDocument = engine.compile().output.map_err(|e| format_diagnostics(&e))?; - - typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d)) + let Warned { output, warnings } = engine.compile(); + let document: PagedDocument = output.map_err(|e| format_diagnostics(&e))?; + let bytes = + typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d))?; + let warning_text = if warnings.is_empty() { + String::new() + } else { + format_source_diagnostics(&warnings) + }; + Ok((bytes, warning_text)) } #[tauri::command(async)] -pub fn compile_pdf(source: String, images: Vec) -> Result { - compile(source, &images).map(tauri::ipc::Response::new) +pub fn compile_pdf( + app: tauri::AppHandle, + source: String, + images: Vec, + emit_warnings: bool, +) -> Result { + let (bytes, warnings) = compile(source, &images)?; + if emit_warnings && !warnings.is_empty() { + app.emit("pdf-warnings", warnings).ok(); + } + Ok(tauri::ipc::Response::new(bytes)) } fn format_diagnostics(error: &typst_as_lib::TypstAsLibError) -> String { diff --git a/src/App.tsx b/src/App.tsx index 6d17dca..f29b1f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,8 +23,12 @@ function App() { else if (event.payload === "export-epub") runExport("epub"); else if (event.payload === "check-updates") checkForUpdates(false); }); + const unlistenWarn = listen("pdf-warnings", (event) => { + useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`); + }); return () => { unlisten.then((stop) => stop()); + unlistenWarn.then((stop) => stop()); }; }, []); diff --git a/src/export/exporters.ts b/src/export/exporters.ts index 4e78c69..fbefae8 100644 --- a/src/export/exporters.ts +++ b/src/export/exporters.ts @@ -9,7 +9,7 @@ const EPUB = [{ name: "EPUB", extensions: ["epub"] }]; export async function exportPdf(book: Book): Promise { const { source, images } = bookToPdfInputs(book); - const bytes = await compilePdf(source, images); + const bytes = await compilePdf(source, images, true); await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF); } diff --git a/src/export/run.ts b/src/export/run.ts index 659fd34..fb98778 100644 --- a/src/export/run.ts +++ b/src/export/run.ts @@ -1,5 +1,6 @@ import type { Book } from "../model/book"; import { exportEpub, exportPdf } from "./exporters"; +import { unsupportedScripts } from "./typst"; import { useBook } from "../store/useBook"; import { isDesktop } from "../ipc"; @@ -29,6 +30,11 @@ export async function runExport(format: "pdf" | "epub"): Promise { await nextPaint(); try { await run(book); + if (format === "pdf") { + const scripts = unsupportedScripts(book); + if (scripts.length) + setNotice(`Some ${scripts.join(", ")} characters may not render — the PDF fonts cover Latin scripts only.`); + } } catch (e) { setNotice(`${label} export failed: ${e}`); } finally { diff --git a/src/export/typst.ts b/src/export/typst.ts index 7a89916..db65fc6 100644 --- a/src/export/typst.ts +++ b/src/export/typst.ts @@ -256,6 +256,34 @@ export function extractImages(book: Book): { images: ImageInput[]; paths: Map chapter.content)); } +const SCRIPT_RANGES: { label: string; test: RegExp }[] = [ + { label: "CJK", test: /[぀-鿿가-힯豈-﫿]/u }, + { label: "Arabic", test: /[؀-ۿݐ-ݿ]/u }, + { label: "Hebrew", test: /[֐-׿]/u }, + { label: "Devanagari", test: /[ऀ-ॿ]/u }, + { label: "Thai", test: /[฀-๿]/u }, + { label: "emoji", test: /[☀-➿]|[\u{1f000}-\u{1faff}]/u }, +]; + +function collectText(book: Book): string { + const parts = [book.metadata.title, book.metadata.subtitle, book.metadata.author]; + const visit = (node: JSONContent) => { + if (node.type === "text" && node.text) parts.push(node.text); + if (node.attrs?.caption) parts.push(String(node.attrs.caption)); + (node.content ?? []).forEach(visit); + }; + book.chapters.forEach((chapter) => { + parts.push(chapter.title); + visit(chapter.content); + }); + return parts.join("\n"); +} + +export function unsupportedScripts(book: Book): string[] { + const text = collectText(book); + return SCRIPT_RANGES.filter((s) => s.test.test(text)).map((s) => s.label); +} + function cleanTitle(title: string): string { return (title || "") .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ") diff --git a/src/ipc.ts b/src/ipc.ts index 09cd2e6..e189da0 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -7,7 +7,11 @@ export interface ImageInput { data: string; } -export async function compilePdf(source: string, images: ImageInput[] = []): Promise { - const buffer = await invoke("compile_pdf", { source, images }); +export async function compilePdf( + source: string, + images: ImageInput[] = [], + emitWarnings = false, +): Promise { + const buffer = await invoke("compile_pdf", { source, images, emitWarnings }); return new Uint8Array(buffer); }