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

+4701 -43
View File
File diff suppressed because it is too large. Load diff
+51 -1
View File
@@ -33,13 +33,45 @@ trash = "5"
rusqlite = { version = "0.40", features = ["bundled"] }
tokio = { version = "1", features = ["sync", "time"] }
# PDF export. Typst is the typesetter: a document becomes Typst source, the source is compiled in
# process and the bytes go to whatever the native save panel pointed at. No headless browser, no
# LaTeX install, nothing for the user to have on their machine first.
#
# typst and typst-pdf move together and are pinned to the same minor, because typst-as-lib links
# against a particular pair and a mismatched trio does not compile. fontdb finds a system face for
# the one thing this app does not bundle, a monospace family for code.
typst = "0.14.2"
typst-pdf = "0.14.2"
typst-as-lib = "0.15.5"
fontdb = "0.23"
# Inline image bytes arrive over the IPC boundary as base64: a mermaid diagram is rendered to SVG
# in the webview and has no file behind it to read.
base64 = "0.22"
# Grammar, which is Harper's and is the one checker this app does ship. Spelling stays the system's
# below; Harper's own spell rule is turned off in grammar.rs for that reason.
#
# Pinned exactly: the [patch] stubs at the foot of this file are tied to this version's burn/cubecl
# graph. A minor bump could silently invalidate a patch ("unused"), and the whole CUDA and LLVM
# subtree those stubs remove would come back. Bump deliberately and re-audit the stubs.
harper-core = { version = "=2.5.0", features = ["concurrent"] }
# Spelling is the system's, not ours. NSSpellChecker is the same checker every other Mac app
# corrects into, so a word learned in Mail is not underlined here, and it carries the user's own
# languages without this app shipping a dictionary. These objc2 crates are already in the graph
# through Tauri, so asking for them adds nothing to the build but the features named.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSSpellChecker"] }
# NSMenu and its neighbours are for Writing Tools, which has no API this app can call: the system
# puts a submenu on Edit and the only way in is to find that item and perform it. Deliberately not
# NSWritingToolsCoordinator, which the sibling asks for and never uses.
objc2-app-kit = { version = "0.3", features = [
"NSSpellChecker",
"NSApplication",
"NSMenu",
"NSMenuItem",
"NSResponder",
] }
objc2-foundation = { version = "0.3", features = ["NSString", "NSArray", "NSRange", "NSTextCheckingResult"] }
# There is no auto-updater and no process to restart on a phone: the store is the update channel.
@@ -50,3 +82,21 @@ tauri-plugin-updater = "2"
[dev-dependencies]
tempfile = "3"
# harper-core transitively declares optional, disabled GPU backends through burn: a `burn-cuda`
# CUDA backend and, under cubecl, a `cubecl-cpu` LLVM/MLIR JIT runtime. Both are off, and neither
# is ever compiled, but Cargo still version-resolves and downloads the whole dead subtree behind
# them, which is the cuda toolchain crates on one side and tracel-llvm plus its LLVM bundler on the
# other. Replacing the two roots with empty stubs takes several hundred crates out of the graph and
# a large part of the build with them.
[patch.crates-io]
burn-cuda = { path = "stubs/burn-cuda" }
cubecl-cpu = { path = "stubs/cubecl-cpu" }
# Optimize dependencies even in dev builds. Harper's grammar engine, and the burn-ndarray POS
# tagger under it, is roughly ten times slower unoptimized, which is the difference between a check
# that lands while the user is still typing the next word and one that takes seconds. This compiles
# dependencies at opt-level 3 and leaves this crate itself unoptimized, so incremental rebuilds of
# our own code stay fast. The first build after adding it is slower, once.
[profile.dev.package."*"]
opt-level = 3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31
View File
@@ -0,0 +1,31 @@
# Where these faces came from
Nine static instances, cut from the four variable fonts in `public/fonts/` that the editor renders
with. Same designs, same licence, same bytes underneath: the only difference is that a weight axis
has been pinned rather than left open.
They exist because Typst does not support a variable axis. It warns that it does not, then lays the
text out at the default instance whatever weight was asked for, so a PDF set from the variable files
has its headings, its bold runs and its callout labels all at 400 and no visible hierarchy at all.
They live here rather than in `public/fonts/` because nothing in the webview loads them. Everything
under `public/` is copied into the frontend bundle, and these are compiled into the binary by
`src-tauri/src/pdf.rs`, so keeping them here ships them once instead of twice.
To regenerate after updating a variable font, with fonttools installed:
```python
from fontTools.ttLib import TTFont
from fontTools.varLib import instancer
font = TTFont("public/fonts/Literata-VF.ttf")
instancer.instantiateVariableFont(font, {"wght": 600, "opsz": 12}, inplace=True)
# then set name IDs 1, 2, 4, 6, 16 and 17 to the family and style, and save.
font.save("src-tauri/fonts/Literata-SemiBold.ttf")
```
Literata pins `opsz` to 12, which is its own default and the size the body text is set at. The
weights are 400, 600 and 700 upright and 400 and 700 italic for Literata, and 400 and 700 in both
slopes for Hanken Grotesk, which are the weights `src/export/typst.ts` asks for. Typst falls back to
the nearest weight it has, so a face that is missing is a heading that comes out too heavy rather
than an export that fails.
+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())
}
}
+21
View File
@@ -0,0 +1,21 @@
# Empty stand-in for `burn-cuda`, used via [patch.crates-io] in ../../Cargo.toml.
# harper-core pulls burn-cuda only as an optional, disabled CUDA backend. This stub keeps
# the heavy cubecl + tracel-llvm subtree out of the dependency graph. Never compiled.
# The feature names below must match the ones `burn` references via `burn-cuda?/...` so that
# Cargo's feature validation passes; they are all empty no-ops.
[package]
name = "burn-cuda"
version = "0.19.1"
edition = "2021"
publish = false
[lib]
path = "src/lib.rs"
[features]
default = []
std = []
doc = []
fusion = []
autotune = []
autotune-checks = []
+1
View File
@@ -0,0 +1 @@
// Intentionally empty. See Cargo.toml: this stubs out the disabled `burn-cuda` backend.
+17
View File
@@ -0,0 +1,17 @@
# Empty stand-in for `cubecl-cpu`, used via [patch.crates-io] in ../../Cargo.toml.
# cubecl-cpu is the LLVM/MLIR JIT CPU runtime pulled (transitively, through burn's disabled GPU
# backends) by harper-core. It drags in the tracel-llvm / tracel-llvm-bundler crates, which build
# and bundle an LLVM toolchain and are by a wide margin the most expensive thing in the graph.
# Replacing it with this empty stub removes that whole subtree. Nothing here is ever compiled
# (GPU backends are off).
[package]
name = "cubecl-cpu"
version = "0.8.1"
edition = "2021"
publish = false
[lib]
path = "src/lib.rs"
[features]
default = []
+1
View File
@@ -0,0 +1 @@
// Intentionally empty. See Cargo.toml: this stubs out the disabled cubecl-cpu JIT runtime.
+2 -1
View File
@@ -41,7 +41,8 @@
"icons/icon.ico"
],
"macOS": {
"minimumSystemVersion": "10.15"
"minimumSystemVersion": "10.15",
"hardenedRuntime": true
}
}
}
@@ -0,0 +1,481 @@
// An export over a real folder of markdown, asked the same question src-tauri/tests/no_write_on_open.rs
// asks of a save: did anything in the folder move?
//
// src-tauri/tests/pdf.rs already covers what the compiler refuses and what it survives, against a
// TempDir with two files in it. That is the right shape for a question about diagnostics and the
// wrong shape for a question about bytes: a bag of files in a temporary folder cannot say that a
// folder somebody would plausibly have opened comes back from `git status` with no lines in it. So
// this suite runs against the same generated git repository the no-write suite does, built by
// tests/support/notes_repo.rs, and the oracle is the same one: `git status --porcelain`, plus a
// stat and content snapshot of every path under the root so that a rewrite with identical bytes is
// still caught.
//
// The tests share that one folder and `pristine()` resets it, so they hold a lock rather than
// needing `--test-threads=1`. Running the binary under any thread count is correct.
//
// The sharp question is at the bottom, and it is about `pdf_write` rather than about the compiler.
// `pdf_write` skips the open-roots guard on purpose, because its path came from a native save panel
// and is the user's own choice of destination. What that also means is that it will write to
// whatever it is given, and nothing on either side of the boundary checks that the destination is
// not one of the user's own documents. `a_save_panel_pointed_at_a_document_is_refused` is what
// happens then, written down so that it is a fact somebody decided rather than one nobody noticed.
use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use margin_docs_lib::dto::ImageInput;
use margin_docs_lib::pdf::{compile, pdf_write};
use tempfile::TempDir;
#[path = "support/notes_repo.rs"]
mod notes_repo;
// ---------------------------------------------------------------- fixture
/// One test in the folder at a time. The suite shares a single repository and `pristine()` throws
/// away whatever the last test did to it, so this is what makes the sharing safe under `cargo
/// test`'s default thread count instead of a flag in CI that somebody has to remember.
static FIXTURE: Mutex<()> = Mutex::new(());
/// Back to the committed state, with the lock held for as long as the guard lives.
///
/// A poisoned lock is taken anyway: it means an earlier test panicked, which is a failure that has
/// already been reported, and refusing to run the rest of the suite on top of it turns one red test
/// into a file of them.
fn pristine() -> (MutexGuard<'static, ()>, PathBuf) {
let guard = FIXTURE.lock().unwrap_or_else(|e| e.into_inner());
let root = notes_repo::path().to_path_buf();
assert!(
root.join(".git").is_dir(),
"the fixture repo is missing: {}",
root.display()
);
notes_repo::git(&["reset", "--hard", "-q"]);
notes_repo::git(&["clean", "-fdq"]);
let status = notes_repo::git(&["status", "--porcelain"]);
assert!(
status.is_empty(),
"the fixture repo did not start clean:\n{status}"
);
(guard, root)
}
fn git_status() -> String {
notes_repo::git(&["status", "--porcelain"])
}
fn quoted(status: &str) -> String {
if status.is_empty() {
" <empty: working tree clean>".to_string()
} else {
status
.lines()
.map(|line| format!(" {line}"))
.collect::<Vec<_>>()
.join("\n")
}
}
fn assert_clean(label: &str) {
let status = git_status();
println!(" [{label}] git status --porcelain:\n{}", quoted(&status));
assert!(status.is_empty(), "{label} left git dirty:\n{status}");
}
// ---------------------------------------------------------------- snapshots
/// Enough of a file to notice a rewrite that put the same bytes back. `git status` cannot see one
/// of those and it is exactly what an exporter tidying up after itself would leave.
#[derive(Clone, PartialEq, Eq, Debug)]
struct Stamp {
kind: &'static str,
len: u64,
mtime: (i64, i64),
ino: u64,
/// Content hash, for everything outside the vendored `node_modules`, where the stat is already
/// conclusive and hashing 13,000 files on every snapshot is not worth the second it costs.
hash: Option<u64>,
}
type Snapshot = BTreeMap<String, Stamp>;
fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in bytes {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
/// Every path under `root`, dotfiles, .git and node_modules included.
fn snapshot(root: &Path) -> Snapshot {
let mut out = Snapshot::new();
walk(root, root, &mut out);
out
}
fn walk(root: &Path, dir: &Path, out: &mut Snapshot) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
let Ok(meta) = fs::symlink_metadata(&path) else {
continue;
};
let kind = if meta.is_dir() {
"dir"
} else if meta.is_symlink() {
"link"
} else {
"file"
};
let cheap = rel.starts_with("node_modules/") || rel.starts_with(".git/");
out.insert(
rel,
Stamp {
kind,
len: meta.len(),
mtime: (meta.mtime(), meta.mtime_nsec()),
ino: meta.ino(),
hash: if kind == "file" && !cheap {
fs::read(&path).ok().map(|bytes| fnv1a(&bytes))
} else {
None
},
},
);
if meta.is_dir() {
walk(root, &path, out);
}
}
}
/// What moved between two snapshots, ignoring nothing.
fn changes(before: &Snapshot, after: &Snapshot) -> (Vec<String>, Vec<String>, Vec<String>) {
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
for (path, stamp) in after {
match before.get(path) {
None => added.push(path.clone()),
Some(was) if was != stamp => {
changed.push(format!("{path}\n was {was:?}\n now {stamp:?}"))
}
Some(_) => {}
}
}
for path in before.keys() {
if !after.contains_key(path) {
removed.push(path.clone());
}
}
(added, removed, changed)
}
fn assert_untouched(label: &str, before: &Snapshot, after: &Snapshot) {
let (added, removed, changed) = changes(before, after);
assert!(
added.is_empty() && removed.is_empty() && changed.is_empty(),
"{label} touched the folder\n added: {added:?}\n removed: {removed:?}\n changed:\n {}",
changed.join("\n ")
);
println!(" [{label}] {} paths, none touched", before.len());
}
/// The same, allowing exactly the paths named to appear and the folders holding them to have been
/// written into. A PDF exported next to the documents is a new file, and a new file is a new mtime
/// on its parent directory; nothing else is allowed to move.
fn assert_only_added(label: &str, before: &Snapshot, after: &Snapshot, expected: &[&str]) {
let (added, removed, changed) = changes(before, after);
assert_eq!(added, expected, "{label} added something unexpected");
assert!(removed.is_empty(), "{label} removed {removed:?}");
let parents: Vec<String> = expected
.iter()
.map(|p| match p.rfind('/') {
Some(at) => p[..at].to_string(),
None => String::new(),
})
.collect();
let unexpected: Vec<&String> = changed
.iter()
.filter(|entry| {
let path = entry.lines().next().unwrap_or_default();
!parents.iter().any(|parent| parent == path)
})
.collect();
assert!(
unexpected.is_empty(),
"{label} changed more than the folder it wrote into:\n {}",
unexpected
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join("\n ")
);
println!(" [{label}] added {expected:?} and moved nothing else");
}
// ---------------------------------------------------------------- the document
/// The head of what src/export/typst.ts writes: the mitex import that is the contract between the
/// converter and src-tauri/src/pdf.rs, and set rules naming no font, because which faces exist is
/// the backend's business.
const PREAMBLE: &str = r#"#import "/mitex/lib.typ": mitex, mi
#set document(title: "Handbook")
#set page(paper: "a4", margin: 2cm)
#set text(size: 11pt, lang: "en")
"#;
/// A drawn mermaid diagram as it crosses the boundary: SVG from the webview, with no file behind it.
const DIAGRAM: &[u8] =
br##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="8"><rect width="16" height="8" fill="#456"/></svg>"##;
/// The whole export, as the converter would have written it for a document in this folder: a
/// picture that is a real file inside the open root, a drawn diagram that is bytes, a table and a
/// formula. Every one of those is a path the exporter does extra work on, and three of them mean
/// the compiler opening something.
fn document_from(root: &Path) -> (String, Vec<ImageInput>) {
let picture = root.join("assets/logo.png");
assert!(picture.is_file(), "the fixture has an asset to point at");
let source = format!(
"{PREAMBLE}
= The handbook
Prose, then a picture that is a file in the open folder.
#image(\"{}\")
#figure(image(\"/inline/diagram-1.svg\"))
#table(columns: 2, [region], [total], [north], [12])
Inline #mi(\"a^2 + b^2 = c^2\") and a display one:
#mitex(\"\\\\frac{{1}}{{2}} \\\\int_0^1 x^2 dx\")
",
picture.display()
);
let images = vec![
ImageInput {
path: picture.to_string_lossy().into_owned(),
data: None,
},
ImageInput {
path: "/inline/diagram-1.svg".to_string(),
data: Some(STANDARD.encode(DIAGRAM)),
},
];
(source, images)
}
fn roots_of(root: &Path) -> Vec<String> {
vec![root.to_string_lossy().into_owned()]
}
// ================================================================ compiling
#[test]
fn compiling_a_document_out_of_a_real_folder_writes_nothing_in_it() {
let (_lock, root) = pristine();
let (source, images) = document_from(&root);
let before = snapshot(&root);
let (bytes, warnings) =
compile(source, &images, &roots_of(&root)).expect("the folder compiles");
let after = snapshot(&root);
assert_eq!(&bytes[..4], b"%PDF", "the answer is a PDF");
// The picture really was read off disk rather than worked around, so the one step of the
// compile that opens a file in the user's folder is a step this test actually took.
let worked_around: Vec<&str> = warnings
.iter()
.filter(|w| w.kind == "image")
.map(|w| w.message.as_str())
.collect();
assert!(
worked_around.is_empty(),
"the image in the open folder was read: {worked_around:?}"
);
assert_untouched("a compile", &before, &after);
assert_clean("a compile");
}
#[test]
fn compiling_the_same_folder_ten_times_over_still_writes_nothing() {
// Once could be a compile that failed early and touched nothing because it did nothing. Ten
// laps, with the fonts loaded and the images opened every time, is the shape of the thing the
// user actually does: export, read it, change a line, export again.
let (_lock, root) = pristine();
let before = snapshot(&root);
for lap in 0..10 {
let (source, images) = document_from(&root);
let (bytes, _) =
compile(source, &images, &roots_of(&root)).unwrap_or_else(|e| panic!("lap {lap}: {e}"));
assert_eq!(&bytes[..4], b"%PDF");
}
assert_untouched("ten compiles", &before, &snapshot(&root));
assert_clean("ten compiles");
}
// ================================================================ writing
#[test]
fn exporting_to_a_folder_of_its_own_leaves_the_documents_alone() {
// The ordinary export: the panel points somewhere outside the notes folder, which is where a
// PDF belongs. Nothing in the folder the document came from has any business moving, and the
// whole of `git status` is the evidence.
let (_lock, root) = pristine();
let (source, images) = document_from(&root);
let elsewhere = TempDir::new().expect("a temp dir");
let target = elsewhere.path().join("The handbook.pdf");
let before = snapshot(&root);
let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles");
pdf_write(target.to_string_lossy().into_owned(), bytes.clone()).expect("the panel's path");
let after = snapshot(&root);
assert_eq!(fs::read(&target).expect("the PDF is there"), bytes);
assert_untouched("an export to another folder", &before, &after);
assert_clean("an export to another folder");
}
#[test]
fn exporting_beside_the_documents_adds_the_pdf_and_moves_nothing_else() {
// The other ordinary export, and the one that cannot leave `git status` empty: a PDF written
// next to the document is a new file in the folder and shows up as one. What matters is that
// it is the only line, and that no document was rewritten to put it there.
let (_lock, root) = pristine();
let (source, images) = document_from(&root);
let target = root.join("docs/guides/setup.pdf");
let before = snapshot(&root);
let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles");
pdf_write(target.to_string_lossy().into_owned(), bytes).expect("a path inside the root");
let after = snapshot(&root);
assert_only_added(
"an export into the folder",
&before,
&after,
&["docs/guides/setup.pdf"],
);
let status = git_status();
println!(
"git status --porcelain after an export into the folder:\n{}",
quoted(&status)
);
assert_eq!(
status.lines().collect::<Vec<_>>(),
vec!["?? docs/guides/setup.pdf"],
"the PDF is the only thing git can see"
);
// And the atomic write left nothing of its own behind: the temp file it renames through is
// gone, so there is no `.setup.pdf.tmp` for the next `git add -A` to sweep up.
let strays: Vec<String> = fs::read_dir(root.join("docs/guides"))
.expect("the guides folder")
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| !name.ends_with(".md") && name != "setup.pdf")
.collect();
assert!(strays.is_empty(), "the write left {strays:?} behind");
}
// ================================================================ the guard that is not there
#[test]
fn a_save_panel_pointed_at_a_document_is_refused() {
// This test was written the other way up, pinning the overwrite as a fact somebody had decided.
// Nobody had: it was the one way an export could destroy markdown, and it needed only a user who
// reached the save panel and typed a `.md` name into it. It has not been seen because the panel
// carries a PDF filter and AppKit usually appends `.pdf` to a name typed with another extension,
// which is the panel being careful rather than this module, and the promise that the editor
// never writes a file the user did not edit is not the panel's to keep.
//
// So `pdf_write` now refuses a destination that already holds one of the user's documents, and
// this is the same test rewritten to the truth that replaced it. Everything about the rest of
// the decision still stands: the root guard is still off this path on purpose, which is what
// `pdf_write_will_also_write_outside_every_open_folder` below is about.
let (_lock, root) = pristine();
let (source, images) = document_from(&root);
let document = root.join("docs/design.md");
let markdown = fs::read(&document).expect("a document to aim at");
assert!(
markdown.starts_with(b"#") || markdown.len() > 100,
"the fixture document has real markdown in it"
);
let (bytes, _) = compile(source, &images, &roots_of(&root)).expect("the folder compiles");
let answer = pdf_write(document.to_string_lossy().into_owned(), bytes.clone());
let message = answer.expect_err("a document is not a place to put a PDF");
assert!(
message.contains("design.md") && message.contains("document"),
"the refusal names the file and says why: {message}"
);
assert_eq!(
fs::read(&document).expect("the document is still a file"),
markdown,
"the document is untouched, byte for byte"
);
let status = git_status();
assert_eq!(status, "", "the folder is clean:\n{}", quoted(&status));
// A .txt is a document too, and a .pdf that is already there is not: replacing one is what a
// save panel is for, and it has already asked.
let notes = root.join("docs/notes.txt");
fs::write(&notes, b"a plain text document\n").expect("a text file");
let again = bytes;
pdf_write(notes.to_string_lossy().into_owned(), again.clone())
.expect_err("a .txt is a document as much as a .md is");
let existing = root.join("docs/already.pdf");
fs::write(&existing, b"%PDF-1.7 an older export\n").expect("an older PDF");
pdf_write(existing.to_string_lossy().into_owned(), again.clone())
.expect("replacing a PDF is what the panel asked about");
assert_eq!(fs::read(&existing).expect("the newer export"), again);
let fresh = root.join("docs/never-existed.md");
pdf_write(fresh.to_string_lossy().into_owned(), again)
.expect("a name nothing holds destroys nothing, however odd it looks");
}
#[test]
fn pdf_write_will_also_write_outside_every_open_folder() {
// The half of the same decision that is intended, kept next to the half that is not so the two
// are read together. A PDF belongs on the Desktop or in Downloads far more often than it
// belongs in the notes folder, so `resolve_in_roots` is not on this path and must not be. The
// fix for the test above, if there is one, is not a root guard.
let (_lock, root) = pristine();
let elsewhere = TempDir::new().expect("a temp dir");
let target = elsewhere.path().join("report.pdf");
pdf_write(
target.to_string_lossy().into_owned(),
b"%PDF-1.7\n".to_vec(),
)
.expect("somewhere the user never opened");
assert_eq!(fs::read(&target).expect("the PDF"), b"%PDF-1.7\n");
assert_clean("a write outside every root");
let _ = root;
}
+126
View File
@@ -0,0 +1,126 @@
// What the grammar checker promises the frontend, asserted against the real engine.
//
// Three things, and the first is the one that would be invisible everywhere else. Offsets are in
// characters, and src/editor/proofing.ts adds a ProseMirror position to them without converting
// anything, so an engine that counted bytes would draw its underlines further and further to the
// left of the words they are about for every non-ASCII character earlier in the paragraph. That is
// a bug nobody writing in English would ever see and everybody writing in French or Polish would
// see immediately, and no other test in this repository is in a position to notice it: the Rust
// suite otherwise asserts about files, and the browser suite talks to the fixture in
// src/dev/mockIpc.ts rather than to Harper.
//
// The second is that Harper's own spell rule is off. It is turned off in src-tauri/src/grammar.rs because
// NSSpellChecker already does the spelling and knows the user's own names and languages, and a
// second dictionary underlining "Yoshinari" would be exactly the noise that gets a checker switched
// off for good. Left on, everything would still be green: there would just be spelling issues
// arriving through the grammar command, drawn in the grammar colour, with no Learn item on them.
//
// The third is that the engine survives being asked twice. It is a static built on first use and a
// `LintGroup` that lints through `&mut self`, so a second call is the one that would find a lock
// held or a state left dirty by the first.
use margin_docs_lib::dto::GrammarIssue;
use margin_docs_lib::grammar::{grammar_available, grammar_check};
/// The text a lint is about, sliced the way the frontend slices it: by character.
fn flagged(text: &str, issue: &GrammarIssue) -> String {
text.chars()
.skip(issue.start)
.take(issue.end - issue.start)
.collect()
}
fn check(text: &str) -> Vec<GrammarIssue> {
grammar_check(text.to_string()).expect("the checker answered")
}
#[test]
fn a_build_with_harper_in_it_says_so() {
assert!(grammar_available().expect("availability answered"));
}
#[test]
fn a_repeated_word_is_found_and_can_be_corrected() {
let issues = check("I put the the book down.");
let repeat = issues
.iter()
.find(|issue| flagged("I put the the book down.", issue).contains("the the"))
.expect("the repeated word was found");
assert!(!repeat.kind.is_empty(), "a lint carries the rule's category");
assert!(!repeat.message.is_empty(), "and something to show the reader");
assert!(
repeat.suggestions.iter().any(|s| s.trim() == "the"),
"with the correction on it: {:?}",
repeat.suggestions
);
}
#[test]
fn offsets_are_characters_and_not_bytes() {
// Every character before the mistake is three bytes in UTF-8, so a checker counting bytes would
// report a span three times too far along and the assertion below would slice the wrong words.
let text = "Zażółć gęślą jaźń, and then I put the the book down.";
let issues = check(text);
let repeat = issues
.iter()
.find(|issue| flagged(text, issue).contains("the the"))
.expect("the repeated word was found after a run of non-ASCII text");
assert_eq!(flagged(text, repeat), "the the");
assert!(
repeat.end <= text.chars().count(),
"and it ends inside the run it was told about"
);
}
#[test]
fn spelling_is_left_to_the_system_checker() {
// Not a word in any dictionary, and Harper's curated one included. Nothing may come back about
// it, because the only rule that would have is the one src-tauri/src/grammar.rs turns off.
let text = "The flurbulent maglifter needs oiling.";
for issue in check(text) {
assert_ne!(
issue.kind, "Spelling",
"the grammar checker reported a spelling issue: {issue:?}"
);
}
}
#[test]
fn nothing_to_say_about_nothing() {
assert!(check("").is_empty());
}
/// Harper flags two spaces between sentences as "French spaces", and src-tauri/src/grammar.rs drops every
/// lint whose whole span is whitespace for the reasons written there: an underline nobody can see
/// or click on, and, when the spaces are the ones src/editor/proofing.ts writes in place of an
/// inline code span, an underline over the one thing that file went out of its way not to send.
///
/// These two inputs are the ones that reach the filter, so taking the filter out fails this test
/// rather than leaving it green. What it cannot see is a future Harper that stops raising the lint
/// at all, which would leave the guard unreached and this file none the wiser.
#[test]
fn a_lint_about_nothing_but_spaces_is_not_reported() {
assert!(
check("This is fine. Two spaces there.").is_empty(),
"the two spaces between the sentences were reported"
);
// The shape src/editor/proofing.ts produces from a blanked inline code span.
assert!(
check("The value is nine.").is_empty(),
"a blanked code span was reported as a run of spaces"
);
}
#[test]
fn the_engine_is_kept_and_answers_the_same_way_twice() {
let text = "I put the the book down.";
let first = check(text);
let second = check(text);
assert_eq!(first.len(), second.len());
assert_eq!(
first.first().map(|i| (i.start, i.end)),
second.first().map(|i| (i.start, i.end))
);
}
+316
View File
@@ -0,0 +1,316 @@
// The exporter compiles a document nobody has checked, so the tests here are about what it refuses
// and what it survives rather than about the typesetting: a formula reaches mitex instead of the
// page as source, an image outside every open folder is never read, and neither a broken link nor a
// formula the converter choked on costs the user the whole PDF.
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use margin_docs_lib::dto::ImageInput;
use margin_docs_lib::pdf::compile;
use tempfile::TempDir;
/// A real 4x4 PNG, so an image that is meant to be read is one Typst can actually decode, and one
/// the placeholder cannot be mistaken for.
const PNG: &str =
"iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAE0lEQVR4nGO8I2LDAANMcBZeDgA8PAE0qLfS9QAAAABJRU5ErkJggg==";
/// The shape of what src/export/typst.ts puts at the top of a document with a formula in it: the
/// mitex import, which is the contract between the converter and this module, and set rules that
/// name neither the monospace nor the maths face, because which of those exist is the backend's
/// business and a family named hopefully is a warning per export about fonts nobody chose. A
/// document set rule after the preamble pdf.rs prepends is part of what these fixtures prove.
const PREAMBLE: &str = r#"#import "/mitex/lib.typ": mitex, mi
#set document(title: "Fixture")
#set page(paper: "a4", margin: 2cm)
#set text(size: 11pt, lang: "en")
"#;
fn png_bytes() -> Vec<u8> {
STANDARD.decode(PNG).unwrap()
}
fn root() -> (TempDir, Vec<String>) {
let dir = TempDir::new().expect("a temp dir");
let paths = vec![dir.path().to_string_lossy().into_owned()];
(dir, paths)
}
fn kinds<'a>(warnings: &'a [margin_docs_lib::dto::PdfWarning], kind: &str) -> Vec<&'a str> {
warnings
.iter()
.filter(|w| w.kind == kind)
.map(|w| w.message.as_str())
.collect()
}
#[test]
fn a_document_with_an_image_and_a_formula_compiles() {
let (dir, roots) = root();
let photo = dir.path().join("photo.png");
std::fs::write(&photo, png_bytes()).unwrap();
let source = format!(
"{PREAMBLE}
= Fixture
Inline #mi(\"a^2 + b^2 = c^2\") in a sentence.
#mitex(\"\\\\frac{{1}}{{2}} \\\\int_0^1 x^2 dx\")
#image(\"{}\")
Diagram: #image(\"diagram.svg\")
",
photo.display()
);
let images = vec![
ImageInput {
path: photo.to_string_lossy().into_owned(),
data: None,
},
// How a mermaid diagram arrives: rendered to SVG in the webview, with no file behind it.
ImageInput {
path: "diagram.svg".to_string(),
data: Some(STANDARD.encode(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="#333"/></svg>"##,
)),
},
];
let (bytes, warnings) = compile(source, &images, &roots).expect("the fixture compiles");
assert_eq!(&bytes[..4], b"%PDF", "the answer is a PDF");
assert!(
kinds(&warnings, "image").is_empty(),
"no image was worked around: {:?}",
kinds(&warnings, "image")
);
assert!(
kinds(&warnings, "math").is_empty(),
"no formula was worked around: {:?}",
kinds(&warnings, "math")
);
}
#[test]
fn a_formula_typesets_through_mitex_rather_than_falling_back_to_source() {
let (_dir, roots) = root();
// Only the wasm plugin can turn `\frac{a}{b}` into Typst's own `frac(a, b )`, so asserting on
// what came back out of it proves the whole path resolved: the import, the relative imports
// underneath it, the specs and the plugin. An `assert` inside the document makes a wrong answer
// a compile error rather than a difference in bytes nobody would notice.
let source = format!(
"{PREAMBLE}#import \"/mitex/lib.typ\": mitex-convert
#assert.eq(mitex-convert(\"\\\\frac{{a}}{{b}}\"), \"frac(a ,b )\")
#mi(\"\\\\alpha + \\\\beta\")
"
);
let (bytes, warnings) = compile(source, &[], &roots).expect("mitex loads and converts");
assert_eq!(&bytes[..4], b"%PDF");
assert!(
kinds(&warnings, "math").is_empty(),
"the formula typeset without a workaround: {:?}",
kinds(&warnings, "math")
);
}
#[test]
fn the_mitex_files_are_the_only_thing_the_compiler_can_import() {
let (_dir, roots) = root();
let source = format!("{PREAMBLE}#import \"/etc/passwd\": *\n");
let error = compile(source, &[], &roots).expect_err("nothing outside the vendored files loads");
assert!(
error.contains("file not found"),
"the compiler has no filesystem: {error}"
);
}
#[test]
fn an_image_outside_every_open_folder_is_never_read() {
let (dir, roots) = root();
// Somewhere the user did not open, which is what `![](../../../.ssh/id_rsa)` resolves to.
let elsewhere = TempDir::new().expect("a temp dir");
let secret = elsewhere.path().join("id_rsa");
std::fs::write(&secret, "PRIVATE KEY").unwrap();
let _ = dir;
let source = format!(
"{PREAMBLE}#image(\"{}\")\n",
secret.display()
);
let images = vec![ImageInput {
path: secret.to_string_lossy().into_owned(),
data: None,
}];
let (bytes, warnings) = compile(source, &images, &roots).expect("the export still happens");
assert_eq!(&bytes[..4], b"%PDF");
let refused = kinds(&warnings, "image");
assert_eq!(refused.len(), 1, "one image was worked around: {refused:?}");
assert!(
refused[0].contains("outside every open folder"),
"the root guard is what refused it: {}",
refused[0]
);
assert!(
!String::from_utf8_lossy(&bytes).contains("PRIVATE KEY"),
"nothing from the file reached the page"
);
}
#[test]
fn one_broken_image_does_not_cost_the_whole_export() {
let (dir, roots) = root();
let missing = dir.path().join("gone.jpg");
let source = format!(
"{PREAMBLE}#image(\"{0}\")\n\n#image(\"{0}\")\n",
missing.display()
);
let images = vec![
ImageInput {
path: missing.to_string_lossy().into_owned(),
data: None,
},
ImageInput {
path: missing.to_string_lossy().into_owned(),
data: None,
},
];
let (bytes, warnings) = compile(source, &images, &roots).expect("the export still happens");
assert_eq!(&bytes[..4], b"%PDF");
// Two broken links to one file are one problem with a number on it, not two toasts.
let counted: Vec<u32> = warnings
.iter()
.filter(|w| w.kind == "image")
.map(|w| w.count)
.collect();
assert_eq!(counted, vec![2]);
}
#[test]
fn a_formula_nothing_can_typeset_does_not_cost_the_export() {
let (_dir, roots) = root();
// Unbalanced braces, which is what somebody halfway through typing a formula has. mitex cannot
// parse it and says so by stopping the compile, and the whole page of notes would go with it.
let source = format!("{PREAMBLE}#mi(\"\\\\frac{{\")\n");
let (bytes, warnings) = compile(source, &[], &roots).expect("the export still happens");
assert_eq!(&bytes[..4], b"%PDF");
assert_eq!(
kinds(&warnings, "math"),
vec!["a formula could not be typeset, so every formula is shown as the source it was written in"]
);
}
#[test]
fn an_ordinary_document_says_nothing_at_all() {
let (_dir, roots) = root();
// Every construct that has ever made this module warn about its own furniture rather than about
// the document: the bundled variable fonts, the font families the preamble names, and mitex's
// own deprecations. A user who wrote none of that should see no toast.
let source = format!(
"{PREAMBLE}
= Heading
A paragraph with `inline code` and #mi(\"a^2 + b^2\") in it.
```rust
fn main() {{}}
```
"
);
let (bytes, warnings) = compile(source, &[], &roots).expect("the fixture compiles");
assert_eq!(&bytes[..4], b"%PDF");
assert!(warnings.is_empty(), "nothing to say: {warnings:?}");
}
#[test]
fn a_broken_link_is_worked_around_in_every_format_the_editor_writes() {
let (dir, roots) = root();
// Typst decides how to decode an image from the extension and not from the bytes, so the
// stand-in for a file that could not be read has to be a real image of the format the link
// claimed. A png in place of a jpg is a decode error, which is the failure being avoided.
for extension in ["png", "jpg", "jpeg", "gif", "webp", "svg", "heic"] {
let missing = dir.path().join(format!("gone.{extension}"));
let source = format!("{PREAMBLE}#image(\"{}\")\n", missing.display());
let images = vec![ImageInput {
path: missing.to_string_lossy().into_owned(),
data: None,
}];
let (bytes, warnings) = compile(source, &images, &roots)
.unwrap_or_else(|e| panic!("a missing .{extension} still exports: {e}"));
assert_eq!(&bytes[..4], b"%PDF");
assert_eq!(kinds(&warnings, "image").len(), 1);
}
}
/// mitex's `\hspace`, `\vspace` and `\raisebox` handlers used to hand the text between the braces
/// to Typst's `eval`, which ran it as code. The text is whatever the author typed, so a markdown
/// file was a program, and the compiler runs in this process rather than beside it: the payload
/// vendor/mitex/PATCHES.md names took nine gigabytes and the app with it.
///
/// That payload is deliberately not the fixture here. A test that brings down the machine when it
/// fails is not a test anybody can run, and the property underneath it is smaller and exact: a
/// length literal is evaluated and nothing else is. `2cm*2` is the whole difference, because it is
/// arithmetic rather than a literal, and under `eval` it was four centimetres.
///
/// Proved by what lands on the page rather than by reading the guard, which needs one control: the
/// same source twice is the same bytes, so two documents differing only in a spacing command and
/// differing in bytes is that spacing command doing something.
#[test]
fn a_spacing_command_takes_a_length_and_never_an_expression() {
let (_dir, roots) = root();
let page = |length: &str| {
let source = format!("{PREAMBLE}x#mi(\"a\\\\hspace{{{length}}}b\")x\n");
compile(source, &[], &roots).expect("a spacing command typesets").0
};
assert_eq!(page("0pt"), page("0pt"), "the compiler is deterministic");
assert_ne!(page("0pt"), page("4cm"), "4cm moved nothing on the page");
assert_eq!(page("0pt"), page("2cm*2"), "an expression reached eval");
}
/// Every weight src/export/typst.ts asks for has to be a face of its own, or the hierarchy the
/// author sees on screen is not in the PDF.
///
/// This is what the static instances in src-tauri/fonts are for. Typst does not lay out a variable
/// axis: it takes the default instance, and every weight comes out at 400. There is no way to read a
/// weight back out of a PDF here, so the assertion is that the same word at two weights is two
/// different documents, which stops being true the moment two weights resolve to one face.
#[test]
fn every_weight_the_converter_asks_for_has_a_face_of_its_own() {
let (_dir, roots) = root();
let page = |markup: &str| {
let source = format!("{PREAMBLE}{markup}\n");
compile(source, &[], &roots).expect("a weight typesets").0
};
let cuts = [
"#text(weight: 400)[Margin]",
"#text(weight: 600)[Margin]",
"#text(weight: 700)[Margin]",
"#text(weight: 400, style: \"italic\")[Margin]",
"#text(weight: 700, style: \"italic\")[Margin]",
"#text(font: \"Hanken Grotesk\", weight: 400)[Margin]",
"#text(font: \"Hanken Grotesk\", weight: 700)[Margin]",
];
for (i, one) in cuts.iter().enumerate() {
for two in &cuts[i + 1..] {
assert_ne!(page(one), page(two), "{one} and {two} are the same face");
}
}
}
+176
View File
@@ -0,0 +1,176 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+20
View File
@@ -0,0 +1,20 @@
# Local changes to mitex 0.2.5
This copy is not stock. mitex is vendored rather than fetched through Typst's package system so
that an export needs no network, which also means an update is a manual copy and a patch applied
here is a patch that can be silently lost. Anyone replacing this directory has to re-apply what is
listed below, or re-check that it is no longer needed.
Upstream is https://github.com/mitex-rs/mitex, Apache-2.0, and LICENSE beside this file is theirs.
## specs/latex/standard.typ: `\hspace`, `\vspace` and `\raisebox` no longer evaluate their argument
Those three handlers passed the text between the braces to `eval`, which runs it as Typst code.
The text is whatever the author typed, so a markdown file containing
`$\hspace{range(999999999).len()*1pt}$` is a program rather than a formula. Typst is hermetic, so
this is not a way to read a file or reach the network, but the compiler runs inside the editor's
own process: measured, that document took nine gigabytes resident before the process died, and it
would have taken any unsaved buffer with it.
The three handlers now go through `mitex-safe-length`, which evaluates the argument only when it is
a plain length literal and answers `0pt` otherwise. `src-tauri/tests/pdf.rs` pins both halves.
+1
View File
@@ -0,0 +1 @@
#import "mitex.typ": mitex-wasm, mitex-convert, mitex-scope, mitex, mitext, mimath, mi
+45
View File
@@ -0,0 +1,45 @@
#import "specs/mod.typ": mitex-scope
#let mitex-wasm = plugin("./mitex.wasm")
#let get-elem-text(it) = {
{
if type(it) == str {
it
} else if type(it) == content and it.has("text") {
it.text
} else {
panic("Unsupported type: " + str(type(it)))
}
}
}
#let mitex-convert(it, mode: "math", spec: bytes(())) = {
if mode == "math" {
str(mitex-wasm.convert_math(bytes(get-elem-text(it)), spec))
} else {
str(mitex-wasm.convert_text(bytes(get-elem-text(it)), spec))
}
}
// Math Mode
#let mimath(it, block: true, ..args) = {
let res = mitex-convert(mode: "math", it)
let eval-res = eval("$" + res + "$", scope: mitex-scope)
math.equation(block: block, eval-res, ..args)
}
// Text Mode
#let mitext(it) = {
let res = mitex-convert(mode: "text", it)
eval(res, mode: "markup", scope: mitex-scope)
}
#let mitex(it, mode: "math", ..args) = {
if mode == "math" {
mimath(it, ..args)
} else {
mitext(it, ..args)
}
}
#let mi = mimath.with(block: false)
Binary file not shown.
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
#import "prelude.typ": *
#import "latex/standard.typ": package as latex-std
// 1. import all the packages and form a mitex-scope for mitex to use
#let packages = (latex-std,)
#let mitex-scope = packages.map(pkg => pkg.scope).sum()
// 2. export all packages with specs by metadata and <mitex-packages> label,
// mitex-cli can fetch them by
// `typst query --root . ./packages/mitex/specs/mod.typ "<mitex-packages>"`
#metadata(packages) <mitex-packages>
+230
View File
@@ -0,0 +1,230 @@
/// Define a normal symbol, as no-argument commands like \alpha
///
/// Arguments:
/// - s (str): Alias command for typst handler.
/// For example, alias `\prod` to typst's `product`.
/// - sym (content): The specific content, as the value of alias in mitex-scope.
/// For example, there is no direct alias for \negthinspace symbol in typst,
/// but we can add `h(-(3/18) * 1em)` ourselves
///
/// Return: A spec item and a scope item (none for no scope item)
#let define-sym(s, sym: none) = {
(
(kind: "alias-sym", alias: s),
if sym != none {
(alias: s, handle: sym)
} else {
none
},
)
}
/// Define a greedy command, like \displaystyle
///
/// Arguments:
/// - s (str): Alias command for typst handler.
/// For example, alias `\displaystyle` to typst's `mitexdisplay`, as the key in mitex-scope.
/// - handle (function): The handler function, as the value of alias in mitex-scope.
/// It receives a content argument as all greedy matches to the content
/// For example, we define `mitexdisplay` to `math.display`
///
/// Return: A spec item and a scope item (none for no scope item)
#let define-greedy-cmd(s, handle: none) = {
(
(kind: "greedy-cmd", alias: s),
if handle != none {
(alias: s, handle: handle)
} else {
none
},
)
}
/// Define an infix command, like \over
///
/// Arguments:
/// - s (str): Alias command for typst handler.
/// For example, alias `\over` to typst's `frac`, as the key in mitex-scope.
/// - handle (function): The handler function, as the value of alias in mitex-scope.
/// It receives two content arguments, as (prev, after) arguments.
/// For example, we define `\over` to `frac: (num, den) => $(num)/(den)$`
///
/// Return: A spec item and a scope item (none for no scope item)
#let define-infix-cmd(s, handle: none) = {
(
(kind: "infix-cmd", alias: s),
if handle != none {
(alias: s, handle: handle)
} else {
none
},
)
}
/// Define a glob (Global Wildcard) match command with a specified pattern for matching args
/// Kind of item to match:
/// - Bracket/b: []
/// - Parenthesis/p: ()
/// - Term/t: any rest of terms, typically {} or single char
///
/// Arguments:
/// - pat (pattern): The pattern for glob-cmd
/// For example, `{,b}t` for `\sqrt` to support `\sqrt{2}` and `\sqrt[3]{2}`
/// - s (str): Alias command for typst handler.
/// For example, alias `\sqrt` to typst's `mitexsqrt`, as the key in mitex-scope.
/// - handle (function): The handler function, as the value of alias in mitex-scope.
/// It receives variable length arguments, for example `(2,)` or `([3], 2)` for sqrt.
/// Therefore you need to use `(.. arg) = > {..}` to receive them.
///
/// Return: A spec item and a scope item (none for no scope item)
#let define-glob-cmd(pat, s, handle: none) = {
(
(kind: "glob-cmd", pattern: pat, alias: s),
if handle != none {
(alias: s, handle: handle)
} else {
none
},
)
}
/// Define a command with a fixed number of arguments, like \hat{x} and \frac{1}{2}
///
/// Arguments:
/// - num (int): The number of arguments for the command.
/// - alias (str): Alias command for typst handler.
/// For example, alias `\frac` to typst's `frac`, as the key in mitex-scope.
/// - handle (function): The handler function, as the value of alias in mitex-scope.
/// It receives fixed number of arguments, for example `frac(1, 2)` for `\frac{1}{2}`.
///
/// Return: A spec item and a scope item (none for no scope item)
#let define-cmd(num, alias: none, handle: none) = {
(
(
kind: "cmd",
args: ("kind": "right", "pattern": (kind: "fixed-len", len: num)),
alias: alias,
),
if handle != none {
(alias: alias, handle: handle)
} else {
none
},
)
}
/// Define an environment with a fixed number of arguments, like \begin{alignedat}{2}
///
/// Arguments:
/// - num (int): The number of arguments as environment options for the environment.
/// - alias (str): Alias command for typst handler.
/// For example, alias `\begin{alignedat}{2}` to typst's `alignedat`,
/// and alias `\begin{aligned}` to typst's `aligned`, as the key in mitex-scope.
/// - kind (str): environment kind, it could be "is-math", "is-cases", "is-matrix",
/// "is-itemize", "is-enumerate"
/// - handle (function): The handler function, as the value of alias in mitex-scope.
/// It receives fixed number of named arguments as environment options,
/// for example `alignedat(arg0: ..)` or `alignedat(arg0: .., arg1: ..)`.
/// And it receives variable length arguments as environment body,
/// Therefore you need to use `(.. arg) = > {..}` to receive them.
///
/// Return: A spec item and a scope item (none for no scope item)
#let define-env(num, kind: "none", alias: none, handle: none) = {
(
(
kind: "env",
args: if num != none {
(kind: "fixed-len", len: num)
} else {
(kind: "none")
},
ctx_feature: (kind: kind),
alias: alias,
),
if handle != none {
(alias: alias, handle: handle)
} else {
none
},
)
}
#let define-glob-env(pat, kind: "none", alias: none, handle: none) = {
(
(
kind: "glob-env",
pattern: pat,
ctx_feature: (kind: kind),
alias: alias,
),
if handle != none {
(alias: alias, handle: handle)
} else {
none
},
)
}
/// Define a symbol without alias and without handler function, like \alpha => alpha
///
/// Return: A spec item and no scope item (none for no scope item)
#let sym = ((kind: "sym"), none)
/// Define a symbol without alias and with handler function,
/// like \negthinspace => h(-(3/18) * 1em)
///
/// Arguments:
/// - handle (function): The handler function, as the value of alias in mitex-scope.
/// For example, define `negthinspace` to handle `h(-(3/18) * 1em)` in mitex-scope
///
/// Return: A symbol spec and a scope item
#let of-sym(handle) = ((kind: "sym"), (handle: handle))
/// Define a left1-op command without handler, like `\limits` for `\sum\limits`
///
/// Arguments:
/// - alias (str): Alias command for typst handler.
/// For example, alias `\limits` to typst's `limits`
/// and alias `\nolimits` to typst's `scripts`
///
/// Return: A cmd spec and no scope item (none for no scope item)
#let left1-op(alias) = ((kind: "cmd", args: (kind: "left1"), alias: alias), none)
/// Define a cmd1 command like \hat{x} => hat(x)
///
/// Return: A cmd1 spec and a scope item (none for no scope item)
#let cmd1 = ((kind: "cmd1"), none)
/// Define a cmd2 command like \binom{1}{2} => binom(1, 2)
///
/// Return: A cmd2 spec and a scope item (none for no scope item)
#let cmd2 = ((kind: "cmd2"), none)
/// Define a matrix environment without handler
///
/// Return: A matrix-env spec and a scope item (none for no scope item)
#let matrix-env = ((kind: "matrix-env"), none)
/// Receives a list of definitions composed of the above functions, and processes them to return a dictionary containing spec and scope.
#let process-spec(definitions) = {
let spec = (:)
let scope = (:)
for (key, value) in definitions.pairs() {
let spec-item = value.at(0)
let scope-item = value.at(1)
spec.insert(key, spec-item)
if scope-item != none {
if "alias" in scope-item and type(scope-item.alias) == str {
let key = if scope-item.alias.starts-with("#") {
scope-item.alias.slice(1)
} else {
scope-item.alias
}
scope.insert(key, scope-item.handle)
} else {
scope.insert(key, scope-item.handle)
}
}
}
(spec: spec, scope: scope)
}