mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
Quality bugfix plan (#2)
* fix(persist): atomic+durable .margin writes Route save_book, write_file, write_bytes through a shared atomic_write helper: write to a temp file, fsync, then rename into place so a crash mid-write can never leave a truncated or empty manuscript. Rotate the prior copy to .bak for manuscripts (not exports) so one good copy always survives. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(editor): isolate undo history per chapter Key the editor by chapter id so each chapter gets a fresh TipTap instance with its own history. Previously a single instance was reused across chapters, so the content swap entered the undo stack and Ctrl+Z after switching restored the prior chapter's doc into the current one, which autosave then persisted. With remount, save the cursor/scroll position on unmount (from a ref, not the editor, which is destroyed by then) and load it on mount, replacing the in-place chapter-switch bookkeeping. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * feat(editor): persist active chapter per book Remember which chapter was open per book and restore it on reopen, falling back to the first chapter when the saved id is no longer valid. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(persist): flush pending save on navigate-away and quit Edits are saved on an 800ms debounce, so leaving a book within that window lost the latest changes. Flush a dirty book before openBook / closeBook swap state (covers All books, open-another, Cmd+N), and add a window close-request handler that saves before destroying on quit. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(persist): guard markSaved against in-flight edits markSaved cleared dirty unconditionally, so an edit made while a save was in flight had its dirty flag wiped and its follow-up save dropped. Only clear dirty when the book reference is unchanged since the save started (immutable updates give a fresh reference per edit). Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(persist): persist new books on create A freshly created book lived only in memory until the first edit, so quitting beforehand lost it and it never appeared in the library. Save it to disk on creation via a shared createAndOpenBook used by both the Library card and the New Book menu action. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(export): correct list nesting, marks, line-start escaping, svg type Four export-correctness fixes intermingled across the Typst and EPUB renderers: - H7: neutralize newlines in escaped text and apply leading-marker escaping (= - + / and N.) to the first text node of every block (paragraph, heading, list item, blockquote) and figure captions, even when that run carries a mark, so author text can never inject Typst markup or phantom headings/TOC entries. - H9: render lists recursively so nested lists survive and multi- paragraph list items keep their paragraph breaks (Typst functional list()/enum(); EPUB nested <ul>/<ol> and multiple <p> in <li>). - H10: render strike, underline and inline code marks in both exporters (Typst #strike/#underline/#raw; EPUB <s>/<u>/<code> with css). - H8b: normalize +-bearing image subtypes so an imported svg+xml cover becomes cover.svg with the correct media type. Validated the generated Typst constructs compile with typst 0.15. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * 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 * fix(ui): handle library-open errors Opening a book card swallowed failures: a corrupt or unreadable file failed silently and normalizeBook could throw on a missing chapters array. Surface load errors as a toast, throw a clear message on unparseable JSON, and make normalizeBook tolerant of missing chapters, metadata and settings. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(preview): destroy pdf.js document on recompile The preview created a new PDF document on every recompile but never destroyed the old one, leaking the document and its worker transport. Keep the loading task and destroy it on effect cleanup. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(library): surface unreadable books list_books silently skipped any .margin file it could not read, parse, or that lacked an id, so a truncated manuscript just vanished. Emit a corrupt placeholder entry instead and show it in the library as a non-openable card with a recovery hint pointing at the .bak backup. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(persist): schema-version guard and editor content check A book file's declared schema was never read, so a file from a newer app version would be opened, normalized and autosaved over in the old format. Refuse to open books whose schema is newer than supported. Enable the editor content check so chapters with unreadable content warn the user instead of silently loading stripped content that the next edit would persist. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(editor): prune chapter positions on delete and guard quota Saved cursor/scroll and active-chapter entries accumulated per book forever and were never removed when a book was deleted. Add clearPositions and call it from the delete flow, and guard localStorage writes against quota or unavailable storage. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(security): validate book id paths load/save/delete_book built paths from the raw id, and ids round-trip from file JSON, so a crafted id could escape the library directory. Route all three through a book_path helper that rejects anything but [A-Za-z0-9_-]. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * chore(security): restrictive content security policy security.csp was null (no CSP). Add a restrictive policy as defense-in-depth: self-only by default, data:/blob: images for cover and figure URLs, inline styles for React/inline style attributes, and blob: workers for pdf.js. Needs verification against a production build (covers, preview, export, IPC). Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(import): preserve link, underline, strike and code marks EPUB import only mapped bold and italic; a, u, s/del/strike and code elements lost their formatting (kept as plain text). Map them to the matching marks, consistent with what the exporters now render. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(import): keep inline svg and unresolved image alt text Inline svg elements were dropped entirely and an img whose source could not be resolved was discarded. Serialize a standalone svg to a data:image/svg+xml figure, and fall back to an unresolved image's alt text as a paragraph instead of losing it. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(import): keep table rows together Tables were flattened to one paragraph per cell, losing row grouping. Join each row's cells into a single paragraph so the row structure survives (the model has no table node). Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(import): import the cover image The EPUB cover was discarded and replaced with the default generated cover. Locate the cover via the manifest cover-image property or the meta name="cover" reference, resolve it to a data URI, and import it as an image cover. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(import): validate ISBN with checksum The importer accepted any 9-13 digit run as the ISBN, so a UUID or arbitrary number became a bogus ISBN. Prefer scheme-tagged identifiers and accept only values that pass the ISBN-10/13 length and checksum test, leaving it empty otherwise. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * fix(import): isolate bad chapters and bound nesting depth A deeply nested document could overflow the stack and abort the entire import. Bound block recursion depth and wrap each chapter build in a try/catch so one unreadable chapter is skipped instead of failing the whole import. Claude-Session: https://claude.ai/code/session_01RzTWrLiy4zGuw2hZSQGamk * changes
This commit is contained in:
22 files changed
+495
-118
No files matched your search
+22
-2
@@ -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<string>("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<string>("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());
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
</header>
|
||||
<Editor
|
||||
key={chapter.id}
|
||||
bookId={book.id}
|
||||
chapterId={chapter.id}
|
||||
content={chapter.content}
|
||||
onChange={(content) => setChapterContent(chapter.id, content)}
|
||||
onReady={setEditor}
|
||||
onContentError={() =>
|
||||
setNotice("This chapter contains content Margin couldn't fully read; editing may drop the unrecognized parts.")
|
||||
}
|
||||
/>
|
||||
</article>
|
||||
<FloatingToolbar editor={editor} />
|
||||
|
||||
@@ -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 }) {
|
||||
<div className="library">
|
||||
<header className="library-head" data-tauri-drag-region />
|
||||
<div className="shelf">
|
||||
<button className="card card-action" onClick={() => onOpen(newBook())}>
|
||||
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
|
||||
<Icon d="M12 5v14M5 12h14" size={20} />
|
||||
<span>New book</span>
|
||||
</button>
|
||||
@@ -78,13 +80,25 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||
<span className="card-badge">Example</span>
|
||||
</button>
|
||||
)}
|
||||
{books.map((b) => (
|
||||
<div key={b.id} className="card card-book" onClick={() => loadBook(b.id).then(onOpen)}>
|
||||
<span className="card-title">{b.title || "Untitled"}</span>
|
||||
{b.author && <span className="card-author">{b.author}</span>}
|
||||
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
||||
</div>
|
||||
))}
|
||||
{books.map((b) =>
|
||||
b.corrupt ? (
|
||||
<div key={b.id} className="card card-book card-corrupt">
|
||||
<span className="card-title">{b.title}</span>
|
||||
<span className="card-author">Couldn't be read; a .bak backup may sit beside it.</span>
|
||||
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={b.id}
|
||||
className="card card-book"
|
||||
onClick={() => loadBook(b.id).then(onOpen).catch((e) => setNotice(`Could not open book: ${e}`))}
|
||||
>
|
||||
<span className="card-title">{b.title || "Untitled"}</span>
|
||||
{b.author && <span className="card-author">{b.author}</span>}
|
||||
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{notice && (
|
||||
<div className="toast" onClick={() => setNotice(null)}>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
+18
-18
@@ -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<ChapterPosition | null>(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<typeof setTimeout>;
|
||||
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 <EditorContent editor={editor} className="editor-host" />;
|
||||
}
|
||||
+38
-1
@@ -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<string, string> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(ACTIVE_KEY) || "{}") as Record<string, string>;
|
||||
} 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);
|
||||
}
|
||||
+15
-7
@@ -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 = `<code>${text}</code>`;
|
||||
if (marks.some((m) => m.type === "bold")) text = `<strong>${text}</strong>`;
|
||||
if (marks.some((m) => m.type === "italic")) text = `<em>${text}</em>`;
|
||||
if (marks.some((m) => m.type === "strike")) text = `<s>${text}</s>`;
|
||||
if (marks.some((m) => m.type === "underline")) text = `<u>${text}</u>`;
|
||||
const href = marks.find((m) => m.type === "link")?.attrs?.href;
|
||||
if (href) text = `<a href="${attr(href)}">${text}</a>`;
|
||||
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, string>): 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 `<li>${inner}</li>`;
|
||||
}
|
||||
|
||||
@@ -92,9 +95,9 @@ function block(node: JSONContent, paths: Map<string, string>): string {
|
||||
case "blockquote":
|
||||
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
|
||||
case "bulletList":
|
||||
return `<ul>${(node.content ?? []).map(listItem).join("")}</ul>`;
|
||||
return `<ul>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ul>`;
|
||||
case "orderedList":
|
||||
return `<ol>${(node.content ?? []).map(listItem).join("")}</ol>`;
|
||||
return `<ol>${(node.content ?? []).map((li) => listItem(li, paths)).join("")}</ol>`;
|
||||
case "horizontalRule":
|
||||
return `<hr class="scene-break"/>`;
|
||||
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;
|
||||
|
||||
@@ -9,7 +9,7 @@ const EPUB = [{ name: "EPUB", extensions: ["epub"] }];
|
||||
|
||||
export async function exportPdf(book: Book): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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 {
|
||||
|
||||
+53
-20
@@ -10,27 +10,32 @@ const TRIM: Record<TrimSize, { w: string; h: string }> = {
|
||||
};
|
||||
|
||||
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, string>): 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, string>): string {
|
||||
@@ -59,7 +64,7 @@ function figure(node: JSONContent, paths: Map<string, string>): 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, string>): string {
|
||||
function block(node: JSONContent, paths: Map<string, string>): 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<st
|
||||
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 {
|
||||
return (title || "")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
|
||||
|
||||
+130
-29
@@ -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<string, unknown> };
|
||||
|
||||
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<string, unknown>): 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<string, ManifestItem>,
|
||||
byPath: Map<string, RawFile>,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
|
||||
+6
-2
@@ -7,7 +7,11 @@ export interface ImageInput {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export async function compilePdf(source: string, images: ImageInput[] = []): Promise<Uint8Array> {
|
||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images });
|
||||
export async function compilePdf(
|
||||
source: string,
|
||||
images: ImageInput[] = [],
|
||||
emitWarnings = false,
|
||||
): Promise<Uint8Array> {
|
||||
const buffer = await invoke<ArrayBuffer>("compile_pdf", { source, images, emitWarnings });
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
+27
-2
@@ -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<BookSummary[]> {
|
||||
@@ -15,7 +16,16 @@ export async function listBooks(): Promise<BookSummary[]> {
|
||||
|
||||
export async function loadBook(id: string): Promise<Book> {
|
||||
const contents = await invoke<string>("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<void> {
|
||||
@@ -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<void> {
|
||||
const book = newBook();
|
||||
if (isDesktop) {
|
||||
try {
|
||||
await saveBook(book);
|
||||
} catch (e) {
|
||||
onError?.(`Could not create book: ${e}`);
|
||||
}
|
||||
}
|
||||
open(book);
|
||||
}
|
||||
+19
-3
@@ -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")],
|
||||
};
|
||||
|
||||
+24
-3
@@ -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<BookState>((set) => ({
|
||||
function flushOutgoing(book: Book | null, dirty: boolean): void {
|
||||
if (book && dirty) saveBook(book).catch(() => {});
|
||||
}
|
||||
|
||||
export const useBook = create<BookState>((set, get) => ({
|
||||
book: null,
|
||||
activeChapterId: "",
|
||||
dirty: false,
|
||||
@@ -49,10 +55,17 @@ export const useBook = create<BookState>((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<BookState>((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);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user