diff --git a/.gitignore b/.gitignore index e4e7a1be8..381556a5f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,5 @@ **/*.rs.bk Cargo.lock bench/target -tests/png -tests/pdf +tests/out _things diff --git a/Cargo.toml b/Cargo.toml index a32cc8ead..258afd0b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,26 +8,22 @@ edition = "2018" members = ["bench"] [features] -default = ["fs", "anyhow"] +default = ["cli", "fs"] +cli = ["fs", "anyhow"] fs = ["fontdock/fs"] [dependencies] -fontdock = { path = "../fontdock", default-features = false } -pdf-writer = { path = "../pdf-writer" } deflate = { version = "0.8.6" } +fontdock = { path = "../fontdock", default-features = false } image = { version = "0.23", default-features = false, features = ["jpeg", "png"] } itoa = "0.4" +pdf-writer = { path = "../pdf-writer" } ttf-parser = "0.8.2" unicode-xid = "0.2" - -# feature = "serde" +anyhow = { version = "1", optional = true } serde = { version = "1", features = ["derive"], optional = true } -# for the CLI -anyhow = { version = "1", optional = true } - [dev-dependencies] -memmap = "0.7" tiny-skia = "0.2" [profile.dev] @@ -38,7 +34,7 @@ lto = true [[bin]] name = "typst" -required-features = ["fs", "anyhow"] +required-features = ["cli"] [[test]] name = "typeset" diff --git a/bench/src/bench.rs b/bench/src/bench.rs index c232d4bfa..947a02c52 100644 --- a/bench/src/bench.rs +++ b/bench/src/bench.rs @@ -13,7 +13,7 @@ use typst::parse::parse; use typst::typeset; const FONT_DIR: &str = "../fonts"; -const COMA: &str = include_str!("../../tests/typ/coma.typ"); +const COMA: &str = include_str!("../../tests/typ/example-coma.typ"); fn benchmarks(c: &mut Criterion) { macro_rules! bench { diff --git a/src/geom/point.rs b/src/geom/point.rs index 10ab2d3aa..4523a861a 100644 --- a/src/geom/point.rs +++ b/src/geom/point.rs @@ -17,6 +17,11 @@ impl Point { pub fn new(x: Length, y: Length) -> Self { Self { x, y } } + + /// Create an instance with two equal components. + pub fn uniform(value: Length) -> Self { + Self { x: value, y: value } + } } impl Get for Point { diff --git a/src/geom/size.rs b/src/geom/size.rs index 0ad0e0f89..289846591 100644 --- a/src/geom/size.rs +++ b/src/geom/size.rs @@ -21,6 +21,11 @@ impl Size { Self { width, height } } + /// Create an instance with two equal components. + pub fn uniform(value: Length) -> Self { + Self { width: value, height: value } + } + /// Whether the other size fits into this one (smaller width and height). pub fn fits(self, other: Self) -> bool { self.width >= other.width && self.height >= other.height diff --git a/src/main.rs b/src/main.rs index 3f12655bb..acd2d0cd1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,10 +59,10 @@ fn main() -> anyhow::Result<()> { let map = LineMap::new(&src); for diag in diags { let span = diag.span; - let start = map.location(span.start); - let end = map.location(span.end); + let start = map.location(span.start).unwrap(); + let end = map.location(span.end).unwrap(); println!( - " {}: {}:{}-{}: {}", + "{}: {}:{}-{}: {}", diag.v.level, src_path.display(), start, diff --git a/src/parse/lines.rs b/src/parse/lines.rs index ce5a1fe5a..be120d8aa 100644 --- a/src/parse/lines.rs +++ b/src/parse/lines.rs @@ -1,7 +1,7 @@ //! Conversion of byte positions to line/column locations. use super::Scanner; -use crate::syntax::{Location, Pos}; +use crate::syntax::{Location, Offset, Pos}; /// Enables conversion of byte position to locations. pub struct LineMap<'s> { @@ -25,23 +25,48 @@ impl<'s> LineMap<'s> { } /// Convert a byte position to a location. - /// - /// # Panics - /// This panics if the position is out of bounds. - pub fn location(&self, pos: Pos) -> Location { + pub fn location(&self, pos: Pos) -> Option { + // Find the line which contains the position. let line_index = match self.line_starts.binary_search(&pos) { Ok(i) => i, Err(i) => i - 1, }; - let line_start = self.line_starts[line_index]; - let head = &self.src[line_start.to_usize() .. pos.to_usize()]; + let start = self.line_starts.get(line_index)?; + let head = self.src.get(start.to_usize() .. pos.to_usize())?; let column_index = head.chars().count(); - Location { + Some(Location { line: 1 + line_index as u32, column: 1 + column_index as u32, - } + }) + } + + /// Convert a location to a byte position. + pub fn pos(&self, location: Location) -> Option { + // Determine the boundaries of the line. + let line_idx = location.line.checked_sub(1)? as usize; + let line_start = self.line_starts.get(line_idx)?; + let line_end = self + .line_starts + .get(location.line as usize) + .map_or(self.src.len(), |pos| pos.to_usize()); + + let line = self.src.get(line_start.to_usize() .. line_end)?; + + // Find the index in the line. For the first column, the index is always zero. For + // other columns, we have to look at which byte the char directly before the + // column in question ends. We can't do `nth(column_idx)` directly since the + // column may be behind the last char. + let column_idx = location.column.checked_sub(1)? as usize; + let line_offset = if let Some(prev_idx) = column_idx.checked_sub(1) { + let (idx, prev) = line.char_indices().nth(prev_idx)?; + idx + prev.len_utf8() + } else { + 0 + }; + + Some(line_start.offset(Pos(line_offset as u32))) } } @@ -71,18 +96,26 @@ mod tests { #[test] fn test_line_map_location() { let map = LineMap::new(TEST); - assert_eq!(map.location(Pos(0)), Location::new(1, 1)); - assert_eq!(map.location(Pos(2)), Location::new(1, 2)); - assert_eq!(map.location(Pos(6)), Location::new(1, 6)); - assert_eq!(map.location(Pos(7)), Location::new(2, 1)); - assert_eq!(map.location(Pos(8)), Location::new(2, 2)); - assert_eq!(map.location(Pos(12)), Location::new(2, 3)); - assert_eq!(map.location(Pos(21)), Location::new(4, 4)); + assert_eq!(map.location(Pos(0)), Some(Location::new(1, 1))); + assert_eq!(map.location(Pos(2)), Some(Location::new(1, 2))); + assert_eq!(map.location(Pos(6)), Some(Location::new(1, 6))); + assert_eq!(map.location(Pos(7)), Some(Location::new(2, 1))); + assert_eq!(map.location(Pos(8)), Some(Location::new(2, 2))); + assert_eq!(map.location(Pos(12)), Some(Location::new(2, 3))); + assert_eq!(map.location(Pos(21)), Some(Location::new(4, 4))); + assert_eq!(map.location(Pos(22)), None); } #[test] - #[should_panic] - fn test_line_map_panics_out_of_bounds() { - LineMap::new(TEST).location(Pos(22)); + fn test_line_map_pos() { + fn assert_round_trip(map: &LineMap, pos: Pos) { + assert_eq!(map.location(pos).and_then(|loc| map.pos(loc)), Some(pos)); + } + + let map = LineMap::new(TEST); + assert_round_trip(&map, Pos(0)); + assert_round_trip(&map, Pos(7)); + assert_round_trip(&map, Pos(12)); + assert_round_trip(&map, Pos(21)); } } diff --git a/src/parse/tests.rs b/src/parse/tests.rs index 054b2cd90..172b1d15a 100644 --- a/src/parse/tests.rs +++ b/src/parse/tests.rs @@ -56,24 +56,31 @@ use Unit::*; fn Id(ident: &str) -> Expr { Expr::Lit(Lit::Ident(Ident(ident.to_string()))) } + fn Bool(b: bool) -> Expr { Expr::Lit(Lit::Bool(b)) } + fn Int(int: i64) -> Expr { Expr::Lit(Lit::Int(int)) } + fn Float(float: f64) -> Expr { Expr::Lit(Lit::Float(float)) } + fn Percent(percent: f64) -> Expr { Expr::Lit(Lit::Percent(percent)) } + fn Length(val: f64, unit: Unit) -> Expr { Expr::Lit(Lit::Length(val, unit)) } + fn Color(color: RgbaColor) -> Expr { Expr::Lit(Lit::Color(color)) } + fn Str(string: &str) -> Expr { Expr::Lit(Lit::Str(string.to_string())) } @@ -98,6 +105,7 @@ fn Unary(op: impl Into>, expr: impl Into>) -> Expr { expr: expr.into().map(Box::new), }) } + fn Binary( op: impl Into>, lhs: impl Into>, diff --git a/tests/README.md b/tests/README.md index 89c31c890..7d9c3edac 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,8 +1,7 @@ # Tests - `typ`: Input files -- `pdf`: PDF files produced by tests -- `png`: PNG files produced by tests -- `cmp`: Reference images which the PNGs are compared to byte-wise to determine - whether the test passed or failed +- `ref`: Reference images which the output is compared with to determine + whether a test passed or failed - `res`: Resource files used by tests +- `out`: PNG and PDF files produced by tests diff --git a/tests/cmp/coma.png b/tests/cmp/coma.png deleted file mode 100644 index d0c524ecf..000000000 Binary files a/tests/cmp/coma.png and /dev/null differ diff --git a/tests/cmp/image.png b/tests/cmp/image.png deleted file mode 100644 index 5bf744e99..000000000 Binary files a/tests/cmp/image.png and /dev/null differ diff --git a/tests/ref/empty.png b/tests/ref/empty.png new file mode 100644 index 000000000..812a3758c Binary files /dev/null and b/tests/ref/empty.png differ diff --git a/tests/ref/example-coma.png b/tests/ref/example-coma.png new file mode 100644 index 000000000..0c18b810c Binary files /dev/null and b/tests/ref/example-coma.png differ diff --git a/tests/ref/image-error.png b/tests/ref/image-error.png new file mode 100644 index 000000000..812a3758c Binary files /dev/null and b/tests/ref/image-error.png differ diff --git a/tests/ref/image-fit.png b/tests/ref/image-fit.png new file mode 100644 index 000000000..b89e78fd1 Binary files /dev/null and b/tests/ref/image-fit.png differ diff --git a/tests/ref/image-jpeg.png b/tests/ref/image-jpeg.png new file mode 100644 index 000000000..ef9e74cbd Binary files /dev/null and b/tests/ref/image-jpeg.png differ diff --git a/tests/ref/image-png.png b/tests/ref/image-png.png new file mode 100644 index 000000000..4e0818d21 Binary files /dev/null and b/tests/ref/image-png.png differ diff --git a/tests/typ/empty.typ b/tests/typ/empty.typ new file mode 100644 index 000000000..e69de29bb diff --git a/tests/typ/coma.typ b/tests/typ/example-coma.typ similarity index 91% rename from tests/typ/coma.typ rename to tests/typ/example-coma.typ index 839335b70..f841a1221 100644 --- a/tests/typ/coma.typ +++ b/tests/typ/example-coma.typ @@ -1,3 +1,5 @@ +// Small integration test of syntax, page setup, box layout and alignment. + [page: width=450pt, height=300pt, margins=1cm] [box][ diff --git a/tests/typ/image-error.typ b/tests/typ/image-error.typ new file mode 100644 index 000000000..4fde4ab29 --- /dev/null +++ b/tests/typ/image-error.typ @@ -0,0 +1,8 @@ +// error: 5:9-5:30 failed to load image +// error: 8:9-8:30 failed to load image + +// File does not exist. +[image: "path/does/not/exist"] + +// File exists, but is no image. +[image: "typ/image-error.typ"] diff --git a/tests/typ/image-fit.typ b/tests/typ/image-fit.typ new file mode 100644 index 000000000..b735f058e --- /dev/null +++ b/tests/typ/image-fit.typ @@ -0,0 +1,21 @@ +// Fit to width of page. +[image: "res/rhino.png"] + +// Fit to height of page. +[page: width=270pt][ + [image: "res/rhino.png"] +] + +// Set width explicitly. +[image: "res/rhino.png", width=50pt] + +// Set height explicitly. +[image: "res/rhino.png", height=50pt] + +// Set width and height explicitly and force stretching. +[image: "res/rhino.png", width=25pt, height=50pt] + +// Make sure the bounding-box of the image is correct. +[align: bottom, right][ + [image: "res/tiger.jpg"] +] diff --git a/tests/typ/image-jpeg.typ b/tests/typ/image-jpeg.typ new file mode 100644 index 000000000..48cf1a0dc --- /dev/null +++ b/tests/typ/image-jpeg.typ @@ -0,0 +1,2 @@ +// Load an RGB JPEG image. +[image: "res/tiger.jpg"] diff --git a/tests/typ/image-png.typ b/tests/typ/image-png.typ new file mode 100644 index 000000000..482591e92 --- /dev/null +++ b/tests/typ/image-png.typ @@ -0,0 +1,2 @@ +// Load an RGBA PNG image. +[image: "res/rhino.png"] diff --git a/tests/typ/image.typ b/tests/typ/image.typ deleted file mode 100644 index 6ae349a10..000000000 --- a/tests/typ/image.typ +++ /dev/null @@ -1,15 +0,0 @@ -[page: width=5cm, height=5cm, margins=0.25cm] - -[image: "res/tiger.jpg"] - -[pagebreak] - -# Tiger -[image: "res/tiger.jpg", width=2cm] -[image: "res/rhino.png", width=1cm] -[image: "res/rhino.png", height=2cm] - -[pagebreak] - -[align: center, bottom] -[image: "res/tiger.jpg", width=2cm, height=3.5cm] diff --git a/tests/typeset.rs b/tests/typeset.rs index 807215ed1..037bd7efa 100644 --- a/tests/typeset.rs +++ b/tests/typeset.rs @@ -1,35 +1,35 @@ use std::cell::RefCell; use std::env; use std::ffi::OsStr; -use std::fs::{self, File}; +use std::fs; use std::path::Path; use std::rc::Rc; use fontdock::fs::{FsIndex, FsSource}; use image::{GenericImageView, Rgba}; -use memmap::Mmap; use tiny_skia::{ Canvas, Color, ColorU8, FillRule, FilterQuality, Paint, PathBuilder, Pattern, Pixmap, Rect, SpreadMode, Transform, }; use ttf_parser::OutlineBuilder; -use typst::diag::{Feedback, Pass}; +use typst::diag::{Diag, Feedback, Level, Pass}; use typst::env::{Env, ImageResource, ResourceLoader, SharedEnv}; use typst::eval::State; use typst::export::pdf; use typst::font::FontLoader; -use typst::geom::{Length, Point}; +use typst::geom::{Length, Point, Sides, Size}; use typst::layout::{BoxLayout, ImageElement, LayoutElement}; -use typst::parse::LineMap; +use typst::parse::{LineMap, Scanner}; use typst::shaping::Shaped; +use typst::syntax::{Location, Pos, SpanVec, SpanWith, Spanned}; use typst::typeset; -const FONT_DIR: &str = "../fonts"; const TYP_DIR: &str = "typ"; -const PDF_DIR: &str = "pdf"; -const PNG_DIR: &str = "png"; -const CMP_DIR: &str = "cmp"; +const REF_DIR: &str = "ref"; +const PNG_DIR: &str = "out/png"; +const PDF_DIR: &str = "out/pdf"; +const FONT_DIR: &str = "../fonts"; fn main() { env::set_current_dir(env::current_dir().unwrap().join("tests")).unwrap(); @@ -44,12 +44,8 @@ fn main() { } let name = src_path.file_stem().unwrap().to_string_lossy().to_string(); - let pdf_path = Path::new(PDF_DIR).join(&name).with_extension("pdf"); - let png_path = Path::new(PNG_DIR).join(&name).with_extension("png"); - let ref_path = Path::new(CMP_DIR).join(&name).with_extension("png"); - if filter.matches(&name) { - filtered.push((name, src_path, pdf_path, png_path, ref_path)); + filtered.push((name, src_path)); } } @@ -62,8 +58,8 @@ fn main() { println!("Running {} tests", len); } - fs::create_dir_all(PDF_DIR).unwrap(); fs::create_dir_all(PNG_DIR).unwrap(); + fs::create_dir_all(PDF_DIR).unwrap(); let mut index = FsIndex::new(); index.search_dir(FONT_DIR); @@ -76,29 +72,12 @@ fn main() { let mut ok = true; - for (name, src_path, pdf_path, png_path, ref_path) in filtered { - print!("Testing {}.", name); - test(&src_path, &pdf_path, &png_path, &env); + for (name, src_path) in filtered { + let png_path = Path::new(PNG_DIR).join(&name).with_extension("png"); + let pdf_path = Path::new(PDF_DIR).join(&name).with_extension("pdf"); + let ref_path = Path::new(REF_DIR).join(&name).with_extension("png"); - let png_file = File::open(&png_path).unwrap(); - let ref_file = match File::open(&ref_path) { - Ok(file) => file, - Err(_) => { - println!(" Failed to open reference image. ❌"); - ok = false; - continue; - } - }; - - let a = unsafe { Mmap::map(&png_file).unwrap() }; - let b = unsafe { Mmap::map(&ref_file).unwrap() }; - - if *a != *b { - println!(" Does not match reference image. ❌"); - ok = false; - } else { - println!(" Okay. ✔"); - } + ok &= test(&name, &src_path, &pdf_path, &png_path, &ref_path, &env); } if !ok { @@ -106,41 +85,6 @@ fn main() { } } -fn test(src_path: &Path, pdf_path: &Path, png_path: &Path, env: &SharedEnv) { - let src = fs::read_to_string(src_path).unwrap(); - let state = State::default(); - let Pass { - output: layouts, - feedback: Feedback { mut diags, .. }, - } = typeset(&src, Rc::clone(env), state); - - if !diags.is_empty() { - diags.sort(); - - let map = LineMap::new(&src); - for diag in diags { - let span = diag.span; - let start = map.location(span.start); - let end = map.location(span.end); - println!( - " {}: {}:{}-{}: {}", - diag.v.level, - src_path.display(), - start, - end, - diag.v.message, - ); - } - } - - let env = env.borrow(); - let canvas = draw(&layouts, &env, 2.0); - canvas.pixmap.save_png(png_path).unwrap(); - - let pdf_data = pdf::export(&layouts, &env); - fs::write(pdf_path, pdf_data).unwrap(); -} - struct TestFilter { filter: Vec, perfect: bool, @@ -171,6 +115,111 @@ impl TestFilter { } } +fn test( + name: &str, + src_path: &Path, + pdf_path: &Path, + png_path: &Path, + ref_path: &Path, + env: &SharedEnv, +) -> bool { + println!("Testing {}.", name); + + let src = fs::read_to_string(src_path).unwrap(); + let map = LineMap::new(&src); + let ref_diags = parse_diags(&src, &map); + + let mut state = State::default(); + state.page.size = Size::uniform(Length::pt(120.0)); + state.page.margins = Sides::uniform(Some(Length::pt(10.0).into())); + + let Pass { + output: layouts, + feedback: Feedback { mut diags, .. }, + } = typeset(&src, Rc::clone(env), state); + diags.sort(); + + let env = env.borrow(); + let canvas = draw(&layouts, &env, 2.0); + canvas.pixmap.save_png(png_path).unwrap(); + + let pdf_data = pdf::export(&layouts, &env); + fs::write(pdf_path, pdf_data).unwrap(); + + let mut ok = true; + + if diags != ref_diags { + println!(" Does not match expected diagnostics. ❌"); + ok = false; + + for diag in &diags { + if ref_diags.binary_search(diag).is_err() { + print!(" Unexpected | "); + print_diag(diag, &map); + } + } + + for diag in &ref_diags { + if diags.binary_search(diag).is_err() { + print!(" Missing | "); + print_diag(diag, &map); + } + } + } + + if let Ok(ref_pixmap) = Pixmap::load_png(&ref_path) { + if canvas.pixmap != ref_pixmap { + println!(" Does not match reference image. ❌"); + ok = false; + } + } else { + println!(" Failed to open reference image. ❌"); + ok = false; + } + + if ok { + println!("\x1b[1ATesting {}. ✔", name); + } + + ok +} + +fn parse_diags(src: &str, map: &LineMap) -> SpanVec { + let mut diags = vec![]; + + for line in src.lines() { + let (level, rest) = if let Some(rest) = line.strip_prefix("// error: ") { + (Level::Error, rest) + } else if let Some(rest) = line.strip_prefix("// warning: ") { + (Level::Warning, rest) + } else { + continue; + }; + + fn pos(s: &mut Scanner, map: &LineMap) -> Pos { + let (line, _, column) = (num(s), s.eat_assert(':'), num(s)); + map.pos(Location { line, column }).unwrap() + } + + fn num(s: &mut Scanner) -> u32 { + s.eat_while(|c| c.is_numeric()).parse().unwrap() + } + + let mut s = Scanner::new(rest); + let (start, _, end) = (pos(&mut s, map), s.eat_assert('-'), pos(&mut s, map)); + diags.push(Diag::new(level, s.rest().trim()).span_with(start .. end)); + } + + diags.sort(); + diags +} + +fn print_diag(diag: &Spanned, map: &LineMap) { + let start = map.location(diag.span.start).unwrap(); + let end = map.location(diag.span.end).unwrap(); + println!("{}: {}-{}: {}", diag.v.level, start, end, diag.v.message,); +} + fn draw(layouts: &[BoxLayout], env: &Env, pixel_per_pt: f32) -> Canvas { let pad = Length::pt(5.0);