diff options
Diffstat (limited to 'tests/src')
| -rw-r--r-- | tests/src/layouter.rs | 192 | ||||
| -rw-r--r-- | tests/src/parser.rs | 311 | ||||
| -rw-r--r-- | tests/src/render.py | 193 | ||||
| -rw-r--r-- | tests/src/spanless.rs | 62 |
4 files changed, 758 insertions, 0 deletions
diff --git a/tests/src/layouter.rs b/tests/src/layouter.rs new file mode 100644 index 00000000..6d38666b --- /dev/null +++ b/tests/src/layouter.rs @@ -0,0 +1,192 @@ +use std::collections::HashMap; +use std::error::Error; +use std::ffi::OsStr; +use std::fs::{File, create_dir_all, read_dir, read_to_string}; +use std::io::{BufWriter, Write}; +use std::panic; +use std::process::Command; + +use futures_executor::block_on; + +use typstc::Typesetter; +use typstc::layout::{MultiLayout, Serialize}; +use typstc::size::{Size, Size2D}; +use typstc::style::PageStyle; +use typstc::toddle::query::FileSystemFontProvider; +use typstc::export::pdf::PdfExporter; + + +type DynResult<T> = Result<T, Box<dyn Error>>; + +fn main() -> DynResult<()> { + let opts = Options::parse(); + + create_dir_all("tests/cache/serial")?; + create_dir_all("tests/cache/render")?; + create_dir_all("tests/cache/pdf")?; + + let tests: Vec<_> = read_dir("tests/layouter/")?.collect(); + let mut filtered = Vec::new(); + + for entry in tests { + let path = entry?.path(); + if path.extension() != Some(OsStr::new("typ")) { + continue; + } + + let name = path + .file_stem().ok_or("expected file stem")? + .to_string_lossy() + .to_string(); + + if opts.matches(&name) { + let src = read_to_string(&path)?; + filtered.push((name, src)); + } + } + + let len = filtered.len(); + println!(); + println!("Running {} test{}", len, if len > 1 { "s" } else { "" }); + + for (name, src) in filtered { + panic::catch_unwind(|| { + if let Err(e) = test(&name, &src) { + println!("error: {}", e); + } + }).ok(); + } + + println!(); + + Ok(()) +} + +/// Create a _PDF_ with a name from the source code. +fn test(name: &str, src: &str) -> DynResult<()> { + println!("Testing: {}.", name); + + let mut typesetter = Typesetter::new(); + typesetter.set_page_style(PageStyle { + dimensions: Size2D::with_all(Size::pt(250.0)), + .. PageStyle::default() + }); + + let provider = FileSystemFontProvider::from_index("../fonts/index.json")?; + let font_paths = provider.paths(); + typesetter.add_font_provider(provider); + + let layouts = match compile(&typesetter, src) { + Some(layouts) => layouts, + None => return Ok(()), + }; + + // Compute the font's paths. + let mut fonts = HashMap::new(); + let loader = typesetter.loader().borrow(); + for layout in &layouts { + for index in layout.find_used_fonts() { + fonts.entry(index).or_insert_with(|| { + let p = loader.get_provider_and_index(index.id).1; + &font_paths[p][index.variant] + }); + } + } + drop(loader); + + // Write the serialized layout file. + let path = format!("tests/cache/serial/{}", name); + let mut file = BufWriter::new(File::create(path)?); + + // Write the font mapping into the serialization file. + writeln!(file, "{}", fonts.len())?; + for (index, path) in fonts.iter() { + writeln!(file, "{} {} {}", index.id, index.variant, path)?; + } + layouts.serialize(&mut file)?; + + // Render the layout into a PNG. + Command::new("python") + .arg("tests/render.py") + .arg(name) + .spawn() + .expect("failed to run python renderer"); + + // Write the PDF file. + let path = format!("tests/cache/pdf/{}.pdf", name); + let file = BufWriter::new(File::create(path)?); + let exporter = PdfExporter::new(); + exporter.export(&layouts, typesetter.loader(), file)?; + + Ok(()) +} + +/// Compile the source code with the typesetter. +fn compile(typesetter: &Typesetter, src: &str) -> Option<MultiLayout> { + #[cfg(not(debug_assertions))] { + use std::time::Instant; + + // Warmup. + let warmup_start = Instant::now(); + let is_ok = block_on(typesetter.typeset(&src)).is_ok(); + let warmup_end = Instant::now(); + + // Only continue if the typesetting was successful. + if is_ok { + let start = Instant::now(); + let tree = typesetter.parse(&src).unwrap(); + let mid = Instant::now(); + block_on(typesetter.layout(&tree)).unwrap(); + let end = Instant::now(); + + println!(" - cold start: {:?}", warmup_end - warmup_start); + println!(" - warmed up: {:?}", end - start); + println!(" - parsing: {:?}", mid - start); + println!(" - layouting: {:?}", end - mid); + println!(); + } + }; + + match block_on(typesetter.typeset(&src)) { + Ok(layouts) => Some(layouts), + Err(err) => { + println!(" - compilation failed: {}", err); + #[cfg(not(debug_assertions))] + println!(); + None + } + } +} + +/// Command line options. +struct Options { + filter: Vec<String>, + perfect: bool, +} + +impl Options { + /// Parse the options from the environment arguments. + fn parse() -> Options { + let mut perfect = false; + let mut filter = Vec::new(); + + for arg in std::env::args().skip(1) { + match arg.as_str() { + "--nocapture" => {}, + "=" => perfect = true, + _ => filter.push(arg), + } + } + + Options { filter, perfect } + } + + /// Whether a given test should be executed. + fn matches(&self, name: &str) -> bool { + match self.perfect { + true => self.filter.iter().any(|p| name == p), + false => self.filter.is_empty() + || self.filter.iter().any(|p| name.contains(p)) + } + } +} diff --git a/tests/src/parser.rs b/tests/src/parser.rs new file mode 100644 index 00000000..ecf1544c --- /dev/null +++ b/tests/src/parser.rs @@ -0,0 +1,311 @@ +use std::fmt::Debug; + +use typstc::func::Scope; +use typstc::size::Size; +use typstc::syntax::*; +use typstc::{function, parse}; + +mod spanless; +use spanless::SpanlessEq; + + +/// The result of a single test case. +enum Case { + Okay, + Failed { + line: usize, + src: &'static str, + expected: String, + found: String, + } +} + +/// Test all tests. +fn test(tests: Vec<(&str, Vec<Case>)>) { + println!(); + + let mut errors = false; + + let len = tests.len(); + println!("Running {} test{}", len, if len > 1 { "s" } else { "" }); + + for (file, cases) in tests { + print!("Testing: {}. ", file); + + let mut okay = 0; + let mut failed = 0; + + for case in cases { + match case { + Case::Okay => okay += 1, + Case::Failed { line, src, expected, found } => { + println!(); + println!(" - Case failed in file {}.rs in line {}.", file, line); + println!(" - Source: {:?}", src); + println!(" - Expected: {}", expected); + println!(" - Found: {}", found); + + failed += 1; + } + } + } + + // Print a small summary. + print!("{} okay, {} failed.", okay, failed); + if failed == 0 { + print!(" ✔") + } else { + errors = true; + } + + println!(); + } + + println!(); + + if errors { + std::process::exit(-1); + } +} + +/// The main test macro. +macro_rules! tokens { + ($($task:ident $src:expr =>($line:expr)=> [$($e:tt)*])*) => ({ + vec![$({ + let (okay, expected, found) = case!($task $src, [$($e)*]); + if okay { + Case::Okay + } else { + Case::Failed { + line: $line, + src: $src, + expected: format(expected), + found: format(found), + } + } + }),*] + }); +} + +//// Indented formatting for failed cases. +fn format(thing: impl Debug) -> String { + format!("{:#?}", thing).replace('\n', "\n ") +} + +/// Evaluates a single test. +macro_rules! case { + (t $($rest:tt)*) => (case!(@tokenize SpanlessEq::spanless_eq, $($rest)*)); + (ts $($rest:tt)*) => (case!(@tokenize PartialEq::eq, $($rest)*)); + + (@tokenize $cmp:expr, $src:expr, [$($e:tt)*]) => ({ + let expected = list!(tokens [$($e)*]); + let found = tokenize($src).collect::<Vec<_>>(); + ($cmp(&found, &expected), expected, found) + }); + + (p $($rest:tt)*) => (case!(@parse SpanlessEq::spanless_eq, $($rest)*)); + (ps $($rest:tt)*) => (case!(@parse PartialEq::eq, $($rest)*)); + + (@parse $cmp:expr, $src:expr, [$($e:tt)*]) => ({ + let expected = SyntaxTree { nodes: list!(nodes [$($e)*]) }; + let found = parse($src, ParseContext { scope: &scope() }).0; + ($cmp(&found, &expected), expected, found) + }); + + (c $src:expr, [$($e:tt)*]) => ({ + let expected = Colorization { tokens: list!(colors [$($e)*]) }; + let found = parse($src, ParseContext { scope: &scope() }).1; + (expected == found, expected, found) + }); + + (e $src:expr, [$($e:tt)*]) => ({ + let expected = ErrorMap { errors: list!([$($e)*]) }; + let found = parse($src, ParseContext { scope: &scope() }).2; + (expected == found, expected, found) + }); +} + +/// A scope containing the `DebugFn` as a fallback. +fn scope() -> Scope { + Scope::with_debug::<DebugFn>() +} + +/// Parses possibly-spanned lists of token or node expressions. +macro_rules! list { + (expr [$($item:expr),* $(,)?]) => ({ + #[allow(unused_imports)] + use cuts::expr::*; + Tuple { items: vec![$(zspan($item)),*] } + }); + + (expr [$($key:expr =>($_:expr)=> $value:expr),* $(,)?]) => ({ + #[allow(unused_imports)] + use cuts::expr::*; + Object { + pairs: vec![$(Pair { + key: zspan(Ident($key.to_string())), + value: zspan($value), + }),*] + } + }); + + ($cut:ident [$($e:tt)*]) => ({ + #[allow(unused_imports)] + use cuts::$cut::*; + list!([$($e)*]) + }); + + ([$(($sl:tt:$sc:tt, $el:tt:$ec:tt, $v:expr)),* $(,)?]) => ({ + vec![ + $(Spanned { v: $v, span: Span { + start: Position { line: $sl, column: $sc }, + end: Position { line: $el, column: $ec }, + }}),* + ] + }); + + ([$($e:tt)*]) => (vec![$($e)*].into_iter().map(zspan).collect::<Vec<_>>()); +} + +/// Composes a function expression. +macro_rules! func { + ($name:expr $(,pos: [$($p:tt)*])? $(,key: [$($k:tt)*])?; $($b:tt)*) => ({ + #![allow(unused_mut, unused_assignments)] + + let mut positional = Tuple::new(); + let mut keyword = Object::new(); + + $(positional = list!(expr [$($p)*]);)? + $(keyword = list!(expr [$($k)*]);)? + + Node::Func(FuncCall(Box::new(DebugFn { + header: FuncHeader { + name: zspan(Ident($name.to_string())), + args: FuncArgs { + positional, + keyword, + }, + }, + body: func!(@body $($b)*), + }))) + }); + + (@body Some($($b:tt)*)) => (Some(SyntaxTree { nodes: list!(nodes $($b)*) })); + (@body None) => (None); +} + +function! { + /// Most functions in the tests are parsed into the debug function for easy + /// inspection of arguments and body. + #[derive(Debug, PartialEq)] + pub struct DebugFn { + header: FuncHeader, + body: Option<SyntaxTree>, + } + + parse(header, body, ctx) { + DebugFn { + header: header.clone(), + body: parse!(optional: body, ctx), + } + } + + layout() { vec![] } +} + +/// Span an element with a zero span. +fn zspan<T>(v: T) -> Spanned<T> { + Spanned { v, span: Span::ZERO } +} + +/// Abbreviations for tokens, nodes, colors and expressions. +#[allow(non_snake_case, dead_code)] +mod cuts { + pub mod tokens { + pub use typstc::syntax::Token::{ + Whitespace as W, + LineComment as LC, + BlockComment as BC, + StarSlash as SS, + LeftBracket as LB, + RightBracket as RB, + LeftParen as LP, + RightParen as RP, + LeftBrace as LBR, + RightBrace as RBR, + Colon as CL, + Comma as CM, + Equals as EQ, + ExprIdent as ID, + ExprStr as STR, + ExprSize as SIZE, + ExprNumber as NUM, + ExprBool as BOOL, + Star as S, + Underscore as U, + Backtick as B, + Text as T, + }; + } + + pub mod nodes { + use typstc::syntax::Node; + + pub use Node::{ + Space as S, + Newline as N, + ToggleItalic as I, + ToggleBolder as B, + ToggleMonospace as M, + }; + + pub fn T(text: &str) -> Node { + Node::Text(text.to_string()) + } + } + + pub mod colors { + pub use typstc::syntax::ColorToken::{ + Comment as C, + Bracket as B, + FuncName as FN, + Colon as CL, + Key as K, + Equals as EQ, + Comma as CM, + Paren as P, + Brace as BR, + ExprIdent as ID, + ExprStr as STR, + ExprNumber as NUM, + ExprSize as SIZE, + ExprBool as BOOL, + Bold as BD, + Italic as IT, + Monospace as MS, + Invalid as INV, + }; + } + + pub mod expr { + use typstc::syntax::{Expression, Ident}; + + pub use Expression::{ + Number as NUM, + Size as SIZE, + Bool as BOOL, + }; + + pub fn ID(text: &str) -> Expression { + Expression::Ident(Ident(text.to_string())) + } + + pub fn STR(text: &str) -> Expression { + Expression::Str(text.to_string()) + } + } +} + +fn main() { + test(include!("../cache/parser-tests.rs")) +} diff --git a/tests/src/render.py b/tests/src/render.py new file mode 100644 index 00000000..bb27e973 --- /dev/null +++ b/tests/src/render.py @@ -0,0 +1,193 @@ +import sys +import os +import pathlib +import math +import numpy +from PIL import Image, ImageDraw, ImageFont + + +BASE = os.path.dirname(__file__) +CACHE = os.path.join(BASE, '../cache/') +SERIAL = os.path.join(CACHE, 'serial/') +RENDER = os.path.join(CACHE, 'render/') + + +def main(): + assert len(sys.argv) == 2, 'usage: python render.py <name>' + name = sys.argv[1] + + filename = os.path.join(SERIAL, name) + with open(filename, encoding='utf-8') as file: + lines = [line[:-1] for line in file.readlines()] + + renderer = MultiboxRenderer(lines) + renderer.render() + image = renderer.export() + + pathlib.Path(RENDER).mkdir(parents=True, exist_ok=True) + image.save(os.path.join(RENDER, f'{name}.png')) + + +class MultiboxRenderer: + def __init__(self, lines): + self.combined = None + + self.fonts = {} + font_count = int(lines[0]) + for i in range(font_count): + parts = lines[i + 1].split(' ', 2) + index = int(parts[0]), int(parts[1]) + path = parts[2] + self.fonts[index] = os.path.join(BASE, '../../fonts', path) + + self.content = lines[font_count + 1:] + + def render(self): + images = [] + + layout_count = int(self.content[0]) + horizontal = math.floor(math.sqrt(layout_count)) + start = 1 + + for _ in range(layout_count): + width, height = (float(s) for s in self.content[start].split()) + action_count = int(self.content[start + 1]) + start += 2 + + renderer = BoxRenderer(self.fonts, width, height) + for i in range(action_count): + command = self.content[start + i] + renderer.execute(command) + + images.append(renderer.export()) + start += action_count + + i = 0 + x = 10 + y = 10 + width = 10 + row_height = 0 + + positions = [] + + for image in images: + positions.append((x, y)) + + x += 10 + image.width + row_height = max(row_height, image.height) + + i += 1 + if i >= horizontal: + width = max(width, x) + x = 10 + y += 10 + row_height + i = 0 + row_height = 0 + + height = y + if i != 0: + height += 10 + row_height + + self.combined = Image.new('RGBA', (width, height)) + + for (position, image) in zip(positions, images): + self.combined.paste(image, position) + + def export(self): + return self.combined + + +class BoxRenderer: + def __init__(self, fonts, width, height, grid=False): + self.fonts = fonts + self.size = (pix(width), pix(height)) + + img = Image.new('RGBA', self.size, (255, 255, 255, 255)) + pixels = numpy.array(img) + + if grid: + for i in range(0, int(height)): + for j in range(0, int(width)): + if ((i // 2) % 2 == 0) == ((j // 2) % 2 == 0): + pixels[4*i:4*(i+1), 4*j:4*(j+1)] = (225, 225, 225, 255) + + self.img = Image.fromarray(pixels, 'RGBA') + self.draw = ImageDraw.Draw(self.img) + self.cursor = (0, 0) + + self.colors = [ + (176, 264, 158), + (274, 173, 207), + (158, 252, 264), + (285, 275, 187), + (132, 217, 136), + (236, 177, 246), + (174, 232, 279), + (285, 234, 158) + ] + + self.rects = [] + self.color_index = 0 + + def execute(self, command): + cmd = command[0] + parts = command.split()[1:] + + if cmd == 'm': + x, y = (pix(float(s)) for s in parts) + self.cursor = [x, y] + + elif cmd == 'f': + index = int(parts[0]), int(parts[1]) + size = pix(float(parts[2])) + self.font = ImageFont.truetype(self.fonts[index], size) + + elif cmd == 'w': + text = command[2:] + width = self.draw.textsize(text, font=self.font)[0] + self.draw.text(self.cursor, text, (0, 0, 0, 255), font=self.font) + self.cursor[0] += width + + elif cmd == 'b': + x, y = self.cursor + w, h = (pix(float(s)) for s in parts) + rect = [x, y, x+w-1, y+h-1] + + forbidden_colors = set() + for other_rect, other_color in self.rects: + if rect == other_rect: + return + + if overlap(rect, other_rect) or overlap(other_rect, rect): + forbidden_colors.add(other_color) + + for color in self.colors[self.color_index:] + self.colors[:self.color_index]: + self.color_index = (self.color_index + 1) % len(self.colors) + if color not in forbidden_colors: + break + + overlay = Image.new('RGBA', self.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + draw.rectangle(rect, fill=color + (255,)) + + self.img = Image.alpha_composite(self.img, overlay) + self.draw = ImageDraw.Draw(self.img) + + self.rects.append((rect, color)) + + else: + raise Exception('invalid command') + + def export(self): + return self.img + + +def pix(points): + return int(4 * points) + +def overlap(a, b): + return (a[0] < b[2] and b[0] < a[2]) and (a[1] < b[3] and b[1] < a[3]) + + +if __name__ == '__main__': + main() diff --git a/tests/src/spanless.rs b/tests/src/spanless.rs new file mode 100644 index 00000000..fde5a2ed --- /dev/null +++ b/tests/src/spanless.rs @@ -0,0 +1,62 @@ +use super::*; + + +/// Compares elements by only looking at values and ignoring spans. +pub trait SpanlessEq<T> { + fn spanless_eq(&self, other: &T) -> bool; +} + +impl SpanlessEq<Vec<Spanned<Token<'_>>>> for Vec<Spanned<Token<'_>>> { + fn spanless_eq(&self, other: &Vec<Spanned<Token>>) -> bool { + self.len() == other.len() + && self.iter().zip(other).all(|(x, y)| x.v == y.v) + } +} + +impl SpanlessEq<SyntaxTree> for SyntaxTree { + fn spanless_eq(&self, other: &SyntaxTree) -> bool { + fn downcast(func: &FuncCall) -> &DebugFn { + func.0.downcast::<DebugFn>().expect("not a debug fn") + } + + self.nodes.len() == other.nodes.len() + && self.nodes.iter().zip(&other.nodes).all(|(x, y)| match (&x.v, &y.v) { + (Node::Func(a), Node::Func(b)) => downcast(a).spanless_eq(downcast(b)), + (a, b) => a == b, + }) + } +} + +impl SpanlessEq<DebugFn> for DebugFn { + fn spanless_eq(&self, other: &DebugFn) -> bool { + self.header.name.v == other.header.name.v + && self.header.args.positional.spanless_eq(&other.header.args.positional) + && self.header.args.keyword.spanless_eq(&other.header.args.keyword) + } +} + +impl SpanlessEq<Expression> for Expression { + fn spanless_eq(&self, other: &Expression) -> bool { + match (self, other) { + (Expression::Tuple(a), Expression::Tuple(b)) => a.spanless_eq(b), + (Expression::Object(a), Expression::Object(b)) => a.spanless_eq(b), + (a, b) => a == b, + } + } +} + +impl SpanlessEq<Tuple> for Tuple { + fn spanless_eq(&self, other: &Tuple) -> bool { + self.items.len() == other.items.len() + && self.items.iter().zip(&other.items) + .all(|(x, y)| x.v.spanless_eq(&y.v)) + } +} + +impl SpanlessEq<Object> for Object { + fn spanless_eq(&self, other: &Object) -> bool { + self.pairs.len() == other.pairs.len() + && self.pairs.iter().zip(&other.pairs) + .all(|(x, y)| x.key.v == y.key.v && x.value.v.spanless_eq(&y.value.v)) + } +} |
