diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 35a5759..9e19f17 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4704,7 +4704,7 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "margin-app" -version = "0.1.2" +version = "0.1.3" dependencies = [ "base64 0.22.1", "harper-core", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4ec5d4a..b8e3643 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", + "core:window:allow-destroy", "opener:default", "dialog:default", "updater:default", diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index 89ce039..1999390 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -7,6 +7,16 @@ pub struct BookSummary { id: String, title: String, author: String, + corrupt: bool, +} + +fn corrupt_summary(stem: &str) -> BookSummary { + BookSummary { + id: stem.to_string(), + title: "Unreadable book".to_string(), + author: String::new(), + corrupt: true, + } } fn library_dir(app: &tauri::AppHandle) -> Result { @@ -19,6 +29,13 @@ fn library_dir(app: &tauri::AppHandle) -> Result { Ok(dir) } +fn book_path(app: &tauri::AppHandle, id: &str) -> Result { + if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return Err("invalid book id".to_string()); + } + Ok(library_dir(app)?.join(format!("{id}.margin"))) +} + #[tauri::command] pub fn list_books(app: tauri::AppHandle) -> Result, String> { let dir = library_dir(&app)?; @@ -31,16 +48,24 @@ pub fn list_books(app: tauri::AppHandle) -> Result, String> { if path.extension().and_then(|e| e.to_str()) != Some("margin") { continue; } + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let contents = match fs::read_to_string(&path) { Ok(contents) => contents, - Err(_) => continue, + Err(_) => { + books.push(corrupt_summary(stem)); + continue; + } }; let value: serde_json::Value = match serde_json::from_str(&contents) { Ok(value) => value, - Err(_) => continue, + Err(_) => { + books.push(corrupt_summary(stem)); + continue; + } }; let id = value.get("id").and_then(|v| v.as_str()).unwrap_or(""); if id.is_empty() { + books.push(corrupt_summary(stem)); continue; } let metadata = value.get("metadata"); @@ -58,6 +83,7 @@ pub fn list_books(app: tauri::AppHandle) -> Result, String> { id: id.to_string(), title, author, + corrupt: false, }); } Ok(books) @@ -65,19 +91,19 @@ pub fn list_books(app: tauri::AppHandle) -> Result, String> { #[tauri::command] pub fn load_book(app: tauri::AppHandle, id: String) -> Result { - let path = library_dir(&app)?.join(format!("{id}.margin")); + let path = book_path(&app, &id)?; fs::read_to_string(&path).map_err(|e| e.to_string()) } #[tauri::command] pub fn save_book(app: tauri::AppHandle, id: String, contents: String) -> Result<(), String> { - let path = library_dir(&app)?.join(format!("{id}.margin")); - fs::write(&path, contents).map_err(|e| e.to_string()) + let path = book_path(&app, &id)?; + crate::project::atomic_write(&path, contents.as_bytes(), true) } #[tauri::command] pub fn delete_book(app: tauri::AppHandle, id: String) -> Result<(), String> { - let path = library_dir(&app)?.join(format!("{id}.margin")); + let path = book_path(&app, &id)?; if path.exists() { fs::remove_file(&path).map_err(|e| e.to_string())?; } 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-tauri/src/project.rs b/src-tauri/src/project.rs index 3e7e856..e8e27ef 100644 --- a/src-tauri/src/project.rs +++ b/src-tauri/src/project.rs @@ -1,6 +1,33 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD; -use std::fs; +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +fn with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(suffix); + PathBuf::from(name) +} + +pub fn atomic_write(path: &Path, bytes: &[u8], backup: bool) -> Result<(), String> { + let tmp = with_suffix(path, ".tmp"); + { + let mut f = File::create(&tmp).map_err(|e| e.to_string())?; + f.write_all(bytes).map_err(|e| e.to_string())?; + f.sync_all().map_err(|e| e.to_string())?; + } + if path.exists() { + if backup { + let bak = with_suffix(path, ".bak"); + let _ = fs::remove_file(&bak); + fs::rename(path, &bak).map_err(|e| e.to_string())?; + } else { + fs::remove_file(path).map_err(|e| e.to_string())?; + } + } + fs::rename(&tmp, path).map_err(|e| e.to_string()) +} #[tauri::command] pub fn read_file(path: String) -> Result { @@ -9,11 +36,11 @@ pub fn read_file(path: String) -> Result { #[tauri::command] pub fn write_file(path: String, contents: String) -> Result<(), String> { - fs::write(&path, contents).map_err(|e| e.to_string()) + atomic_write(Path::new(&path), contents.as_bytes(), true) } #[tauri::command] pub fn write_bytes(path: String, data: String) -> Result<(), String> { let bytes = STANDARD.decode(data.as_bytes()).map_err(|e| e.to_string())?; - fs::write(&path, bytes).map_err(|e| e.to_string()) + atomic_write(Path::new(&path), &bytes, false) } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5d3bafb..b2da513 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ } ], "security": { - "csp": null + "csp": "default-src 'self'; img-src 'self' data: blob:; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self' blob:; connect-src 'self' ipc: http://ipc.localhost" } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index 4e39ac2..f29b1f0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,11 @@ import { useEffect } from "react"; import { listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { Library } from "./components/Library"; import { EditorView } from "./components/EditorView"; import { useBook } from "./store/useBook"; import { isDesktop } from "./ipc"; -import { newBook } from "./library"; +import { createAndOpenBook, saveBook } from "./library"; import { runExport } from "./export/run"; import { checkForUpdates } from "./updater"; @@ -17,11 +18,30 @@ function App() { checkForUpdates(true); const unlisten = listen("menu-action", (event) => { const state = useBook.getState(); - if (event.payload === "new-book") state.openBook(newBook()); + if (event.payload === "new-book") createAndOpenBook(state.openBook, state.setNotice); else if (event.payload === "export-pdf") runExport("pdf"); 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()); + }; + }, []); + + useEffect(() => { + if (!isDesktop) return; + const win = getCurrentWindow(); + const unlisten = win.onCloseRequested(async (event) => { + const { book, dirty } = useBook.getState(); + if (!book || !dirty) return; + event.preventDefault(); + await saveBook(book).catch(() => {}); + win.destroy(); + }); return () => { unlisten.then((stop) => stop()); }; diff --git a/src/components/EditorView.tsx b/src/components/EditorView.tsx index 634e3d1..ec03970 100644 --- a/src/components/EditorView.tsx +++ b/src/components/EditorView.tsx @@ -75,8 +75,13 @@ export function EditorView() { const saveNow = useCallback(() => { const current = useBook.getState().book; - if (current) saveBook(current).then(markSaved).catch((e) => setNotice(`Save failed: ${e}`)); - }, [markSaved]); + if (!current) return; + saveBook(current) + .then(() => { + if (useBook.getState().book === current) markSaved(); + }) + .catch((e) => setNotice(`Save failed: ${e}`)); + }, [markSaved, setNotice]); useEffect(() => { if (!book || !dirty || !isDesktop) return; @@ -263,11 +268,15 @@ export function EditorView() { )} setChapterContent(chapter.id, content)} onReady={setEditor} + onContentError={() => + setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.") + } /> diff --git a/src/components/Library.tsx b/src/components/Library.tsx index ba1d403..7c2d914 100644 --- a/src/components/Library.tsx +++ b/src/components/Library.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; -import { type BookSummary, deleteBook, exampleBook, listBooks, loadBook, newBook, saveBook } from "../library"; +import { type BookSummary, createAndOpenBook, deleteBook, exampleBook, listBooks, loadBook, saveBook } from "../library"; import type { Book } from "../model/book"; import { importEpub } from "../import/epub"; +import { clearPositions } from "../editor/positions"; import { isDesktop } from "../ipc"; import { ConfirmDialog } from "./ConfirmDialog"; import { Icon } from "./Icon"; @@ -38,6 +39,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) { const removeBook = async () => { if (!pendingDelete) return; await deleteBook(pendingDelete.id); + clearPositions(pendingDelete.id); setPendingDelete(null); refresh(); }; @@ -64,7 +66,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
- @@ -78,13 +80,25 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) { Example )} - {books.map((b) => ( -
loadBook(b.id).then(onOpen)}> - {b.title || "Untitled"} - {b.author && {b.author}} - setPendingDelete(b)} /> -
- ))} + {books.map((b) => + b.corrupt ? ( +
+ {b.title} + Couldn't be read; a .bak backup may sit beside it. + setPendingDelete(b)} /> +
+ ) : ( +
loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))} + > + {b.title || "Untitled"} + {b.author && {b.author}} + setPendingDelete(b)} /> +
+ ), + )}
{notice && (
setNotice(null)}> diff --git a/src/components/PdfPreview.tsx b/src/components/PdfPreview.tsx index 52a1b35..e98f1ad 100644 --- a/src/components/PdfPreview.tsx +++ b/src/components/PdfPreview.tsx @@ -12,10 +12,11 @@ export function PdfPreview({ data }: { data: Uint8Array }) { const container = ref.current; if (!container) return; let cancelled = false; + const loadingTask = pdfjs.getDocument({ data: data.slice() }); (async () => { try { - const doc = await pdfjs.getDocument({ data: data.slice() }).promise; + const doc = await loadingTask.promise; if (cancelled) return; const width = container.clientWidth; const dpr = window.devicePixelRatio || 1; @@ -47,6 +48,7 @@ export function PdfPreview({ data }: { data: Uint8Array }) { return () => { cancelled = true; + loadingTask.destroy(); }; }, [data]); diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 0da1108..0a03738 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; import { EditorContent, useEditor, type Editor as TiptapEditor } from "@tiptap/react"; import type { JSONContent } from "@tiptap/core"; import { editorExtensions } from "./extensions"; -import { loadPosition, savePosition } from "./positions"; +import { loadPosition, savePosition, type ChapterPosition } from "./positions"; interface EditorProps { bookId: string; @@ -10,24 +10,30 @@ interface EditorProps { content: JSONContent; onChange: (content: JSONContent) => void; onReady: (editor: TiptapEditor | null) => void; + onContentError: (error: Error) => void; } -export function Editor({ bookId, chapterId, content, onChange, onReady }: EditorProps) { +export function Editor({ bookId, chapterId, content, onChange, onReady, onContentError }: EditorProps) { const onChangeRef = useRef(onChange); onChangeRef.current = onChange; + const onContentErrorRef = useRef(onContentError); + onContentErrorRef.current = onContentError; - const openChapter = useRef(chapterId); + const latest = useRef(null); const editor = useEditor({ extensions: editorExtensions, content, immediatelyRender: false, + enableContentCheck: true, editorProps: { attributes: { class: "prose" } }, + onContentError: ({ error }) => onContentErrorRef.current(error), onUpdate: ({ editor }) => onChangeRef.current(editor.getJSON()), }); useEffect(() => { onReady(editor); + return () => onReady(null); }, [editor, onReady]); useEffect(() => { @@ -35,15 +41,6 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor let active = true; const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null; - const previous = openChapter.current; - if (previous !== chapterId) { - const { from, to } = editor.state.selection; - savePosition(bookId, previous, { from, to, scroll: scroller?.scrollTop ?? 0 }); - } - openChapter.current = chapterId; - - editor.commands.setContent(content, { emitUpdate: false }); - const saved = loadPosition(bookId, chapterId); const size = editor.state.doc.content.size; const selection = saved ? { from: Math.min(saved.from, size), to: Math.min(saved.to, size) } : 0; @@ -63,18 +60,20 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor active = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chapterId, editor, bookId]); + }, [editor]); useEffect(() => { if (!editor) return; const scroller = editor.view.dom.closest(".editor-pane") as HTMLElement | null; let timer: ReturnType; + const capture = () => { + const { from, to } = editor.state.selection; + latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 }; + }; const persist = () => { + capture(); clearTimeout(timer); - timer = setTimeout(() => { - const { from, to } = editor.state.selection; - savePosition(bookId, openChapter.current, { from, to, scroll: scroller?.scrollTop ?? 0 }); - }, 400); + timer = setTimeout(() => savePosition(bookId, chapterId, latest.current!), 400); }; editor.on("selectionUpdate", persist); scroller?.addEventListener("scroll", persist, { passive: true }); @@ -82,8 +81,9 @@ export function Editor({ bookId, chapterId, content, onChange, onReady }: Editor clearTimeout(timer); editor.off("selectionUpdate", persist); scroller?.removeEventListener("scroll", persist); + if (latest.current) savePosition(bookId, chapterId, latest.current); }; - }, [editor, bookId]); + }, [editor, bookId, chapterId]); return ; } diff --git a/src/editor/positions.ts b/src/editor/positions.ts index c804ce0..5355c69 100644 --- a/src/editor/positions.ts +++ b/src/editor/positions.ts @@ -16,6 +16,14 @@ function readAll(): Store { } } +function write(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + return; + } +} + export function loadPosition(bookId: string, chapterId: string): ChapterPosition | null { return readAll()[bookId]?.[chapterId] ?? null; } @@ -23,5 +31,34 @@ export function loadPosition(bookId: string, chapterId: string): ChapterPosition export function savePosition(bookId: string, chapterId: string, position: ChapterPosition): void { const all = readAll(); (all[bookId] ||= {})[chapterId] = position; - localStorage.setItem(KEY, JSON.stringify(all)); + write(KEY, all); +} + +const ACTIVE_KEY = "margin-active-chapter"; + +function readActive(): Record { + try { + return JSON.parse(localStorage.getItem(ACTIVE_KEY) || "{}") as Record; + } catch { + return {}; + } +} + +export function loadActiveChapter(bookId: string): string | null { + return readActive()[bookId] ?? null; +} + +export function saveActiveChapter(bookId: string, chapterId: string): void { + const all = readActive(); + all[bookId] = chapterId; + write(ACTIVE_KEY, all); +} + +export function clearPositions(bookId: string): void { + const positions = readAll(); + const active = readActive(); + delete positions[bookId]; + delete active[bookId]; + write(KEY, positions); + write(ACTIVE_KEY, active); } diff --git a/src/export/epub.ts b/src/export/epub.ts index 02f4081..3615dac 100644 --- a/src/export/epub.ts +++ b/src/export/epub.ts @@ -25,7 +25,7 @@ function slug(text: string): string { function imageExtension(dataUrl: string): string { const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl); - const kind = (match?.[1] ?? "png").toLowerCase(); + const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0]; return kind === "jpeg" ? "jpg" : kind; } @@ -42,8 +42,11 @@ function inline(node: JSONContent): string { if (node.type === "text") { let text = esc(node.text ?? ""); const marks = node.marks ?? []; + if (marks.some((m) => m.type === "code")) text = `${text}`; if (marks.some((m) => m.type === "bold")) text = `${text}`; if (marks.some((m) => m.type === "italic")) text = `${text}`; + if (marks.some((m) => m.type === "strike")) text = `${text}`; + if (marks.some((m) => m.type === "underline")) text = `${text}`; const href = marks.find((m) => m.type === "link")?.attrs?.href; if (href) text = `${text}`; return text; @@ -56,11 +59,11 @@ function inlines(content: JSONContent[] = []): string { return content.map(inline).join(""); } -function listItem(item: JSONContent): string { +function listItem(item: JSONContent, paths: Map): string { const inner = (item.content ?? []) - .filter((c) => c.type === "paragraph") - .map((p) => inlines(p.content)) - .join(" "); + .map((child) => block(child, paths)) + .filter((s) => s.length > 0) + .join(""); return `
  • ${inner}
  • `; } @@ -92,9 +95,9 @@ function block(node: JSONContent, paths: Map): string { case "blockquote": return `
    ${(node.content ?? []).map((n) => block(n, paths)).join("")}
    `; case "bulletList": - return `
      ${(node.content ?? []).map(listItem).join("")}
    `; + return `
      ${(node.content ?? []).map((li) => listItem(li, paths)).join("")}
    `; case "orderedList": - return `
      ${(node.content ?? []).map(listItem).join("")}
    `; + return `
      ${(node.content ?? []).map((li) => listItem(li, paths)).join("")}
    `; case "horizontalRule": return `
    `; case "figure": @@ -406,6 +409,11 @@ h2, h3 { margin: 0; } +code { + font-family: "SF Mono", Menlo, Consolas, monospace; + font-size: 0.9em; +} + blockquote { margin: 1.2em 1.6em; font-style: italic; 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 0482024..db65fc6 100644 --- a/src/export/typst.ts +++ b/src/export/typst.ts @@ -10,27 +10,32 @@ const TRIM: Record = { }; const INLINE_SPECIAL = /[\\#$*_`<>@~[\]]/g; +const LINE_SEPARATORS = /[\n\r\v\f\u2028\u2029]+/g; function esc(text: string): string { - return text.replace(INLINE_SPECIAL, (m) => "\\" + m); + return text.replace(LINE_SEPARATORS, " ").replace(INLINE_SPECIAL, (m) => "\\" + m); } -function guardLineStart(line: string): string { - if (/^[=\-+/]/.test(line)) return "\\" + line; - if (/^\d+\./.test(line)) return line.replace(".", "\\."); - return line; +function guardLineStart(text: string): string { + if (/^[=\-+/]/.test(text)) return "\\" + text; + if (/^\d+\./.test(text)) return text.replace(".", "\\."); + return text; } function str(value: string): string { return JSON.stringify(value); } -function inline(node: JSONContent): string { +function inline(node: JSONContent, atLineStart: boolean): string { if (node.type === "text") { - let text = esc(node.text ?? ""); const marks = node.marks ?? []; + if (marks.some((m) => m.type === "code")) return `#raw(${str(node.text ?? "")})`; + let text = esc(node.text ?? ""); + if (atLineStart) text = guardLineStart(text); if (marks.some((m) => m.type === "italic")) text = `#emph[${text}]`; if (marks.some((m) => m.type === "bold")) text = `#strong[${text}]`; + if (marks.some((m) => m.type === "strike")) text = `#strike[${text}]`; + if (marks.some((m) => m.type === "underline")) text = `#underline[${text}]`; const href = marks.find((m) => m.type === "link")?.attrs?.href; if (href) text = `#link(${str(href)})[${text}]`; return text; @@ -39,15 +44,15 @@ function inline(node: JSONContent): string { return ""; } -function inlines(content: JSONContent[] = []): string { - return content.map(inline).join(""); +function inlines(content: JSONContent[] = [], atLineStart = false): string { + return content.map((node, i) => inline(node, atLineStart && i === 0)).join(""); } -function listItem(item: JSONContent): string { - return (item.content ?? []) - .filter((c) => c.type === "paragraph") - .map((p) => inlines(p.content)) - .join(" "); +function listItem(item: JSONContent, paths: Map): string { + const parts = (item.content ?? []) + .map((child) => block(child, paths)) + .filter((s) => s.length > 0); + return `[${parts.join("\n\n")}]`; } function figure(node: JSONContent, paths: Map): string { @@ -59,7 +64,7 @@ function figure(node: JSONContent, paths: Map): string { } 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 caption = node.attrs?.caption ? `, caption: [${guardLineStart(esc(node.attrs.caption))}]` : ""; const float = placement === "float-top" ? ", placement: top" : ""; return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`; } @@ -67,18 +72,18 @@ function figure(node: JSONContent, paths: Map): string { function block(node: JSONContent, paths: Map): string { switch (node.type) { case "paragraph": { - const body = guardLineStart(inlines(node.content)); + const body = inlines(node.content, true); if (!body.trim()) return "~"; return node.attrs?.indent ? `#h(1.3em)${body}` : body; } case "heading": - return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content)}]`; + return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`; case "blockquote": return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`; case "bulletList": - return (node.content ?? []).map((li) => `- ${listItem(li)}`).join("\n"); + return `#list(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`; case "orderedList": - return (node.content ?? []).map((li) => `+ ${listItem(li)}`).join("\n"); + return `#enum(${(node.content ?? []).map((li) => listItem(li, paths)).join(", ")})`; case "horizontalRule": return "#scenebreak"; case "figure": @@ -188,7 +193,7 @@ function preamble(book: Book): string { function imageExtension(dataUrl: string): string { const match = /^data:image\/([a-z0-9.+-]+)/i.exec(dataUrl); - const kind = (match?.[1] ?? "png").toLowerCase(); + const kind = (match?.[1] ?? "png").toLowerCase().split("+")[0]; return kind === "jpeg" ? "jpg" : kind; } @@ -251,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/import/epub.ts b/src/import/epub.ts index 8877acc..55cfe63 100644 --- a/src/import/epub.ts +++ b/src/import/epub.ts @@ -1,7 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import type { JSONContent } from "@tiptap/core"; -import { type Book, type BookMetadata, type Chapter, createCover } from "../model/book"; +import { type Book, type BookMetadata, type Chapter, type Cover, createCover } from "../model/book"; export interface RawFile { path: string; @@ -9,7 +9,7 @@ export interface RawFile { encoding: "utf8" | "base64"; } -type Mark = { type: string }; +type Mark = { type: string; attrs?: Record }; const BLOCK_TAGS = new Set([ "p", "div", "section", "article", "main", "header", "footer", "aside", @@ -81,8 +81,8 @@ function hasMark(marks: Mark[], type: string): boolean { return marks.some((m) => m.type === type); } -function addMark(marks: Mark[], type: string): Mark[] { - return hasMark(marks, type) ? marks : [...marks, { type }]; +function addMark(marks: Mark[], type: string, attrs?: Record): Mark[] { + return hasMark(marks, type) ? marks : [...marks, attrs ? { type, attrs } : { type }]; } function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void { @@ -102,6 +102,15 @@ function collectInline(node: Node, marks: Mark[], out: JSONContent[]): void { collectInline(el, addMark(marks, "bold"), out); } else if (tag === "em" || tag === "i") { collectInline(el, addMark(marks, "italic"), out); + } else if (tag === "u") { + collectInline(el, addMark(marks, "underline"), out); + } else if (tag === "s" || tag === "strike" || tag === "del") { + collectInline(el, addMark(marks, "strike"), out); + } else if (tag === "code") { + collectInline(el, addMark(marks, "code"), out); + } else if (tag === "a") { + const href = el.getAttribute("href"); + collectInline(el, href ? addMark(marks, "link", { href }) : marks, out); } else if (!BLOCK_TAGS.has(tag)) { collectInline(el, marks, out); } @@ -137,13 +146,23 @@ function meaningful(inline: JSONContent[]): boolean { function imgFigure(img: Element, caption = ""): JSONContent[] { const src = img.getAttribute("src"); - if (!src || !src.startsWith("data:")) return []; - return [ - { - type: "figure", - attrs: { src, alt: img.getAttribute("alt") ?? "", caption, placement: "full-width" }, - }, - ]; + if (src && src.startsWith("data:")) { + return [ + { + type: "figure", + attrs: { src, alt: img.getAttribute("alt") ?? "", caption, placement: "full-width" }, + }, + ]; + } + const alt = (img.getAttribute("alt") ?? "").trim(); + return alt ? [{ type: "paragraph", content: [{ type: "text", text: alt }] }] : []; +} + +function svgFigure(el: Element): JSONContent[] { + const markup = el.outerHTML; + if (!markup) return []; + const src = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(markup)))}`; + return [{ type: "figure", attrs: { src, alt: "", caption: "", placement: "full-width" } }]; } function figureFrom(el: Element): JSONContent[] { @@ -165,18 +184,35 @@ function hasBlockChild(el: Element): boolean { return Array.from(el.children).some((c) => BLOCK_TAGS.has(c.localName.toLowerCase())); } -function listItems(el: Element): JSONContent[] { +function listItems(el: Element, depth: number): JSONContent[] { const items: JSONContent[] = []; Array.from(el.children) .filter((c) => c.localName.toLowerCase() === "li") .forEach((li) => { - const content = blocksFrom(li); + const content = blocksFrom(li, depth + 1); items.push({ type: "listItem", content: content.length ? content : [{ type: "paragraph" }] }); }); return items; } -function blockFromElement(el: Element): JSONContent[] { +function tableBlocks(el: Element): JSONContent[] { + const out: JSONContent[] = []; + Array.from(el.querySelectorAll("tr")).forEach((tr) => { + const content: JSONContent[] = []; + Array.from(tr.children) + .filter((c) => c.localName.toLowerCase() === "td" || c.localName.toLowerCase() === "th") + .forEach((cell) => { + const inline = inlineOf(cell); + if (!meaningful(inline)) return; + if (content.length) content.push({ type: "text", text: " · " }); + content.push(...inline); + }); + if (meaningful(content)) out.push({ type: "paragraph", content }); + }); + return out; +} + +function blockFromElement(el: Element, depth: number): JSONContent[] { const tag = el.localName.toLowerCase(); switch (tag) { case "p": @@ -192,15 +228,15 @@ function blockFromElement(el: Element): JSONContent[] { return meaningful(content) ? [{ type: "heading", attrs: { level }, content }] : []; } case "blockquote": { - const inner = blocksFrom(el); + const inner = blocksFrom(el, depth + 1); return [{ type: "blockquote", content: inner.length ? inner : [{ type: "paragraph" }] }]; } case "ul": { - const items = listItems(el); + const items = listItems(el, depth); return items.length ? [{ type: "bulletList", content: items }] : []; } case "ol": { - const items = listItems(el); + const items = listItems(el, depth); return items.length ? [{ type: "orderedList", content: items }] : []; } case "hr": @@ -209,22 +245,27 @@ function blockFromElement(el: Element): JSONContent[] { return figureFrom(el); case "img": return imgFigure(el); + case "svg": + return svgFigure(el); + case "table": + return tableBlocks(el); case "script": case "style": return []; default: - return hasBlockChild(el) ? blocksFrom(el) : paragraphAndFigures(el); + return hasBlockChild(el) ? blocksFrom(el, depth + 1) : paragraphAndFigures(el); } } -function blocksFrom(parent: Node): JSONContent[] { +function blocksFrom(parent: Node, depth = 0): JSONContent[] { + if (depth > 64) return []; const out: JSONContent[] = []; parent.childNodes.forEach((child) => { if (child.nodeType === 3) { const text = (child.nodeValue ?? "").replace(/\s+/g, " ").trim(); if (text !== "") out.push({ type: "paragraph", content: [{ type: "text", text }] }); } else if (child.nodeType === 1) { - out.push(...blockFromElement(child as Element)); + out.push(...blockFromElement(child as Element, depth)); } }); return out; @@ -234,6 +275,41 @@ function headingText(node: JSONContent): string { return (node.content ?? []).map((n) => n.text ?? "").join("").trim(); } +function isbnChecksum(s: string): boolean { + if (s.length === 10) { + let sum = 0; + for (let i = 0; i < 10; i++) { + const v = s[i] === "X" ? 10 : Number(s[i]); + if (Number.isNaN(v) || (s[i] === "X" && i !== 9)) return false; + sum += v * (10 - i); + } + return sum % 11 === 0; + } + if (s.length === 13 && /^\d{13}$/.test(s)) { + let sum = 0; + for (let i = 0; i < 13; i++) sum += Number(s[i]) * (i % 2 === 0 ? 1 : 3); + return sum % 10 === 0; + } + return false; +} + +function findIsbn(opf: Document): string { + const ids = Array.from(opf.getElementsByTagName("*")).filter((el) => el.localName === "identifier"); + const ranked = ids + .map((el) => ({ + text: el.textContent ?? "", + tagged: + /isbn/i.test(el.getAttribute("opf:scheme") || el.getAttribute("scheme") || "") || + /urn:isbn:/i.test(el.textContent ?? ""), + })) + .sort((a, b) => Number(b.tagged) - Number(a.tagged)); + for (const { text } of ranked) { + const cleaned = text.replace(/[^0-9Xx]/g, "").toUpperCase(); + if (isbnChecksum(cleaned)) return cleaned; + } + return ""; +} + interface ManifestItem { href: string; mediaType: string; @@ -317,6 +393,11 @@ function readNavTitles( return titles; } +function fileToDataUri(file: RawFile, mime: string): string { + const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data))); + return `data:${mime};base64,${data}`; +} + function resolveImages( doc: Document, baseDir: string, @@ -335,12 +416,29 @@ function resolveImages( img.removeAttribute("src"); return; } - const mime = mimeByPath.get(resolved) || mimeFromExt(resolved); - const data = file.encoding === "base64" ? file.data : btoa(unescape(encodeURIComponent(file.data))); - img.setAttribute("src", `data:${mime};base64,${data}`); + img.setAttribute("src", fileToDataUri(file, mimeByPath.get(resolved) || mimeFromExt(resolved))); }); } +function findCover( + opf: Document, + manifest: Map, + byPath: Map, +): Cover { + let item = Array.from(manifest.values()).find((m) => m.properties.split(/\s+/).includes("cover-image")); + if (!item) { + const metaCover = Array.from(opf.getElementsByTagName("*")).find( + (el) => el.localName === "meta" && el.getAttribute("name") === "cover", + ); + const id = metaCover?.getAttribute("content"); + if (id) item = manifest.get(id); + } + if (!item) return createCover(); + const file = byPath.get(normalize(item.href)); + if (!file) return createCover(); + return { ...createCover(), kind: "image", image: fileToDataUri(file, item.mediaType || mimeFromExt(item.href)) }; +} + function buildChapter( file: RawFile, baseDir: string, @@ -394,18 +492,21 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B spine.forEach((item, i) => { const file = byPath.get(normalize(item.href)); if (!file) return; - chapters.push( - buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest), - ); + try { + chapters.push( + buildChapter(file, dirOf(item.href), navTitles.get(item.href), i, byPath, manifest), + ); + } catch { + return; + } }); if (!chapters.length) throw new Error("Not a valid EPUB: no readable chapters found."); - const identifier = localText(opf, "identifier"); const metadata: BookMetadata = { title: localText(opf, "title") || fallbackName, subtitle: "", author: localText(opf, "creator"), - isbn: /\d{9,13}/.test(identifier) ? identifier.replace(/[^0-9Xx]/g, "") : "", + isbn: findIsbn(opf), language: localText(opf, "language") || "en", }; @@ -415,7 +516,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B metadata, theme: "quiet-press", settings: { trim: "6x9", bleed: true }, - cover: createCover(), + cover: findCover(opf, manifest, byPath), chapters, }; } 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); } diff --git a/src/library.ts b/src/library.ts index cc7a367..e3a145e 100644 --- a/src/library.ts +++ b/src/library.ts @@ -1,11 +1,12 @@ import { invoke } from "@tauri-apps/api/core"; import { isDesktop } from "./ipc"; -import { type Book, createBook, starterBook } from "./model/book"; +import { type Book, SCHEMA_VERSION, createBook, schemaVersion, starterBook } from "./model/book"; export interface BookSummary { id: string; title: string; author: string; + corrupt?: boolean; } export async function listBooks(): Promise { @@ -15,7 +16,16 @@ export async function listBooks(): Promise { export async function loadBook(id: string): Promise { const contents = await invoke("load_book", { id }); - return JSON.parse(contents) as Book; + let book: Book; + try { + book = JSON.parse(contents) as Book; + } catch { + throw new Error("the book file is corrupt or unreadable"); + } + if (schemaVersion(book.schema) > SCHEMA_VERSION) { + throw new Error("this book was made with a newer version of Margin; update the app to open it"); + } + return book; } export async function saveBook(book: Book): Promise { @@ -35,3 +45,18 @@ export function newBook(): Book { export function exampleBook(): Book { return starterBook(); } + +export async function createAndOpenBook( + open: (book: Book) => void, + onError?: (message: string) => void, +): Promise { + const book = newBook(); + if (isDesktop) { + try { + await saveBook(book); + } catch (e) { + onError?.(`Could not create book: ${e}`); + } + } + open(book); +} diff --git a/src/model/book.ts b/src/model/book.ts index 5db51dc..4e78be3 100644 --- a/src/model/book.ts +++ b/src/model/book.ts @@ -115,6 +115,14 @@ export interface Book { chapters: Chapter[]; } +export const SCHEMA_VERSION = 1; + +export function schemaVersion(schema: unknown): number { + if (typeof schema !== "string") return 0; + const match = /^margin\/(\d+)$/.exec(schema); + return match ? Number(match[1]) : 0; +} + function paragraph(text: string): JSONContent { return { type: "paragraph", content: [{ type: "text", text }] }; } @@ -140,11 +148,19 @@ export function createCover(): Cover { return { kind: "default", image: "", bg: COVER_PALETTES[0].bg, ink: COVER_PALETTES[0].ink }; } +const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" }; +const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true }; + export function normalizeBook(book: Book): Book { + const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean); return { ...book, + metadata: { ...DEFAULT_METADATA, ...book.metadata }, + settings: { ...DEFAULT_SETTINGS, ...book.settings }, cover: book.cover ? { ...createCover(), ...book.cover } : createCover(), - chapters: book.chapters.map((c) => (c.updatedAt ? c : { ...c, updatedAt: Date.now() })), + chapters: (chapters.length ? chapters : [createChapter()]).map((c) => + c.updatedAt ? c : { ...c, updatedAt: Date.now() }, + ), }; } @@ -152,9 +168,9 @@ export function createBook(): Book { return { schema: "margin/1", id: crypto.randomUUID(), - metadata: { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" }, + metadata: { ...DEFAULT_METADATA }, theme: "quiet-press", - settings: { trim: "6x9", bleed: true }, + settings: { ...DEFAULT_SETTINGS }, cover: createCover(), chapters: [createChapter("Chapter One")], }; diff --git a/src/store/useBook.ts b/src/store/useBook.ts index c356ba5..79414f5 100644 --- a/src/store/useBook.ts +++ b/src/store/useBook.ts @@ -12,6 +12,8 @@ import { createPage, normalizeBook, } from "../model/book"; +import { loadActiveChapter, saveActiveChapter } from "../editor/positions"; +import { saveBook } from "../library"; export const COVER_ID = "__cover__"; @@ -40,7 +42,11 @@ interface BookState { markSaved: () => void; } -export const useBook = create((set) => ({ +function flushOutgoing(book: Book | null, dirty: boolean): void { + if (book && dirty) saveBook(book).catch(() => {}); +} + +export const useBook = create((set, get) => ({ book: null, activeChapterId: "", dirty: false, @@ -49,10 +55,17 @@ export const useBook = create((set) => ({ setExporting: (label) => set({ exporting: label }), setNotice: (message) => set({ notice: message }), openBook: (book) => { + flushOutgoing(get().book, get().dirty); const normalized = normalizeBook(book); - set({ book: normalized, activeChapterId: normalized.chapters[0]?.id ?? "", dirty: false }); + const saved = loadActiveChapter(normalized.id); + const valid = saved === COVER_ID || normalized.chapters.some((c) => c.id === saved); + const activeChapterId = valid ? (saved as string) : normalized.chapters[0]?.id ?? ""; + set({ book: normalized, activeChapterId, dirty: false }); + }, + closeBook: () => { + flushOutgoing(get().book, get().dirty); + set({ book: null, activeChapterId: "", dirty: false }); }, - closeBook: () => set({ book: null, activeChapterId: "", dirty: false }), setActiveChapter: (id) => set({ activeChapterId: id }), setChapterContent: (id, content) => set((s) => @@ -132,3 +145,11 @@ export const useBook = create((set) => ({ set((s) => (s.book ? { dirty: true, book: { ...s.book, cover: { ...s.book.cover, ...patch } } } : {})), markSaved: () => set({ dirty: false }), })); + +let lastActive = ""; +useBook.subscribe((s) => { + if (s.book && s.activeChapterId && s.activeChapterId !== lastActive) { + lastActive = s.activeChapterId; + saveActiveChapter(s.book.id, s.activeChapterId); + } +}); diff --git a/src/styles/app.css b/src/styles/app.css index e3cd46e..b4a1ae0 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1209,6 +1209,16 @@ body.resizing { box-shadow: 0 4px 10px rgba(35, 32, 27, 0.1), 0 20px 38px rgba(35, 32, 27, 0.13); } +.card-corrupt { + cursor: default; + opacity: 0.65; + border-style: dashed; +} + +.card-corrupt:hover { + transform: none; +} + .card-title { font-family: var(--font-book); font-size: 20px;