feature fixes

This commit is contained in:
pj committed 2026-08-28 21:47:48 +05:30
1 parent 586ee946d0
commit c1c47bc513
63 files changed
+12083 -229

No files matched your search

+58
View File
@@ -183,3 +183,61 @@ pub struct SpellIssue {
#[serde(default)]
pub suggestions: Vec<String>,
}
/// An image the PDF exporter has to put on a page.
///
/// `data` present means the bytes came with the request, base64 encoded, which is the only way a
/// mermaid diagram can arrive: the frontend renders it to SVG and there is no file behind it and
/// never will be. `data` absent means read the file at `path`, which is what an ordinary
/// `![](photo.jpg)` is, and it is absent far more often than not: base64 encoding every photograph
/// in a document through the IPC boundary costs a third again in bytes for a file the backend can
/// already open.
///
/// A read goes through the same root guard every other read in fs.rs does. A document is untrusted
/// input. `![](../../../.ssh/id_rsa)` is a link anybody can type into a markdown file, and an
/// exporter is not the place where this app starts reading outside an open folder.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ImageInput {
/// How the Typst source refers to it, which is also the key the file resolver answers on.
pub path: String,
#[serde(default)]
pub data: Option<String>,
}
/// Something the exporter worked around rather than something it refused to do. Payload of the
/// `pdf-warnings` event, which is how these travel: a compile answers with raw bytes and has
/// nowhere to put a second value.
///
/// `count` is here because the alternative is forty toasts. A document with forty formulas the
/// converter could not typeset has one problem, not forty, and the user wants to be told once with
/// a number on it.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfWarning {
/// math | image | typst
pub kind: String,
pub message: String,
pub count: u32,
}
/// One grammar problem in a run of text handed to the checker.
///
/// `start` and `end` are half-open offsets in *characters*, not bytes and not UTF-16 units, for
/// exactly the reason `SpellIssue` gives: the other end is JavaScript addressing a ProseMirror
/// document, and ProseMirror counts in code points. Harper already counts that way, so unlike the
/// spelling path there is no conversion to do and nowhere for one to go wrong.
///
/// `kind` is Harper's own name for the rule that fired, which is what the popover shows above the
/// message so a correction can be judged before it is taken. `suggestions` can be empty: a rule
/// that can see a sentence is wrong without knowing how to fix it is still worth an underline.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GrammarIssue {
pub start: usize,
pub end: usize,
pub kind: String,
pub message: String,
#[serde(default)]
pub suggestions: Vec<String>,
}
+143
View File
@@ -0,0 +1,143 @@
// The faces a PDF is set in.
//
// Nine of them are bundled and compiled into the binary by pdf.rs, one per weight and slope,
// because a document that typesets differently on two machines is not an export. The two things this app does not bundle
// are a monospace family for code and a math family for formulas, both of which are large and
// neither of which the editor shows on screen, so they come off the system instead and this module
// is how they are found.
//
// A family that is not installed is not an error. Typst is handed whatever was found and falls
// back through the rest of its book for anything missing, and the export still happens.
use std::collections::HashSet;
use fontdb::{Database, Family, Query};
/// Monospace families in descending order of preference, starting with the name Typst's `raw`
/// element asks for by default so that a machine which happens to have it needs no help. The rest
/// are what macOS, Windows and the common Linux desktops actually ship.
const MONOSPACE_FAMILIES: [&str; 8] = [
"DejaVu Sans Mono",
"SF Mono",
"Menlo",
"Monaco",
"Andale Mono",
"Consolas",
"Liberation Mono",
"Courier New",
];
/// Math families, again starting with Typst's own default. A math face is not interchangeable with
/// a text one: laying out an equation needs the OpenType MATH table, and Typst will not fall back
/// off this list on its own, so it is long on purpose.
const MATH_FAMILIES: [&str; 8] = [
"New Computer Modern Math",
"Latin Modern Math",
"STIX Two Math",
"Cambria Math",
"XITS Math",
"TeX Gyre Pagella Math",
"DejaVu Math TeX Gyre",
"Asana Math",
];
/// Loading the system font list walks several directories, so it happens once per compile rather
/// than once per family looked up.
fn system_db() -> Database {
let mut db = Database::new();
db.load_system_fonts();
db
}
/// A key that is the same for every face of one font collection, so a `.ttc` is read once instead
/// of once per style. `with_face_data` hands back the whole collection either way, and Typst
/// expands it into every face it holds.
fn source_key(face: &fontdb::FaceInfo) -> String {
match &face.source {
fontdb::Source::File(path) => path.to_string_lossy().into_owned(),
fontdb::Source::SharedFile(path, _) => path.to_string_lossy().into_owned(),
fontdb::Source::Binary(_) => format!("{:?}:{}", face.id, face.index),
}
}
fn installed(face: &fontdb::FaceInfo, family: &str) -> bool {
face.families
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case(family))
}
fn faces_for(db: &Database, family: &str, into: &mut Vec<Vec<u8>>, seen: &mut HashSet<String>) {
let styles = [
(fontdb::Weight::NORMAL, fontdb::Style::Normal),
(fontdb::Weight::NORMAL, fontdb::Style::Italic),
(fontdb::Weight::BOLD, fontdb::Style::Normal),
(fontdb::Weight::BOLD, fontdb::Style::Italic),
];
for (weight, style) in styles {
let query = Query {
families: &[Family::Name(family)],
weight,
style,
..Query::default()
};
let Some(id) = db.query(&query) else { continue };
// fontdb answers a query with its closest match rather than with nothing, so a family that
// is not installed comes back as some unrelated face. Checking the name of what came back
// is the only way to tell a hit from a substitution.
let Some(face) = db.face(id) else { continue };
if !installed(face, family) || !seen.insert(source_key(face)) {
continue;
}
if let Some(bytes) = db.with_face_data(id, |data, _index| data.to_vec()) {
into.push(bytes);
}
}
}
/// What this machine turned out to have.
///
/// The names matter as much as the bytes. Typst warns once per family it was asked for and could
/// not find, so a preamble naming a hopeful list of eight monospaces produces seven warnings on a
/// machine with one of them, and the export ends with a toast about fonts nobody chose. Naming only
/// what is here means naming nothing that is not.
pub struct Fallbacks {
pub fonts: Vec<Vec<u8>>,
pub monospace: Vec<String>,
pub math: Vec<String>,
}
/// The faces the bundle is missing: a monospace for code blocks and a math family for formulas.
///
/// Every candidate that is installed is loaded, not just the first, so that the preamble can name
/// them in preference order and let Typst pick.
pub fn fallbacks() -> Fallbacks {
let db = system_db();
let mut fonts = Vec::new();
let mut seen = HashSet::new();
let monospace = collect_installed(&db, &MONOSPACE_FAMILIES, &mut fonts, &mut seen);
let math = collect_installed(&db, &MATH_FAMILIES, &mut fonts, &mut seen);
Fallbacks {
fonts,
monospace,
math,
}
}
fn collect_installed(
db: &Database,
families: &[&str],
fonts: &mut Vec<Vec<u8>>,
seen: &mut HashSet<String>,
) -> Vec<String> {
families
.iter()
.filter(|family| {
let before = fonts.len();
faces_for(db, family, fonts, seen);
// A family whose file was already loaded under another name is still installed, so the
// count is not the test on its own.
fonts.len() > before || db.faces().any(|face| installed(face, family))
})
.map(|family| (*family).to_string())
.collect()
}
+16
View File
@@ -670,6 +670,22 @@ pub fn duplicate_entry(path: &Path) -> Result<FileNode, String> {
/// Never `fs::remove_file`. These are the user's own documents and this app does not get to be the
/// reason one of them is gone for good.
pub fn trash_entry(path: &Path) -> Result<(), String> {
// Not `trash::delete`, whose macOS default asks Finder to do it over an Apple event. That is
// the method that leaves Put Back on the file, and it is the wrong trade here: an Apple event
// from a hardened runtime needs an entitlement and a one time permission prompt, and a delete
// that fails because the user said no to a dialog about controlling Finder is a worse answer
// than a delete with no Put Back. `trashItemAtURL:` asks nobody, makes no sound and is faster.
// A file trashed this way is still in the Trash and can still be dragged back out.
#[cfg(target_os = "macos")]
{
use trash::macos::{DeleteMethod, TrashContextExtMacos};
let mut context = trash::TrashContext::default();
context.set_delete_method(DeleteMethod::NsFileManager);
context
.delete(path)
.map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(not(target_os = "macos"))]
trash::delete(path).map_err(|e| format!("{}: {e}", path.display()))
}
+128
View File
@@ -0,0 +1,128 @@
// Grammar, which is Harper's.
//
// Deliberately not a port of the sibling's proofing.rs. That file checks spelling and grammar
// together because it had to ship its own speller; here NSSpellChecker already does the spelling
// in spell.rs and macspell.rs, and Harper's own spell rule stays off for the same reason: the
// system checker knows the user's names, their languages and every word they have ever taught
// their Mac, and a second opinion from a bundled dictionary is a worse one.
//
// The engine is built once and kept. Building it reads a curated dictionary and a part of speech
// model out of the binary, which is far too much work to repeat per paragraph, and this module
// keeps it in a static rather than in `tauri::Manager` state because a `LintGroup` is not the kind
// of thing the rest of the crate has any business reaching. Built on the first check rather than
// at launch, so a window opens without waiting for a model nobody has asked a question of yet.
//
// Nothing here is on the main thread. `grammar_check` is a `#[tauri::command(async)]`, so a long
// paragraph does not hold the window still while it is linted, which matters here for the same
// reason it matters in spell.rs: this runs while the user is typing.
use std::sync::{Arc, LazyLock, Mutex};
use harper_core::linting::{LintGroup, Linter, Suggestion};
use harper_core::spell::FstDictionary;
use harper_core::{Dialect, Document};
use crate::dto::GrammarIssue;
/// What the popover offers, which is what the spelling menu beside it offers.
const MAX_SUGGESTIONS: usize = 5;
/// The linter and the dictionary it was built against, which `Document` needs as well.
struct Harper {
linter: LintGroup,
dict: Arc<FstDictionary>,
}
/// Built on first use and kept for the life of the process. The `Mutex` is not about sharing: it
/// is that linting takes `&mut self`, and two paragraphs arriving at once would otherwise have
/// nowhere to queue.
static ENGINE: LazyLock<Mutex<Harper>> = LazyLock::new(|| Mutex::new(build_harper()));
fn build_harper() -> Harper {
let dict = FstDictionary::curated();
let mut linter = LintGroup::new_curated(dict.clone(), Dialect::American);
// The system checker does the spelling, and it does it better: see the note at the top.
linter.config.set_rule_enabled("SpellCheck", false);
Harper { linter, dict }
}
/// Whether this build has a grammar engine behind it.
///
/// A compile-time fact, answered the way `spell_available` answers its own: harper-core is a
/// dependency of this crate or it is not, and on a build where it is there is no runtime state in
/// which the engine has gone missing. Deliberately does not touch `ENGINE`, because the frontend
/// asks this once at launch and forcing the model load here would put the whole of it in front of
/// the first window for an answer that is already known.
#[tauri::command]
pub fn grammar_available() -> Result<bool, String> {
Ok(true)
}
/// Every grammar problem in one run of text, with offsets in characters counted from the start of
/// that run.
///
/// Like `spell_check`, it is told about a paragraph and answers about that paragraph: it has no
/// idea a document exists, and the caller adds its own base offset afterwards. Harper counts in
/// characters already, so unlike the spelling path there is no conversion here and nowhere for one
/// to go wrong.
#[tauri::command(async)]
pub fn grammar_check(text: String) -> Result<Vec<GrammarIssue>, String> {
let mut engine = ENGINE.lock().map_err(|e| e.to_string())?;
Ok(collect_grammar(&mut engine, &text))
}
fn collect_grammar(harper: &mut Harper, text: &str) -> Vec<GrammarIssue> {
let chars: Vec<char> = text.chars().collect();
let doc = Document::new_plain_english(text, harper.dict.as_ref());
let mut issues = Vec::new();
for lint in harper.linter.lint(&doc) {
// Clamped end first and then start against it, so a span this side never indexes past the
// text and never comes out inverted. Harper should not hand back either, but this is a
// foreign engine walking the user's prose and a slice with a bad pair of bounds is a panic
// rather than a wrong underline.
let end = lint.span.end.min(chars.len());
let start = lint.span.start.min(end);
let existing: String = chars[start..end].iter().collect();
// A lint about nothing but whitespace is dropped, and Harper does produce them: two spaces
// between sentences are "French spaces" to it, and the suggestion is one space. There is
// nothing worth drawing there. An underline over characters that are not visible is not
// visible either, it cannot be clicked to reach the offer behind it, and on the caller's
// side those spaces are frequently not the user's at all: src/editor/proofing.ts blanks
// inline code spans and leaf nodes into runs of spaces of the same width so that the words
// either side keep their positions, and a checker underlining those would be underlining
// exactly the thing that file went to the trouble of refusing to send.
if existing.trim().is_empty() {
continue;
}
let mut suggestions = Vec::new();
for suggestion in &lint.suggestions {
match suggestion {
Suggestion::ReplaceWith(replacement) => {
suggestions.push(replacement.iter().collect())
}
// An insertion is offered as the whole of what the span would become, because the
// popover replaces the underlined text with whatever is chosen and knows nothing
// about the shape of the edit behind it.
Suggestion::InsertAfter(insertion) => {
suggestions.push(format!("{existing}{}", insertion.iter().collect::<String>()))
}
Suggestion::Remove => suggestions.push(String::new()),
}
}
suggestions.truncate(MAX_SUGGESTIONS);
issues.push(GrammarIssue {
start,
end,
// Harper's own name for the category the rule falls into, which is what the popover
// shows above the message so a correction can be judged before it is taken.
kind: format!("{:?}", lint.lint_kind),
message: lint.message,
suggestions,
});
}
issues
}
+44
View File
@@ -1,11 +1,15 @@
pub mod dto;
pub mod fonts;
pub mod fs;
pub mod grammar;
pub mod index;
mod library;
#[cfg(target_os = "macos")]
mod macspell;
pub mod pdf;
pub mod spell;
pub mod watch;
pub mod writingtools;
use std::sync::Mutex;
@@ -57,6 +61,9 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
let save = MenuItemBuilder::with_id("save", "Save")
.accelerator("CmdOrCtrl+S")
.build(handle)?;
let export_pdf = MenuItemBuilder::with_id("export-pdf", "Export as PDF…")
.accelerator("CmdOrCtrl+Shift+E")
.build(handle)?;
let close_folder = MenuItemBuilder::with_id("close-folder", "Close Folder").build(handle)?;
let check_updates =
MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?;
@@ -99,6 +106,7 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
&command_palette,
&PredefinedMenuItem::separator(handle)?,
&save,
&export_pdf,
&PredefinedMenuItem::separator(handle)?,
&close_folder,
&PredefinedMenuItem::separator(handle)?,
@@ -114,6 +122,7 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
.item(&command_palette)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&save)
.item(&export_pdf)
.item(&PredefinedMenuItem::separator(handle)?)
.item(&close_folder)
.build()?;
@@ -140,6 +149,28 @@ fn build_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>
app_submenu.insert(&settings, 3)?;
app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?;
}
// Writing Tools, which is the system's and needs macOS 15.1 with Apple Intelligence on.
//
// These two rows carry the chords and Apple's own Writing Tools rows deliberately do not,
// which is the opposite of what the sibling app does. AppKit performs a key equivalent by
// firing its menu item directly, so a chord on the system's row would reach Writing Tools
// without passing the selection guard in src/editor/writing.ts, and that guard exists
// because a rewrite spanning a link loses its address and one spanning two table cells
// widens the table. These ids go through the command table, so they meet the guard.
// Exactly one menu item owns each chord either way; this is which one.
if let Some(edit) = find_submenu("Edit") {
let proofread = MenuItemBuilder::with_id("writing-proofread", "Proofread")
.accelerator("Shift+Alt+F")
.build(handle)?;
let rewrite = MenuItemBuilder::with_id("writing-rewrite", "Rewrite")
.accelerator("Shift+Alt+R")
.build(handle)?;
edit.append_items(&[
&PredefinedMenuItem::separator(handle)?,
&proofread,
&rewrite,
])?;
}
if let Some(view) = find_submenu("View") {
let toggle_sidebar = MenuItemBuilder::with_id("toggle-sidebar", "Toggle Sidebar")
.accelerator("CmdOrCtrl+\\")
@@ -193,6 +224,10 @@ pub fn run() {
if let Err(e) = index::open(app.handle()) {
eprintln!("failed to open the search index: {e}");
}
// The Writing Tools submenu is AppKit's, not build_menu's: the system inserts it into Edit
// on its own terms, so when it is there to label is writingtools.rs's problem and not this
// file's. One call, whatever it decides to wait for.
writingtools::install(app.handle());
Ok(())
});
@@ -216,6 +251,9 @@ pub fn run() {
| "toggle-sidebar"
| "check-updates"
| "report-issue"
| "export-pdf"
| "writing-proofread"
| "writing-rewrite"
) {
app.emit("menu-action", event.id().0.as_str()).ok();
}
@@ -253,6 +291,12 @@ pub fn run() {
spell::spell_learn,
spell::spell_unlearn,
spell::spell_available,
writingtools::writing_available,
writingtools::writing_run,
pdf::pdf_compile,
pdf::pdf_write,
grammar::grammar_available,
grammar::grammar_check,
])
.run(context)
.expect("error while running Margin Docs");
+387
View File
@@ -0,0 +1,387 @@
// PDF export: Typst source in, PDF bytes out, compiled in this process.
//
// Nothing about this touches the user's markdown. The converter that produces the source lives in
// src/export/typst.ts and reads the ProseMirror document; this module compiles what it is given
// and writes the result where a native save panel pointed. A document that has never been saved
// exports exactly as well as one that has.
//
// The compiler sees no filesystem and no network. Everything it can open is put in front of it by
// hand: the nine bundled faces, the images this call was handed, and a vendored copy of mitex
// served at `/mitex/`. That is the whole world, so a document cannot make the exporter fetch a
// package, and the export works on a machine that has never been online.
use std::path::Path;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use tauri::{Emitter, Manager};
use typst::diag::{Severity, SourceDiagnostic, Warned};
use typst::layout::PagedDocument;
use typst_as_lib::TypstEngine;
use crate::dto::{ImageInput, PdfWarning};
// The faces a document is set in, one file per weight and slope.
//
// Not the variable fonts in public/fonts/ that the editor itself renders with, and that is not
// duplication for its own sake: Typst does not support a variable axis, warns that it does not, and
// lays the text out at the default instance regardless of the weight asked for. Every heading, every
// bold run and every callout label would come out at 400, which is a PDF where the hierarchy the
// author can see on screen is gone. These nine are static instances cut from those same four files;
// src-tauri/fonts/PROVENANCE.md is how, and is what to repeat when a face is updated.
static FACES: [&[u8]; 9] = [
include_bytes!("../fonts/Literata-Regular.ttf"),
include_bytes!("../fonts/Literata-SemiBold.ttf"),
include_bytes!("../fonts/Literata-Bold.ttf"),
include_bytes!("../fonts/Literata-Italic.ttf"),
include_bytes!("../fonts/Literata-BoldItalic.ttf"),
include_bytes!("../fonts/HankenGrotesk-Regular.ttf"),
include_bytes!("../fonts/HankenGrotesk-Bold.ttf"),
include_bytes!("../fonts/HankenGrotesk-Italic.ttf"),
include_bytes!("../fonts/HankenGrotesk-BoldItalic.ttf"),
];
// mitex 0.2.5, vendored under src-tauri/vendor/mitex with its LICENSE, and served as ordinary
// paths under `/mitex/` rather than through Typst's package system. A package spec would mean a
// download on first export and a cache directory to keep, for a dependency that is 380K and never
// changes. Every file in the package has to be here: lib.typ imports mitex.typ relatively, that
// imports specs/mod.typ, and mitex.typ loads the wasm module beside it.
static MITEX_LIB: &str = include_str!("../vendor/mitex/lib.typ");
static MITEX_MAIN: &str = include_str!("../vendor/mitex/mitex.typ");
static MITEX_SPECS: &str = include_str!("../vendor/mitex/specs/mod.typ");
static MITEX_PRELUDE: &str = include_str!("../vendor/mitex/specs/prelude.typ");
static MITEX_LATEX: &str = include_str!("../vendor/mitex/specs/latex/standard.typ");
static MITEX_WASM: &[u8] = include_bytes!("../vendor/mitex/mitex.wasm");
/// The import path the generated source uses, and the contract with src/export/typst.ts:
/// `#import "/mitex/lib.typ": mitex, mi`.
const MITEX_ROOT: &str = "/mitex/";
/// What `/mitex/lib.typ` answers with on a second attempt, after a formula stopped the first one.
///
/// It keeps the names the generated source imports and sets every formula as the LaTeX the user
/// wrote, which is the readable thing to do with a formula nothing can typeset. Nothing here loads
/// the wasm module, because that is what failed.
static MITEX_PLAIN_LIB: &str = r#"#let mitex-source(it) = {
if type(it) == str { it } else if type(it) == content and it.has("text") { it.text } else { repr(it) }
}
#let mi(it, ..args) = raw(mitex-source(it))
#let mimath(it, ..args) = raw(mitex-source(it))
#let mitext(it) = raw(mitex-source(it))
#let mitex(it, mode: "math", ..args) = block(raw(mitex-source(it)))
#let mitex-convert(it, mode: "math", spec: none) = mitex-source(it)
"#;
/// A 1x1 transparent image in each format Typst picks from a file extension, so that an image the
/// exporter could not read leaves a gap on the page rather than killing the export.
///
/// One per format because Typst trusts the extension over the bytes: handing PNG bytes to
/// `#image("photo.jpg")` is a decode error, which is the hard failure this exists to avoid. The
/// formats Typst does not recognise from an extension fall through to sniffing the data, so PNG is
/// the right default for those.
const PLACEHOLDER_PNG: &str =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4//8/AwAI/AL+p5qgoAAAAABJRU5ErkJggg==";
const PLACEHOLDER_JPEG: &str = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAA//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AR//Z";
const PLACEHOLDER_GIF: &str = "R0lGODdhAQABAIEAAP///wAAAAAAAAAAACwAAAAAAQABAAAIBAABBAQAOw==";
const PLACEHOLDER_WEBP: &str =
"UklGRkAAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAAFZQOCAYAAAAMAEAnQEqAQABAAFAJiWkAANwAP789AAA";
const PLACEHOLDER_SVG: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\"/>";
fn placeholder_for(path: &str) -> Vec<u8> {
let extension = Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or_default()
.to_lowercase();
let encoded = match extension.as_str() {
"jpg" | "jpeg" => PLACEHOLDER_JPEG,
"gif" => PLACEHOLDER_GIF,
"webp" => PLACEHOLDER_WEBP,
"svg" | "svgz" => return PLACEHOLDER_SVG.as_bytes().to_vec(),
_ => PLACEHOLDER_PNG,
};
STANDARD.decode(encoded).unwrap_or_default()
}
/// The bytes of one image, either the ones that came with the request or the ones on disk.
///
/// A read is guarded exactly as every read in fs.rs is: the path is resolved and has to land
/// inside a folder the user actually opened. A document is untrusted input, `![](../../../.ssh/id_rsa)`
/// is a link anybody can type, and an exporter is not where this app starts reading outside an
/// open folder. The path has to be absolute for that check to mean anything, which is why the
/// converter sends an absolute one for any image it has no bytes for.
fn image_bytes(image: &ImageInput, root_paths: &[String]) -> Result<Vec<u8>, String> {
match &image.data {
Some(data) => STANDARD
.decode(data.as_bytes())
.map_err(|e| format!("could not decode \"{}\": {e}", image.path)),
None => {
let path = crate::fs::resolve_in_roots(root_paths, &image.path)?;
std::fs::read(&path).map_err(|e| format!("could not read \"{}\": {e}", image.path))
}
}
}
fn font_list(families: &[String], last_resort: &str) -> String {
let mut names: Vec<String> = families.iter().map(|f| format!("\"{f}\"")).collect();
names.push(format!("\"{last_resort}\""));
format!("({})", names.join(", "))
}
/// The only Typst this module writes, and it names font families and nothing else.
///
/// It belongs here rather than in the converter because which faces the compiler can see is this
/// module's business: nine bundled, plus whichever monospace and math families the machine turned
/// out to have. src/export/typst.ts names none of them for exactly that reason.
///
/// Math is the one that cannot be left alone. Typst sets every equation to the single family
/// "New Computer Modern Math" and switches font fallback off while it does it, so on a machine
/// without that family one formula is a hard compile error rather than a warning. Naming a list is
/// what makes a formula typeset at all, and Literata closes both lists so that a machine with none
/// of the candidates gets a page that reads badly and a warning saying so, instead of no PDF.
///
/// Only families that are actually installed are named, because Typst warns once for every family
/// it was asked for and could not find, and a hopeful list would end every export with a toast
/// about fonts nobody chose.
fn font_preamble(fallbacks: &crate::fonts::Fallbacks) -> String {
format!(
"#set text(font: \"Literata\")\n\
#show raw: set text(font: {})\n\
#show math.equation: set text(font: {})\n",
font_list(&fallbacks.monospace, "Literata"),
font_list(&fallbacks.math, "Literata"),
)
}
/// Adds a warning, or bumps the count of one already there.
///
/// Forty formulas that would not typeset are one problem and not forty toasts, and the same
/// message arriving twice is the overwhelmingly common case: one broken construct repeated down a
/// document.
fn note(warnings: &mut Vec<PdfWarning>, kind: &str, message: String) {
if let Some(existing) = warnings
.iter_mut()
.find(|w| w.kind == kind && w.message == message)
{
existing.count += 1;
return;
}
warnings.push(PdfWarning {
kind: kind.to_string(),
message,
count: 1,
});
}
fn in_mitex(span: typst::syntax::Span) -> bool {
span.id().is_some_and(|id| {
id.vpath()
.as_rooted_path()
.to_string_lossy()
.starts_with(MITEX_ROOT)
})
}
fn touches_mitex(diagnostic: &SourceDiagnostic) -> bool {
in_mitex(diagnostic.span) || diagnostic.trace.iter().any(|point| in_mitex(point.span))
}
/// Which kind a diagnostic gets, or `None` for one the user should never see.
///
/// A warning raised inside the vendored mitex sources with nothing in its trace leading back out
/// of them is mitex talking about itself: a deprecation in a pinned copy of a dependency, the same
/// on every machine and in every document, and nothing anybody reading a toast can act on. One
/// with the document in its trace is a formula that did not come out right, which is a "math"
/// warning and is worth saying.
fn kind_for(diagnostic: &SourceDiagnostic) -> Option<&'static str> {
if !in_mitex(diagnostic.span) {
return Some("typst");
}
if diagnostic.trace.iter().any(|point| !in_mitex(point.span)) {
return Some("math");
}
None
}
/// One pass over the compiler. `lib` is what `/mitex/lib.typ` answers with, which is the whole
/// difference between a formula that typesets and one that is shown as the source the user wrote.
///
/// The one error in this crate that is not a `String`, and deliberately. The caller has to ask
/// whether the failure came from inside mitex before it decides whether a second attempt is worth
/// making, and that question is asked of the diagnostics' spans. Formatting them first would throw
/// away the only thing the answer depends on.
fn compile_once(
source: &str,
lib: &str,
binaries: &[(&str, Vec<u8>)],
fonts: &[Vec<u8>],
) -> Result<(Vec<u8>, Vec<SourceDiagnostic>), Vec<SourceDiagnostic>> {
let engine = TypstEngine::builder()
.main_file(source)
.fonts(fonts.iter().map(|font| font.as_slice()))
.with_static_source_file_resolver([
("/mitex/lib.typ", lib),
("/mitex/mitex.typ", MITEX_MAIN),
("/mitex/specs/mod.typ", MITEX_SPECS),
("/mitex/specs/prelude.typ", MITEX_PRELUDE),
("/mitex/specs/latex/standard.typ", MITEX_LATEX),
])
.with_static_file_resolver(binaries.iter().map(|(path, bytes)| (*path, bytes.as_slice())))
.build();
let Warned { output, warnings } = engine.compile();
let document: PagedDocument = output.map_err(|e| match e {
typst_as_lib::TypstAsLibError::TypstSource(diagnostics) => diagnostics.into_iter().collect(),
other => vec![SourceDiagnostic::error(
typst::syntax::Span::detached(),
other.to_string(),
)],
})?;
let bytes = typst_pdf::pdf(&document, &Default::default())
.map_err(|d| d.into_iter().collect::<Vec<_>>())?;
Ok((bytes, warnings.into_iter().collect()))
}
/// Compiles Typst source to PDF bytes, with whatever the compiler had to work around.
///
/// Separate from the command so a test can reach it: a `#[tauri::command]` taking an `AppHandle`
/// needs a running app, and none of the work below wants one.
pub fn compile(
source: String,
images: &[ImageInput],
root_paths: &[String],
) -> Result<(Vec<u8>, Vec<PdfWarning>), String> {
let mut warnings: Vec<PdfWarning> = Vec::new();
let mut binaries: Vec<(&str, Vec<u8>)> = Vec::with_capacity(images.len() + 1);
for image in images {
match image_bytes(image, root_paths) {
Ok(bytes) => binaries.push((image.path.as_str(), bytes)),
Err(e) => {
note(&mut warnings, "image", e);
binaries.push((image.path.as_str(), placeholder_for(&image.path)));
}
}
}
binaries.push(("/mitex/mitex.wasm", MITEX_WASM.to_vec()));
// The two families this app does not bundle, a monospace for code blocks and a math face for
// formulas, come off the system. Neither is shown in the editor, both are large, and shipping a
// mono nobody ever sees on screen so that a code block matches across machines is a trade this
// project has decided against.
let mut fallbacks = crate::fonts::fallbacks();
let mut fonts: Vec<Vec<u8>> = FACES.iter().map(|face| face.to_vec()).collect();
fonts.append(&mut fallbacks.fonts);
let source = format!("{}{source}", font_preamble(&fallbacks));
let failure = match compile_once(&source, MITEX_LIB, &binaries, &fonts) {
Ok((bytes, diagnostics)) => {
for diagnostic in &diagnostics {
if let Some(kind) = kind_for(diagnostic) {
note(&mut warnings, kind, diagnostic.message.to_string());
}
}
return Ok((bytes, warnings));
}
Err(failure) => failure,
};
// mitex turns LaTeX into Typst by running a wasm module over it, and a formula it cannot parse
// is a hard error rather than a bad-looking equation. One `$\frac{$` a user typed halfway down
// a page of notes would otherwise cost them the entire export, so the second attempt serves a
// stand-in `/mitex/lib.typ` that sets every formula as the source the user actually wrote. The
// page reads worse and the warning says so, which is a trade the user can act on.
if !failure.iter().any(touches_mitex) {
return Err(format_diagnostics(&failure));
}
let (bytes, diagnostics) = compile_once(&source, MITEX_PLAIN_LIB, &binaries, &fonts)
// The first failure is the one worth reading: the second is whatever the stand-in tripped
// over on the way, and the formula that started it is named in the first.
.map_err(|_| format_diagnostics(&failure))?;
note(
&mut warnings,
"math",
"a formula could not be typeset, so every formula is shown as the source it was written in"
.to_string(),
);
for diagnostic in &diagnostics {
if let Some(kind) = kind_for(diagnostic) {
note(&mut warnings, kind, diagnostic.message.to_string());
}
}
Ok((bytes, warnings))
}
/// Compiles Typst source to PDF bytes. Warnings, when there are any, arrive separately on the
/// `pdf-warnings` event, because a compile answers with raw bytes and has nowhere to put a second
/// value.
#[tauri::command(async)]
pub fn pdf_compile(
app: tauri::AppHandle,
source: String,
images: Vec<ImageInput>,
) -> Result<tauri::ipc::Response, String> {
// The same gate `fs::checked` puts in front of every other read, reached the same way it is:
// the open roots out of shared state, then `resolve_in_roots`. The lock is dropped before the
// compile, which is the slowest thing this app does and has no business holding it.
let roots = app.state::<crate::Roots>();
let root_paths: Vec<String> = {
let open = roots.0.lock().map_err(|e| e.to_string())?;
open.iter().map(|root| root.path.clone()).collect()
};
let (bytes, warnings) = compile(source, &images, &root_paths)?;
if !warnings.is_empty() {
app.emit("pdf-warnings", warnings).ok();
}
Ok(tauri::ipc::Response::new(bytes))
}
/// Writes the finished file to wherever the save panel pointed.
#[tauri::command(async)]
pub fn pdf_write(path: String, bytes: Vec<u8>) -> Result<(), String> {
// No root guard here, deliberately, and the next person to read this will assume that is a
// bug. It is not: this path came from the user through a native save panel, so it is the
// user's own choice of destination and not something a document said. The guard exists to stop
// an untrusted document naming a path, and saving somewhere outside every open folder is the
// ordinary case rather than the attack.
//
// What that argument does not cover is a destination that is already one of the user's
// documents. `atomic_write` replaces whatever is there, so a `.md` name typed into the save
// panel is the one way an export can destroy markdown, and this app does not get to be the
// reason a document is gone. The save panel carries a PDF filter and AppKit usually appends
// `.pdf` to a name typed with another extension, but that is the panel being careful rather
// than this command, and the promise is not the panel's to keep.
//
// Only a file that is already there is refused. Writing a document-shaped name that nothing
// holds yet is odd rather than destructive, and the user can see what they typed.
let target = Path::new(&path);
if target.is_file() && matches!(crate::fs::kind_for(target, false), "markdown" | "text") {
let name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.clone());
return Err(format!("{name} is a document. A PDF cannot be written over it."));
}
crate::fs::atomic_write(target, &bytes)
}
fn format_diagnostics(diagnostics: &[SourceDiagnostic]) -> String {
diagnostics
.iter()
.map(|diagnostic| {
let kind = match diagnostic.severity {
Severity::Error => "error",
Severity::Warning => "warning",
};
let mut message = format!("{kind}: {}", diagnostic.message);
for hint in &diagnostic.hints {
message.push_str(&format!("\n hint: {hint}"));
}
message
})
.collect::<Vec<_>>()
.join("\n")
}
+146
View File
@@ -0,0 +1,146 @@
// Writing Tools, which is Apple's and not this app's.
//
// There is no API to call. The system puts a "Writing Tools" submenu on the Edit menu of any app
// with an editable text view, and the only way in from here is to find that item on the live
// NSMenu and perform it. What happens next happens in the webview, to the DOM, without asking:
// that is the whole risk in this feature, docs/architecture.md is where the seam is described, and
// src/editor/writing.ts is where the selection that may be handed to one is decided.
#[cfg(target_os = "macos")]
use std::sync::atomic::{AtomicBool, Ordering};
/// What the main thread last saw. Read below by a command that has no handle to hop with.
#[cfg(target_os = "macos")]
static SUBMENU_SEEN: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "macos")]
mod mac {
use objc2::rc::Retained;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSMenu, NSMenuItem};
use objc2_foundation::NSArray;
fn submenu_named(items: &NSArray<NSMenuItem>, title: &str) -> Option<Retained<NSMenu>> {
for i in 0..items.count() {
let item = items.objectAtIndex(i);
if item.title().to_string() == title {
return item.submenu();
}
}
None
}
fn edit_menu(mtm: MainThreadMarker) -> Option<Retained<NSMenu>> {
let main = NSApplication::sharedApplication(mtm).mainMenu()?;
for i in 0..main.numberOfItems() {
let Some(item) = main.itemAtIndex(i) else { continue };
let Some(submenu) = item.submenu() else { continue };
if submenu.title().to_string() == "Edit" || item.title().to_string() == "Edit" {
return Some(submenu);
}
}
None
}
pub fn writing_tools_menu(mtm: MainThreadMarker) -> Option<Retained<NSMenu>> {
submenu_named(&edit_menu(mtm)?.itemArray(), "Writing Tools")
}
/// Whether the submenu is on the Edit menu right now, and false off the main thread, where
/// AppKit may not be asked.
pub fn available() -> bool {
MainThreadMarker::new().is_some_and(|mtm| writing_tools_menu(mtm).is_some())
}
/// Fires one row of the submenu by its title, which is English because the frontend has no way
/// to know what this Mac calls it. A row that is not found is an error rather than a no-op: a
/// gesture that does nothing and says nothing is the worst answer available.
pub fn perform(tool: &str) -> Result<(), String> {
let Some(mtm) = MainThreadMarker::new() else {
return Err("Writing Tools has to be performed on the main thread.".into());
};
let Some(menu) = writing_tools_menu(mtm) else {
return Err("This Mac has no Writing Tools menu.".into());
};
let items = menu.itemArray();
for i in 0..items.count() {
if items.objectAtIndex(i).title().to_string() == tool {
menu.performActionForItemAtIndex(i as isize);
return Ok(());
}
}
Err(format!("The Writing Tools menu has no {tool} item."))
}
}
/// Called once from lib.rs's `setup`, which is early enough.
///
/// The sibling app looks for the submenu here and it was worth checking rather than copying,
/// because the submenu is AppKit's and nothing in this process puts it there. Measured on macOS
/// 26.5: at `setup`, before the webview has loaded a page, the Edit menu already carries "Writing
/// Tools" with Proofread and Rewrite on it, so there is nothing to wait for and no window event to
/// hang this off. A Mac without Apple Intelligence has no submenu at any moment, which is the same
/// code path.
///
/// This deliberately does not put Shift+Option+F and Shift+Option+R on Apple's own rows, which is
/// what the sibling does. AppKit performs a key equivalent by firing the menu item directly, so a
/// chord on the system's row reaches Writing Tools without passing the selection guard in
/// src/editor/writing.ts, and that guard is refusing selections that corrupt the file: a rewrite
/// spanning a link loses its address, one spanning two table cells widens the table. The chords are
/// on this app's own Edit rows instead, in lib.rs, where they route through the command table and
/// meet the guard. Exactly one menu item still owns each chord.
pub fn install(_app: &tauri::AppHandle) {
#[cfg(target_os = "macos")]
SUBMENU_SEEN.store(mac::available(), Ordering::Relaxed);
}
/// Whether this machine can actually run a Writing Tool. Writing Tools needs macOS 15.1 and Apple
/// Intelligence turned on, and `minimumSystemVersion` for this app is 10.15, so an unavailable menu
/// is the ordinary case and not an error: the frontend turns it into a toast rather than a button
/// that silently does nothing.
///
/// Answered from the live menu rather than from a version number, because the version is necessary
/// and not sufficient: the feature is off until the user turns Apple Intelligence on, and the
/// submenu is the only thing that knows. This signature carries no `AppHandle` to hop threads with,
/// so off the main thread it answers with what `install` saw at launch instead of guessing.
#[tauri::command]
pub fn writing_available() -> Result<bool, String> {
#[cfg(target_os = "macos")]
{
if mac::available() {
SUBMENU_SEEN.store(true, Ordering::Relaxed);
return Ok(true);
}
Ok(SUBMENU_SEEN.load(Ordering::Relaxed))
}
#[cfg(not(target_os = "macos"))]
{
Ok(false)
}
}
/// Fires one item on the system's Writing Tools submenu, by its English title.
#[tauri::command]
pub fn writing_run(app: tauri::AppHandle, tool: String) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
use objc2::MainThreadMarker;
// Performed here when this is already the main thread, so a missing row comes back as an
// error the frontend can say out loud. The hop is the fallback and it cannot report: waiting
// on its answer would deadlock exactly when the wait was unnecessary.
if MainThreadMarker::new().is_some() {
return mac::perform(&tool);
}
app.run_on_main_thread(move || {
if let Err(e) = mac::perform(&tool) {
eprintln!("writing tools: {e}");
}
})
.map_err(|e| e.to_string())
}
#[cfg(not(target_os = "macos"))]
{
let _ = (app, tool);
Err("Writing Tools is a macOS feature.".into())
}
}