mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33ef53936e | ||
|
|
738eb9c9cf |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "margin-app",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "margin-app"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-destroy",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"updater:default",
|
||||
|
||||
@@ -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<PathBuf, String> {
|
||||
@@ -19,6 +29,13 @@ fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn book_path(app: &tauri::AppHandle, id: &str) -> Result<PathBuf, String> {
|
||||
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<Vec<BookSummary>, String> {
|
||||
let dir = library_dir(&app)?;
|
||||
@@ -31,16 +48,24 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, 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<Vec<BookSummary>, String> {
|
||||
id: id.to_string(),
|
||||
title,
|
||||
author,
|
||||
corrupt: false,
|
||||
});
|
||||
}
|
||||
Ok(books)
|
||||
@@ -65,19 +91,19 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_book(app: tauri::AppHandle, id: String) -> Result<String, String> {
|
||||
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())?;
|
||||
}
|
||||
|
||||
+24
-7
@@ -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<Vec<u8>, String> {
|
||||
fn compile(source: String, images: &[ImageInput]) -> Result<(Vec<u8>, String), String> {
|
||||
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len());
|
||||
for image in images {
|
||||
let bytes = STANDARD
|
||||
@@ -31,14 +32,30 @@ fn compile(source: String, images: &[ImageInput]) -> Result<Vec<u8>, 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<ImageInput>) -> Result<tauri::ipc::Response, String> {
|
||||
compile(source, &images).map(tauri::ipc::Response::new)
|
||||
pub fn compile_pdf(
|
||||
app: tauri::AppHandle,
|
||||
source: String,
|
||||
images: Vec<ImageInput>,
|
||||
emit_warnings: bool,
|
||||
) -> Result<tauri::ipc::Response, String> {
|
||||
let (bytes, warnings) = compile(source, &images)?;
|
||||
if emit_warnings && !warnings.is_empty() {
|
||||
app.emit("pdf-warnings", warnings).ok();
|
||||
}
|
||||
Ok(tauri::ipc::Response::new(bytes))
|
||||
}
|
||||
|
||||
fn format_diagnostics(error: &typst_as_lib::TypstAsLibError) -> String {
|
||||
|
||||
@@ -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<String, String> {
|
||||
@@ -9,11 +36,11 @@ pub fn read_file(path: String) -> Result<String, String> {
|
||||
|
||||
#[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)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "margin",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"identifier": "studio.margin.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -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": {
|
||||
|
||||
+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)}>
|
||||
{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]);
|
||||
|
||||
|
||||
+19
-19
@@ -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 persist = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
const capture = () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
savePosition(bookId, openChapter.current, { from, to, scroll: scroller?.scrollTop ?? 0 });
|
||||
}, 400);
|
||||
latest.current = { from, to, scroll: scroller?.scrollTop ?? 0 };
|
||||
};
|
||||
const persist = () => {
|
||||
capture();
|
||||
clearTimeout(timer);
|
||||
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, " ")
|
||||
|
||||
+121
-20
@@ -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 [];
|
||||
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;
|
||||
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