mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 11:07:05 +00:00
feature fixes
This commit is contained in:
1 parent
586ee946d0
commit
c1c47bc513
63 files changed
+12083
-229
No files matched your search
@@ -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
|
||||
/// `` 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. `` 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>,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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, ``
|
||||
/// 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")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user