// 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 { STANDARD.decode(PNG).unwrap() } fn root() -> (TempDir, Vec) { 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##""##, )), }, ]; 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 = 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"); } } }