mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-04 12:07:03 +00:00
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
This commit is contained in:
1 parent
5c0040955e
commit
9eabef9041
6 files changed
+69
-10
No files matched your search
+24
-7
@@ -1,7 +1,8 @@
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use base64::engine::general_purpose::STANDARD;
|
use base64::engine::general_purpose::STANDARD;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use typst::diag::{Severity, SourceDiagnostic};
|
use tauri::Emitter;
|
||||||
|
use typst::diag::{Severity, SourceDiagnostic, Warned};
|
||||||
use typst::layout::PagedDocument;
|
use typst::layout::PagedDocument;
|
||||||
use typst_as_lib::TypstEngine;
|
use typst_as_lib::TypstEngine;
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ pub struct ImageInput {
|
|||||||
data: String,
|
data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
|
fn compile(source: String, images: &[ImageInput]) -> 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
|
||||||
@@ -31,14 +32,30 @@ fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, String> {
|
|||||||
.with_static_file_resolver(binaries)
|
.with_static_file_resolver(binaries)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let document: PagedDocument = engine.compile().output.map_err(|e| format_diagnostics(&e))?;
|
let Warned { output, warnings } = engine.compile();
|
||||||
|
let document: PagedDocument = output.map_err(|e| format_diagnostics(&e))?;
|
||||||
typst_pdf::pdf(&document, &Default::default()).map_err(|d| format_source_diagnostics(&d))
|
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)]
|
#[tauri::command(async)]
|
||||||
pub fn compile_pdf(source: String, images: Vec<ImageInput>) -> Result<tauri::ipc::Response, String> {
|
pub fn compile_pdf(
|
||||||
compile(source, &images).map(tauri::ipc::Response::new)
|
app: tauri::AppHandle,
|
||||||
|
source: String,
|
||||||
|
images: Vec<ImageInput>,
|
||||||
|
emit_warnings: bool,
|
||||||
|
) -> Result<tauri::ipc::Response, String> {
|
||||||
|
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 {
|
fn format_diagnostics(error: &typst_as_lib::TypstAsLibError) -> String {
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ function App() {
|
|||||||
else if (event.payload === "export-epub") runExport("epub");
|
else if (event.payload === "export-epub") runExport("epub");
|
||||||
else if (event.payload === "check-updates") checkForUpdates(false);
|
else if (event.payload === "check-updates") checkForUpdates(false);
|
||||||
});
|
});
|
||||||
|
const unlistenWarn = listen<string>("pdf-warnings", (event) => {
|
||||||
|
useBook.getState().setNotice(`PDF exported with warnings:\n${event.payload}`);
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unlisten.then((stop) => stop());
|
unlisten.then((stop) => stop());
|
||||||
|
unlistenWarn.then((stop) => stop());
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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 } = bookToPdfInputs(book);
|
||||||
const bytes = await compilePdf(source, images);
|
const bytes = await compilePdf(source, images, true);
|
||||||
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
|
await saveBytes(bytes, `${book.metadata.title || "Untitled"}.pdf`, PDF);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Book } from "../model/book";
|
import type { Book } from "../model/book";
|
||||||
import { exportEpub, exportPdf } from "./exporters";
|
import { exportEpub, exportPdf } from "./exporters";
|
||||||
|
import { unsupportedScripts } from "./typst";
|
||||||
import { useBook } from "../store/useBook";
|
import { useBook } from "../store/useBook";
|
||||||
import { isDesktop } from "../ipc";
|
import { isDesktop } from "../ipc";
|
||||||
|
|
||||||
@@ -29,6 +30,11 @@ export async function runExport(format: "pdf" | "epub"): Promise<void> {
|
|||||||
await nextPaint();
|
await nextPaint();
|
||||||
try {
|
try {
|
||||||
await run(book);
|
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) {
|
} catch (e) {
|
||||||
setNotice(`${label} export failed: ${e}`);
|
setNotice(`${label} export failed: ${e}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -256,6 +256,34 @@ export function extractImages(book: Book): { images: ImageInput[]; paths: Map<st
|
|||||||
return collectImages(book.chapters.map((chapter) => chapter.content));
|
return collectImages(book.chapters.map((chapter) => 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 {
|
function cleanTitle(title: string): string {
|
||||||
return (title || "")
|
return (title || "")
|
||||||
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
|
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
|
||||||
|
|||||||
+6
-2
@@ -7,7 +7,11 @@ export interface ImageInput {
|
|||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function compilePdf(source: string, images: ImageInput[] = []): Promise<Uint8Array> {
|
export async function compilePdf(
|
||||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images });
|
source: string,
|
||||||
|
images: ImageInput[] = [],
|
||||||
|
emitWarnings = false,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images, emitWarnings });
|
||||||
return new Uint8Array(buffer);
|
return new Uint8Array(buffer);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user