/*! This is Typst's test runner. Tests are Typst files composed of a header part followed by subtests. The header may contain: - a small description `// tests that features X works well` - metadata (see [metadata::TestConfiguration]) The subtests may use extra testing functions defined in [library], most importantly, `test(x, y)` which will fail the test `if x != y`. */ #![allow(clippy::comparison_chain)] mod metadata; use self::metadata::*; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::ffi::OsStr; use std::fmt::Write as _; use std::io::{self, IsTerminal, Write as _}; use std::ops::Range; use std::path::{Path, PathBuf, MAIN_SEPARATOR_STR}; use std::sync::{OnceLock, RwLock}; use std::{env, fs}; use clap::Parser; use comemo::{Prehashed, Track}; use oxipng::{InFile, Options, OutFile}; use rayon::iter::{ParallelBridge, ParallelIterator}; use tiny_skia as sk; use typst::diag::{bail, FileError, FileResult, Severity, SourceDiagnostic, StrResult}; use typst::eval::Tracer; use typst::foundations::{func, Bytes, Datetime, NoneValue, Repr, Smart, Value}; use typst::introspection::Meta; use typst::layout::{Abs, Frame, FrameItem, Margin, Page, PageElem, Transform}; use typst::model::Document; use typst::syntax::{FileId, Source, SyntaxNode, VirtualPath}; use typst::text::{Font, FontBook, TextElem, TextSize}; use typst::visualize::Color; use typst::{Library, World, WorldExt}; use walkdir::WalkDir; // These directories are all relative to the tests/ directory. const TYP_DIR: &str = "typ"; const REF_DIR: &str = "ref"; const PNG_DIR: &str = "png"; const PDF_DIR: &str = "pdf"; const SVG_DIR: &str = "svg"; /// Arguments that modify test behaviour. /// /// Specify them like this when developing: /// `cargo test --workspace --test tests -- --help` #[derive(Debug, Clone, Parser)] #[clap(name = "typst-test", author)] struct Args { /// All the tests that contains a filter string will be run (unless /// `--exact` is specified, which is even stricter). filter: Vec, /// Runs only the specified subtest. #[arg(short, long)] #[arg(allow_hyphen_values = true)] subtest: Option, /// Runs only the test with the exact name specified in your command. /// /// Example: /// `cargo test --workspace --test tests -- compiler/bytes.typ --exact` #[arg(long)] exact: bool, /// Updates the reference images in `tests/ref`. #[arg(long, default_value_t = env::var_os("UPDATE_EXPECT").is_some())] update: bool, /// Exports the tests as PDF into `tests/pdf`. #[arg(long)] pdf: bool, /// Configuration of what to print. #[command(flatten)] print: PrintConfig, /// Running `cargo test --workspace -- --nocapture` for the unit tests would /// fail the test runner without argument. // TODO: would it really still happen? #[arg(long)] nocapture: bool, /// Prevents the terminal from being cleared of test names and includes /// non-essential test messages. #[arg(short, long)] verbose: bool, } /// Which things to print out for debugging. #[derive(Default, Debug, Copy, Clone, Eq, PartialEq, Parser)] struct PrintConfig { /// Print the syntax tree. #[arg(long)] syntax: bool, /// Print the content model. #[arg(long)] model: bool, /// Print the layouted frames. #[arg(long)] frames: bool, } impl Args { fn matches(&self, canonicalized_path: &Path) -> bool { let path = canonicalized_path.to_string_lossy(); if !self.exact { return self.filter.is_empty() || self.filter.iter().any(|v| path.contains(v)); } self.filter.iter().any(|v| match path.strip_suffix(v) { None => false, Some(residual) => { residual.is_empty() || residual.ends_with(MAIN_SEPARATOR_STR) } }) } } /// Tests all test files and prints a summary. fn main() { let args = Args::parse(); // Create loader and context. let world = TestWorld::new(args.print); println!("Running tests..."); let results = WalkDir::new(TYP_DIR) .sort_by_file_name() .into_iter() .par_bridge() .filter_map(|entry| { let entry = entry.unwrap(); if entry.depth() == 0 { return None; } if entry.path().starts_with("typ/benches") { return None; } let src_path = entry.into_path(); // Relative to TYP_DIR. if src_path.extension() != Some(OsStr::new("typ")) { return None; } if args.matches(&src_path.canonicalize().unwrap()) { Some(src_path) } else { None } }) .map_with(world, |world, src_path| { let path = src_path.strip_prefix(TYP_DIR).unwrap(); let png_path = Path::new(PNG_DIR).join(path).with_extension("png"); let ref_path = Path::new(REF_DIR).join(path).with_extension("png"); let svg_path = Path::new(SVG_DIR).join(path).with_extension("svg"); let pdf_path = args.pdf.then(|| Path::new(PDF_DIR).join(path).with_extension("pdf")); test( world, &src_path, &png_path, &ref_path, pdf_path.as_deref(), &svg_path, &args, ) as usize }) .collect::>(); let len = results.len(); let ok = results.iter().sum::(); if len > 0 { println!("{ok} / {len} test{} passed.", if len > 1 { "s" } else { "" }); } else { println!("No test ran."); } if ok != len { println!( "Set the UPDATE_EXPECT environment variable or pass the \ --update flag to update the reference image(s)." ); } if ok < len { std::process::exit(1); } } fn library() -> Library { #[func] fn test(lhs: Value, rhs: Value) -> StrResult { if lhs != rhs { bail!("Assertion failed: {} != {}", lhs.repr(), rhs.repr()); } Ok(NoneValue) } #[func] fn test_repr(lhs: Value, rhs: Value) -> StrResult { if lhs.repr() != rhs.repr() { bail!("Assertion failed: {} != {}", lhs.repr(), rhs.repr()); } Ok(NoneValue) } #[func] fn print(#[variadic] values: Vec) -> NoneValue { let mut stdout = io::stdout().lock(); write!(stdout, "> ").unwrap(); for (i, value) in values.into_iter().enumerate() { if i > 0 { write!(stdout, ", ").unwrap(); } write!(stdout, "{value:?}").unwrap(); } writeln!(stdout).unwrap(); NoneValue } // Set page width to 120pt with 10pt margins, so that the inner page is // exactly 100pt wide. Page height is unbounded and font size is 10pt so // that it multiplies to nice round numbers. let mut lib = Library::default(); lib.styles .set(PageElem::set_width(Smart::Custom(Abs::pt(120.0).into()))); lib.styles.set(PageElem::set_height(Smart::Auto)); lib.styles.set(PageElem::set_margin(Margin::splat(Some(Smart::Custom( Abs::pt(10.0).into(), ))))); lib.styles.set(TextElem::set_size(TextSize(Abs::pt(10.0).into()))); // Hook up helpers into the global scope. lib.global.scope_mut().define_func::(); lib.global.scope_mut().define_func::(); lib.global.scope_mut().define_func::(); lib.global .scope_mut() .define("conifer", Color::from_u8(0x9f, 0xEB, 0x52, 0xFF)); lib.global .scope_mut() .define("forest", Color::from_u8(0x43, 0xA1, 0x27, 0xFF)); lib } /// A world that provides access to the tests environment. struct TestWorld { print: PrintConfig, main: FileId, library: Prehashed, book: Prehashed, fonts: Vec, slots: RwLock>, } #[derive(Clone)] struct FileSlot { source: OnceLock>, buffer: OnceLock>, } impl TestWorld { fn new(print: PrintConfig) -> Self { let fonts: Vec<_> = typst_assets::fonts() .chain(typst_dev_assets::fonts()) .flat_map(|data| Font::iter(Bytes::from_static(data))) .collect(); Self { print, main: FileId::new(None, VirtualPath::new("main.typ")), library: Prehashed::new(library()), book: Prehashed::new(FontBook::from_fonts(&fonts)), fonts, slots: RwLock::new(HashMap::new()), } } } impl World for TestWorld { fn library(&self) -> &Prehashed { &self.library } fn book(&self) -> &Prehashed { &self.book } fn main(&self) -> Source { self.source(self.main).unwrap() } fn source(&self, id: FileId) -> FileResult { self.slot(id, |slot| { slot.source .get_or_init(|| { let buf = read(&system_path(id)?)?; let text = String::from_utf8(buf.into_owned())?; Ok(Source::new(id, text)) }) .clone() }) } fn file(&self, id: FileId) -> FileResult { self.slot(id, |slot| { slot.buffer .get_or_init(|| { read(&system_path(id)?).map(|cow| match cow { Cow::Owned(buf) => buf.into(), Cow::Borrowed(buf) => Bytes::from_static(buf), }) }) .clone() }) } fn font(&self, id: usize) -> Option { Some(self.fonts[id].clone()) } fn today(&self, _: Option) -> Option { Some(Datetime::from_ymd(1970, 1, 1).unwrap()) } } impl TestWorld { fn set(&mut self, path: &Path, text: String) -> Source { self.main = FileId::new(None, VirtualPath::new(path)); let source = Source::new(self.main, text); self.slot(self.main, |slot| { slot.source = OnceLock::from(Ok(source.clone())); source }) } fn slot(&self, id: FileId, f: F) -> T where F: FnOnce(&mut FileSlot) -> T, { f(self.slots.write().unwrap().entry(id).or_insert_with(|| FileSlot { source: OnceLock::new(), buffer: OnceLock::new(), })) } } impl Clone for TestWorld { fn clone(&self) -> Self { Self { print: self.print, main: self.main, library: self.library.clone(), book: self.book.clone(), fonts: self.fonts.clone(), slots: RwLock::new(self.slots.read().unwrap().clone()), } } } /// The file system path for a file ID. fn system_path(id: FileId) -> FileResult { let root: PathBuf = match id.package() { Some(spec) => format!("packages/{}-{}", spec.name, spec.version).into(), None => PathBuf::new(), }; id.vpath().resolve(&root).ok_or(FileError::AccessDenied) } /// Read a file. fn read(path: &Path) -> FileResult> { // Basically symlinks `assets/files` to `tests/files` so that the assets // are within the test project root. let resolved = path.to_path_buf(); if let Ok(suffix) = path.strip_prefix("assets/") { return typst_dev_assets::get(&suffix.to_string_lossy()) .map(Cow::Borrowed) .ok_or_else(|| FileError::NotFound(path.into())); } let f = |e| FileError::from_io(e, path); if fs::metadata(&resolved).map_err(f)?.is_dir() { Err(FileError::IsDirectory) } else { fs::read(&resolved).map(Cow::Owned).map_err(f) } } /// Tests a test file and prints the result. /// /// Also tests that the header of each test is written correctly. /// See [parse_part_metadata] for more details. fn test( world: &mut TestWorld, src_path: &Path, png_path: &Path, ref_path: &Path, pdf_path: Option<&Path>, svg_path: &Path, args: &Args, ) -> bool { struct PanicGuard<'a>(&'a Path); impl Drop for PanicGuard<'_> { fn drop(&mut self) { if std::thread::panicking() { println!("Panicked in {}", self.0.display()); } } } let name = src_path.strip_prefix(TYP_DIR).unwrap_or(src_path); let text = fs::read_to_string(src_path).unwrap(); let _guard = PanicGuard(name); let mut output = String::new(); let mut ok = true; let mut updated = false; let mut pages = vec![]; let mut line = 0; let mut header_configuration = None; let mut compare_ever = false; let mut rng = LinearShift::new(); let parts: Vec<_> = text .split("\n---") .map(|s| s.strip_suffix('\r').unwrap_or(s)) .collect(); for (i, &part) in parts.iter().enumerate() { if let Some(x) = args.subtest { let x = usize::try_from( x.rem_euclid(isize::try_from(parts.len()).unwrap_or_default()), ) .unwrap(); if x != i { writeln!(output, " Skipped subtest {i}.").unwrap(); continue; } } let is_header = i == 0 && parts.len() > 1 && part .lines() .all(|s| s.starts_with("//") || s.chars().all(|c| c.is_whitespace())); if is_header { let source = Source::detached(part.to_string()); let metadata = parse_part_metadata(&source, true); match metadata { Ok(metadata) => { header_configuration = Some(metadata.config); } Err(invalid_data) => { ok = false; writeln!( output, " Test {}: invalid metadata in header, failing the test:", name.display() ) .unwrap(); InvalidMetadata::write( invalid_data, &mut output, &mut |annotation, output| { print_annotation(output, &source, line, annotation) }, ); } } } else { let (part_ok, compare_here, part_frames) = test_part( &mut output, world, src_path, part.into(), line, i, header_configuration.as_ref().unwrap_or(&Default::default()), &mut rng, args.verbose, ); ok &= part_ok; compare_ever |= compare_here; pages.extend(part_frames); } line += part.lines().count() + 1; } let document = Document { pages, ..Default::default() }; if compare_ever { if let Some(pdf_path) = pdf_path { let pdf_data = typst_pdf::pdf( &document, Smart::Custom(&format!("typst-test: {}", name.display())), world.today(Some(0)), ); fs::create_dir_all(pdf_path.parent().unwrap()).unwrap(); fs::write(pdf_path, pdf_data).unwrap(); } if world.print.frames { for frame in &document.pages { writeln!(output, "{frame:#?}\n").unwrap(); } } let canvas = render(&document); fs::create_dir_all(png_path.parent().unwrap()).unwrap(); canvas.save_png(png_path).unwrap(); let svg = typst_svg::svg_merged(&document, Abs::pt(5.0)); fs::create_dir_all(svg_path.parent().unwrap()).unwrap(); std::fs::write(svg_path, svg.as_bytes()).unwrap(); if let Ok(ref_pixmap) = sk::Pixmap::load_png(ref_path) { if canvas.width() != ref_pixmap.width() || canvas.height() != ref_pixmap.height() || canvas .data() .iter() .zip(ref_pixmap.data()) .any(|(&a, &b)| a.abs_diff(b) > 2) { if args.update { update_image(png_path, ref_path); updated = true; } else { writeln!(output, " Does not match reference image.").unwrap(); ok = false; } } } else if !document.pages.is_empty() { if args.update { update_image(png_path, ref_path); updated = true; } else { writeln!(output, " Failed to open reference image.").unwrap(); ok = false; } } } { let mut stdout = io::stdout().lock(); stdout.write_all(name.to_string_lossy().as_bytes()).unwrap(); if ok { writeln!(stdout, " ✔").unwrap(); // Don't clear the line when in verbose mode or when the reference image // was updated, to show in the output which test had its image updated. if !updated && !args.verbose && stdout.is_terminal() { // ANSI escape codes: cursor moves up and clears the line. write!(stdout, "\x1b[1A\x1b[2K").unwrap(); } } else { writeln!(stdout, " ❌").unwrap(); } if updated { writeln!(stdout, " Updated reference image.").unwrap(); } if !output.is_empty() { stdout.write_all(output.as_bytes()).unwrap(); } } ok } fn update_image(png_path: &Path, ref_path: &Path) { oxipng::optimize( &InFile::Path(png_path.to_owned()), &OutFile::from_path(ref_path.to_owned()), &Options::max_compression(), ) .unwrap(); } #[allow(clippy::too_many_arguments)] fn test_part( output: &mut String, world: &mut TestWorld, src_path: &Path, text: String, line: usize, i: usize, header_configuration: &TestConfig, rng: &mut LinearShift, verbose: bool, ) -> (bool, bool, Vec) { let source = world.set(src_path, text); if world.print.syntax { writeln!(output, "Syntax Tree:\n{:#?}\n", source.root()).unwrap(); } if world.print.model { print_model(world, &source, output); } let mut tracer = Tracer::new(); let (mut frames, diagnostics) = match typst::compile(world, &mut tracer) { Ok(document) => (document.pages, tracer.warnings()), Err(errors) => { let mut warnings = tracer.warnings(); warnings.extend(errors); (vec![], warnings) } }; let metadata = parse_part_metadata(&source, false); match metadata { Ok(metadata) => { let mut ok = true; let compare_ref = metadata .config .compare_ref .unwrap_or(header_configuration.compare_ref.unwrap_or(true)); let validate_hints = metadata .config .validate_hints .unwrap_or(header_configuration.validate_hints.unwrap_or(true)); let validate_autocomplete = metadata .config .validate_autocomplete .unwrap_or(header_configuration.validate_autocomplete.unwrap_or(false)); if verbose { writeln!(output, "Subtest {i} runs with compare_ref={compare_ref}; validate_hints={validate_hints}; validate_autocomplete={validate_autocomplete};").unwrap(); } ok &= test_spans(output, source.root()); ok &= test_reparse(output, source.text(), i, rng); // Don't retain frames if we don't want to compare with reference images. if !compare_ref { frames.clear(); } // we never check autocomplete and error at the same time let diagnostic_annotations = metadata .annotations .iter() .filter(|a| { !matches!( a.kind, AnnotationKind::AutocompleteContains | AnnotationKind::AutocompleteExcludes ) }) .cloned() .collect::>(); if validate_autocomplete { // warns and ignores diagnostics if !diagnostic_annotations.is_empty() { writeln!( output, " Subtest {i} contains diagnostics but is in autocomplete mode." ) .unwrap(); for annotation in diagnostic_annotations { write!(output, " Ignored | ").unwrap(); print_annotation(output, &source, line, &annotation); } } test_autocomplete( output, world, &source, line, i, &mut ok, metadata.annotations.iter(), ); } else { test_diagnostics( output, world, &source, line, i, &mut ok, validate_hints, diagnostics.iter(), &diagnostic_annotations, ); } (ok, compare_ref, frames) } Err(invalid_data) => { writeln!(output, " Subtest {i} has invalid metadata, failing the test:") .unwrap(); InvalidMetadata::write( invalid_data, output, &mut |annotation: &Annotation, output: &mut String| { print_annotation(output, &source, line, annotation) }, ); (false, false, frames) } } } #[allow(clippy::too_many_arguments)] fn test_autocomplete<'a>( output: &mut String, world: &mut TestWorld, source: &Source, line: usize, i: usize, ok: &mut bool, annotations: impl Iterator, ) { for annotation in annotations.filter(|a| { matches!( a.kind, AnnotationKind::AutocompleteContains | AnnotationKind::AutocompleteExcludes ) }) { // Ok cause we checked in parsing that range was Some for this annotation let cursor = annotation.range.as_ref().unwrap().start; // todo, use document if is_some to test labels autocomplete let completions = typst_ide::autocomplete(world, None, source, cursor, true) .map(|(_, c)| c) .unwrap_or_default() .into_iter() .map(|c| c.label.to_string()) .collect::>(); let completions = completions.iter().map(|s| s.as_str()).collect::>(); let must_contain_or_exclude = parse_string_list(&annotation.text); let missing = must_contain_or_exclude.difference(&completions).collect::>(); if !missing.is_empty() && matches!(annotation.kind, AnnotationKind::AutocompleteContains) { writeln!(output, " Subtest {i} does not match expected completions.") .unwrap(); write!(output, " for annotation | ").unwrap(); print_annotation(output, source, line, annotation); write!(output, " Not contained // ").unwrap(); for item in missing { write!(output, "{item:?}, ").unwrap() } writeln!(output).unwrap(); *ok = false; } let undesired = must_contain_or_exclude.intersection(&completions).collect::>(); if !undesired.is_empty() && matches!(annotation.kind, AnnotationKind::AutocompleteExcludes) { writeln!(output, " Subtest {i} does not match expected completions.") .unwrap(); write!(output, " for annotation | ").unwrap(); print_annotation(output, source, line, annotation); write!(output, " Not excluded // ").unwrap(); for item in undesired { write!(output, "{item:?}, ").unwrap() } writeln!(output).unwrap(); *ok = false; } } } #[allow(clippy::too_many_arguments)] fn test_diagnostics<'a>( output: &mut String, world: &mut TestWorld, source: &Source, line: usize, i: usize, ok: &mut bool, validate_hints: bool, diagnostics: impl Iterator, diagnostic_annotations: &HashSet, ) { // Map diagnostics to range and message format, discard traces and errors from // other files, collect hints. // // This has one caveat: due to the format of the expected hints, we can not // verify if a hint belongs to a diagnostic or not. That should be irrelevant // however, as the line of the hint is still verified. let mut actual_diagnostics = HashSet::new(); for diagnostic in diagnostics { // Ignore diagnostics from other files. if diagnostic.span.id().is_some_and(|id| id != source.id()) { continue; } let annotation = Annotation { kind: match diagnostic.severity { Severity::Error => AnnotationKind::Error, Severity::Warning => AnnotationKind::Warning, }, range: world.range(diagnostic.span), text: diagnostic.message.replace("\\", "/"), }; if validate_hints { for hint in &diagnostic.hints { actual_diagnostics.insert(Annotation { kind: AnnotationKind::Hint, text: hint.clone(), range: annotation.range.clone(), }); } } actual_diagnostics.insert(annotation); } // Basically symmetric_difference, but we need to know where an item is coming from. let mut unexpected_outputs = actual_diagnostics .difference(diagnostic_annotations) .collect::>(); let mut missing_outputs = diagnostic_annotations .difference(&actual_diagnostics) .collect::>(); unexpected_outputs.sort_by_key(|&v| v.range.as_ref().map(|r| r.start)); missing_outputs.sort_by_key(|&v| v.range.as_ref().map(|r| r.start)); // This prints all unexpected emits first, then all missing emits. // Is this reasonable or subject to change? if !(unexpected_outputs.is_empty() && missing_outputs.is_empty()) { writeln!(output, " Subtest {i} does not match expected errors.").unwrap(); *ok = false; for unexpected in unexpected_outputs { write!(output, " Not annotated // ").unwrap(); print_annotation(output, source, line, unexpected) } for missing in missing_outputs { write!(output, " Not emitted // ").unwrap(); print_annotation(output, source, line, missing) } } } fn print_model(world: &mut TestWorld, source: &Source, output: &mut String) { let world = (world as &dyn World).track(); let route = typst::engine::Route::default(); let mut tracer = typst::eval::Tracer::new(); let module = typst::eval::eval(world, route.track(), tracer.track_mut(), source).unwrap(); writeln!(output, "Model:\n{:#?}\n", module.content()).unwrap(); } fn print_annotation( output: &mut String, source: &Source, line: usize, annotation: &Annotation, ) { let Annotation { range, text, kind } = annotation; write!(output, "{kind}: ").unwrap(); if let Some(range) = range { let start_line = 1 + line + source.byte_to_line(range.start).unwrap(); let start_col = 1 + source.byte_to_column(range.start).unwrap(); let end_line = 1 + line + source.byte_to_line(range.end).unwrap(); let end_col = 1 + source.byte_to_column(range.end).unwrap(); write!(output, "{start_line}:{start_col}-{end_line}:{end_col} ").unwrap(); } writeln!(output, "{text}").unwrap(); } /// Pseudorandomly edit the source file and test whether a reparse produces the /// same result as a clean parse. /// /// The method will first inject 10 strings once every 400 source characters /// and then select 5 leaf node boundaries to inject an additional, randomly /// chosen string from the injection list. fn test_reparse( output: &mut String, text: &str, i: usize, rng: &mut LinearShift, ) -> bool { let supplements = [ "[", "]", "{", "}", "(", ")", "#rect()", "a word", ", a: 1", "10.0", ":", "if i == 0 {true}", "for", "* hello *", "//", "/*", "\\u{12e4}", "```typst", " ", "trees", "\\", "$ a $", "2.", "-", "5", ]; let mut ok = true; let mut apply = |replace: Range, with| { let mut incr_source = Source::detached(text); if incr_source.root().len() != text.len() { println!( " Subtest {i} tree length {} does not match string length {} ❌", incr_source.root().len(), text.len(), ); return false; } incr_source.edit(replace.clone(), with); let edited_src = incr_source.text(); let ref_source = Source::detached(edited_src); let ref_root = ref_source.root(); let incr_root = incr_source.root(); // Ensures that the span numbering invariants hold. let spans_ok = test_spans(output, ref_root) && test_spans(output, incr_root); // Ensure that the reference and incremental trees are the same. let tree_ok = ref_root.spanless_eq(incr_root); if !tree_ok { writeln!( output, " Subtest {i} reparse differs from clean parse when inserting '{with}' at {}-{} ❌\n", replace.start, replace.end, ).unwrap(); writeln!(output, " Expected reference tree:\n{ref_root:#?}\n").unwrap(); writeln!(output, " Found incremental tree:\n{incr_root:#?}").unwrap(); writeln!( output, " Full source ({}):\n\"{edited_src:?}\"", edited_src.len() ) .unwrap(); } spans_ok && tree_ok }; let mut pick = |range: Range| { let ratio = rng.next(); (range.start as f64 + ratio * (range.end - range.start) as f64).floor() as usize }; let insertions = (text.len() as f64 / 400.0).ceil() as usize; for _ in 0..insertions { let supplement = supplements[pick(0..supplements.len())]; let start = pick(0..text.len()); let end = pick(start..text.len()); if !text.is_char_boundary(start) || !text.is_char_boundary(end) { continue; } ok &= apply(start..end, supplement); } let source = Source::detached(text); let leafs = leafs(source.root()); let start = source.find(leafs[pick(0..leafs.len())].span()).unwrap().offset(); let supplement = supplements[pick(0..supplements.len())]; ok &= apply(start..start, supplement); ok } /// Returns all leaf descendants of a node (may include itself). fn leafs(node: &SyntaxNode) -> Vec { if node.children().len() == 0 { vec![node.clone()] } else { node.children().flat_map(leafs).collect() } } /// Ensure that all spans are properly ordered (and therefore unique). #[track_caller] fn test_spans(output: &mut String, root: &SyntaxNode) -> bool { test_spans_impl(output, root, 0..u64::MAX) } #[track_caller] fn test_spans_impl(output: &mut String, node: &SyntaxNode, within: Range) -> bool { if !within.contains(&node.span().number()) { writeln!(output, " Node: {node:#?}").unwrap(); writeln!( output, " Wrong span order: {} not in {within:?} ❌", node.span().number() ) .unwrap(); } let start = node.span().number() + 1; let mut children = node.children().peekable(); while let Some(child) = children.next() { let end = children.peek().map_or(within.end, |next| next.span().number()); if !test_spans_impl(output, child, start..end) { return false; } } true } /// Draw all frames into one image with padding in between. fn render(document: &Document) -> sk::Pixmap { let pixel_per_pt = 2.0; let padding = Abs::pt(5.0); for page in &document.pages { let limit = Abs::cm(100.0); if page.frame.width() > limit || page.frame.height() > limit { panic!("overlarge frame: {:?}", page.frame.size()); } } let mut pixmap = typst_render::render_merged( document, pixel_per_pt, Color::WHITE, padding, Color::BLACK, ); let padding = (pixel_per_pt * padding.to_pt() as f32).round(); let [x, mut y] = [padding; 2]; for page in &document.pages { let ts = sk::Transform::from_scale(pixel_per_pt, pixel_per_pt).post_translate(x, y); render_links(&mut pixmap, ts, &page.frame); y += (pixel_per_pt * page.frame.height().to_pt() as f32).round().max(1.0) + padding; } pixmap } /// Draw extra boxes for links so we can see whether they are there. fn render_links(canvas: &mut sk::Pixmap, ts: sk::Transform, frame: &Frame) { for (pos, item) in frame.items() { let ts = ts.pre_translate(pos.x.to_pt() as f32, pos.y.to_pt() as f32); match *item { FrameItem::Group(ref group) => { let ts = ts.pre_concat(to_sk_transform(&group.transform)); render_links(canvas, ts, &group.frame); } FrameItem::Meta(Meta::Link(_), size) => { let w = size.x.to_pt() as f32; let h = size.y.to_pt() as f32; let rect = sk::Rect::from_xywh(0.0, 0.0, w, h).unwrap(); let mut paint = sk::Paint::default(); paint.set_color_rgba8(40, 54, 99, 40); canvas.fill_rect(rect, &paint, ts, None); } _ => {} } } } fn to_sk_transform(transform: &Transform) -> sk::Transform { let Transform { sx, ky, kx, sy, tx, ty } = *transform; sk::Transform::from_row( sx.get() as _, ky.get() as _, kx.get() as _, sy.get() as _, tx.to_pt() as f32, ty.to_pt() as f32, ) } /// A Linear-feedback shift register using XOR as its shifting function. /// Can be used as PRNG. struct LinearShift(u64); impl LinearShift { /// Initialize the shift register with a pre-set seed. pub fn new() -> Self { Self(0xACE5) } /// Return a pseudo-random number between `0.0` and `1.0`. pub fn next(&mut self) -> f64 { self.0 ^= self.0 >> 3; self.0 ^= self.0 << 14; self.0 ^= self.0 >> 28; self.0 ^= self.0 << 36; self.0 ^= self.0 >> 52; self.0 as f64 / u64::MAX as f64 } }