diff --git a/Cargo.lock b/Cargo.lock index e429f4676..1c157a688 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,6 +902,7 @@ dependencies = [ "pdf-writer", "pico-args", "pixglyph", + "regex", "resvg", "roxmltree", "rustybuzz", diff --git a/Cargo.toml b/Cargo.toml index 1dbe7450a..2bf6c58c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ serde = { version = "1", features = ["derive"] } typed-arena = "2" parking_lot = "0.12" unscanny = { git = "https://github.com/typst/unscanny" } +regex = "1" # Text and font handling hypher = "0.1" diff --git a/src/eval/methods.rs b/src/eval/methods.rs index b3674dff8..363474fb5 100644 --- a/src/eval/methods.rs +++ b/src/eval/methods.rs @@ -1,8 +1,9 @@ //! Methods on values. -use super::{Args, StrExt, Value}; +use super::{Args, Regex, StrExt, Value}; use crate::diag::{At, TypResult}; use crate::syntax::Span; +use crate::util::EcoString; use crate::Context; /// Call a method on a value. @@ -66,6 +67,19 @@ pub fn call( _ => missing()?, }, + Value::Dyn(dynamic) => { + if let Some(regex) = dynamic.downcast::() { + match method { + "matches" => { + Value::Bool(regex.matches(&args.expect::("text")?)) + } + _ => missing()?, + } + } else { + missing()? + } + } + _ => missing()?, }; diff --git a/src/eval/str.rs b/src/eval/str.rs index 3b4349a16..514bf318f 100644 --- a/src/eval/str.rs +++ b/src/eval/str.rs @@ -1,3 +1,7 @@ +use std::fmt::{self, Debug, Formatter}; +use std::hash::{Hash, Hasher}; +use std::ops::Deref; + use super::{Array, Value}; use crate::diag::StrResult; use crate::util::EcoString; @@ -35,3 +39,45 @@ impl StrExt for EcoString { } } } + +/// A regular expression. +#[derive(Clone)] +pub struct Regex(regex::Regex); + +impl Regex { + /// Create a new regex. + pub fn new(re: &str) -> StrResult { + regex::Regex::new(re).map(Self).map_err(|err| err.to_string()) + } + + /// Whether the regex matches the given `text`. + pub fn matches(&self, text: &str) -> bool { + self.0.is_match(text) + } +} + +impl Deref for Regex { + type Target = regex::Regex; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Debug for Regex { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "regex({:?})", self.0.as_str()) + } +} + +impl PartialEq for Regex { + fn eq(&self, other: &Self) -> bool { + self.0.as_str() == other.0.as_str() + } +} + +impl Hash for Regex { + fn hash(&self, state: &mut H) { + self.0.as_str().hash(state); + } +} diff --git a/src/eval/value.rs b/src/eval/value.rs index ba30348fe..fc54cbceb 100644 --- a/src/eval/value.rs +++ b/src/eval/value.rs @@ -5,7 +5,7 @@ use std::hash::{Hash, Hasher}; use std::num::NonZeroUsize; use std::sync::Arc; -use super::{ops, Args, Array, Dict, Func, RawLength}; +use super::{ops, Args, Array, Dict, Func, RawLength, Regex}; use crate::diag::{with_alternative, StrResult}; use crate::geom::{ Angle, Color, Dir, Em, Fraction, Length, Paint, Ratio, Relative, RgbaColor, Sides, @@ -641,6 +641,10 @@ dynamic! { Dir: "direction", } +dynamic! { + Regex: "regular expression", +} + castable! { usize, Expected: "non-negative integer", @@ -686,8 +690,10 @@ castable! { castable! { Pattern, - Expected: "function", + Expected: "function, string or regular expression", Value::Func(func) => Pattern::Node(func.node()?), + Value::Str(text) => Pattern::text(&text), + @regex: Regex => Pattern::Regex(regex.clone()), } #[cfg(test)] diff --git a/src/library/mod.rs b/src/library/mod.rs index e90e5cc4f..eeda620d2 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -83,6 +83,7 @@ pub fn new() -> Scope { std.def_fn("cmyk", utility::cmyk); std.def_fn("repr", utility::repr); std.def_fn("str", utility::str); + std.def_fn("regex", utility::regex); std.def_fn("lower", utility::lower); std.def_fn("upper", utility::upper); std.def_fn("letter", utility::letter); diff --git a/src/library/utility/blind.rs b/src/library/utility/blind.rs deleted file mode 100644 index 0075ab91f..000000000 --- a/src/library/utility/blind.rs +++ /dev/null @@ -1,9 +0,0 @@ -use lipsum::lipsum_from_seed; - -use crate::library::prelude::*; - -/// Create blind text. -pub fn lorem(_: &mut Context, args: &mut Args) -> TypResult { - let words: usize = args.expect("number of words")?; - Ok(Value::Str(lipsum_from_seed(words, 97).into())) -} diff --git a/src/library/utility/mod.rs b/src/library/utility/mod.rs index 4244ccbf1..13220242b 100644 --- a/src/library/utility/mod.rs +++ b/src/library/utility/mod.rs @@ -1,11 +1,9 @@ //! Computational utility functions. -mod blind; mod color; mod math; mod string; -pub use blind::*; pub use color::*; pub use math::*; pub use string::*; diff --git a/src/library/utility/string.rs b/src/library/utility/string.rs index 92d80be2c..0b309b318 100644 --- a/src/library/utility/string.rs +++ b/src/library/utility/string.rs @@ -1,3 +1,6 @@ +use lipsum::lipsum_from_seed; + +use crate::eval::Regex; use crate::library::prelude::*; use crate::library::text::{Case, TextNode}; @@ -37,6 +40,18 @@ fn case(case: Case, args: &mut Args) -> TypResult { }) } +/// Create blind text. +pub fn lorem(_: &mut Context, args: &mut Args) -> TypResult { + let words: usize = args.expect("number of words")?; + Ok(Value::Str(lipsum_from_seed(words, 97).into())) +} + +/// Create a regular expression. +pub fn regex(_: &mut Context, args: &mut Args) -> TypResult { + let Spanned { v, span } = args.expect::>("regular expression")?; + Ok(Regex::new(&v).at(span)?.into()) +} + /// Converts an integer into one or multiple letters. pub fn letter(_: &mut Context, args: &mut Args) -> TypResult { convert(Numbering::Letter, args) diff --git a/src/model/content.rs b/src/model/content.rs index 70205acc0..6956d3806 100644 --- a/src/model/content.rs +++ b/src/model/content.rs @@ -366,12 +366,19 @@ impl<'a, 'ctx> Builder<'a, 'ctx> { } fn accept(&mut self, content: &'a Content, styles: StyleChain<'a>) -> TypResult<()> { - // Handle special content kinds. match content { Content::Empty => return Ok(()), + Content::Text(text) => { + if let Some(realized) = styles.apply(self.ctx, Target::Text(text))? { + let stored = self.scratch.templates.alloc(realized); + return self.accept(stored, styles); + } + } + Content::Show(node, _) => return self.show(node, styles), Content::Styled(styled) => return self.styled(styled, styles), Content::Sequence(seq) => return self.sequence(seq, styles), + _ => {} } diff --git a/src/model/recipe.rs b/src/model/recipe.rs index f6adf4a50..48e7a22e1 100644 --- a/src/model/recipe.rs +++ b/src/model/recipe.rs @@ -2,7 +2,7 @@ use std::fmt::{self, Debug, Formatter}; use super::{Content, Interruption, NodeId, Show, ShowNode, StyleEntry}; use crate::diag::{At, TypResult}; -use crate::eval::{Args, Func, Value}; +use crate::eval::{Args, Func, Regex, Value}; use crate::library::structure::{EnumNode, ListNode}; use crate::syntax::Span; use crate::Context; @@ -23,6 +23,7 @@ impl Recipe { pub fn applicable(&self, target: Target) -> bool { match (&self.pattern, target) { (Pattern::Node(id), Target::Node(node)) => *id == node.id(), + (Pattern::Regex(_), Target::Text(_)) => true, _ => false, } } @@ -43,6 +44,31 @@ impl Recipe { })? } + (Target::Text(text), Pattern::Regex(regex)) => { + let mut result = vec![]; + let mut cursor = 0; + + for mat in regex.find_iter(text) { + let start = mat.start(); + if cursor < start { + result.push(Content::Text(text[cursor .. start].into())); + } + + result.push(self.call(ctx, || Value::Str(mat.as_str().into()))?); + cursor = mat.end(); + } + + if result.is_empty() { + return Ok(None); + } + + if cursor < text.len() { + result.push(Content::Text(text[cursor ..].into())); + } + + Content::sequence(result) + } + _ => return Ok(None), }; @@ -86,6 +112,15 @@ impl Debug for Recipe { pub enum Pattern { /// Defines the appearence of some node. Node(NodeId), + /// Defines text to be replaced. + Regex(Regex), +} + +impl Pattern { + /// Define a simple text replacement pattern. + pub fn text(text: &str) -> Self { + Self::Regex(Regex::new(®ex::escape(text)).unwrap()) + } } /// A target for a show rule recipe. @@ -93,6 +128,8 @@ pub enum Pattern { pub enum Target<'a> { /// A showable node. Node(&'a ShowNode), + /// A slice of text. + Text(&'a str), } /// Identifies a show rule recipe. diff --git a/tests/ref/graphics/shape-rect.png b/tests/ref/graphics/shape-rect.png index 5bbaf3db7..5ba78f97e 100644 Binary files a/tests/ref/graphics/shape-rect.png and b/tests/ref/graphics/shape-rect.png differ diff --git a/tests/ref/style/show-text.png b/tests/ref/style/show-text.png new file mode 100644 index 000000000..53c9d1321 Binary files /dev/null and b/tests/ref/style/show-text.png differ diff --git a/tests/typ/graphics/shape-rect.typ b/tests/typ/graphics/shape-rect.typ index a29550b52..5495da8c8 100644 --- a/tests/typ/graphics/shape-rect.typ +++ b/tests/typ/graphics/shape-rect.typ @@ -41,16 +41,16 @@ --- // Outset padding. +#set raw(lang: "rust") #show node: raw as [ - #set text("IBM Plex Mono", 8pt) - #h(.7em, weak: true) - #rect(radius: 3pt, outset: (y: 3pt, x: 2.5pt), fill: rgb(239, 241, 243))[{node.text}] - #h(.7em, weak: true) + #set text(8pt) + #h(5.6pt, weak: true) + #rect(radius: 3pt, outset: (y: 3pt, x: 2.5pt), fill: rgb(239, 241, 243), node) + #h(5.6pt, weak: true) ] -Use the `*const ptr` pointer. +Use the `*const T` pointer or the `&mut T` reference. --- // Error: 15-38 unexpected key "cake" #rect(radius: (left: 10pt, cake: 5pt)) - diff --git a/tests/typ/style/show-node.typ b/tests/typ/style/show-node.typ index 4fcee9aa4..07c30f19d 100644 --- a/tests/typ/style/show-node.typ +++ b/tests/typ/style/show-node.typ @@ -62,6 +62,10 @@ Another text. // Error: 10-15 this function cannot be customized with show #show _: upper as {} +--- +// Error: 7-10 expected function, string or regular expression, found color +#show red as [] + --- // Error: 2-16 set, show and wrap are only allowed directly in markup {show list as a} diff --git a/tests/typ/style/show-recursive.typ b/tests/typ/style/show-recursive.typ index 423bbd80b..9e93739c2 100644 --- a/tests/typ/style/show-recursive.typ +++ b/tests/typ/style/show-recursive.typ @@ -43,7 +43,7 @@ --- // Test multi-recursion with nested lists. -#set rect(padding: 2pt) +#set rect(inset: 2pt) #show v: list as rect(stroke: blue, v) #show v: list as rect(stroke: red, v) diff --git a/tests/typ/style/show-text.typ b/tests/typ/style/show-text.typ new file mode 100644 index 000000000..3bc116f52 --- /dev/null +++ b/tests/typ/style/show-text.typ @@ -0,0 +1,58 @@ +// Test text replacement show rules. + +--- +// Test classic example. +#set text("Roboto") +#show phrase: "Der Spiegel" as text(smallcaps: true, [#phrase]) +Die Zeitung Der Spiegel existiert. + +--- +// Another classic example. +#show "TeX" as [T#h(-0.145em)#move(dy: 0.233em)[E]#h(-0.135em)X] +#show name: regex("(Lua)?(La)?TeX") as box(text("Latin Modern Roman")[#name]) + +TeX, LaTeX, LuaTeX and LuaLaTeX! + +--- +// Test out-of-order guarding. +#show "Good" as [Typst!] +#show "Typst" as [Fun!] +#show "Fun" as [Good!] +#show enum as [] + +Good \ +Fun \ +Typst \ + +--- +// Test that replacements happen exactly once. +#show "A" as [BB] +#show "B" as [CC] +AA (8) + +--- +// Test caseless match and word boundaries. +#show regex("(?i)\bworld\b") as [🌍] + +Treeworld, the World of worlds, is a world. + +--- +// This is a fun one. +#set par(justify: true) +#show letter: regex("\S") as rect(inset: 2pt)[#upper(letter)] +#lorem(5) + +--- +// See also: https://github.com/mTvare6/hello-world.rs +#show it: regex("(?i)rust") as [#it (🚀)] +Rust is memory-safe and blazingly fast. Let's rewrite everything in rust. + +--- +// Replace worlds but only in lists. +#show node: list as [ + #show "World" as [🌎] + #node +] + +World +- World diff --git a/tests/typ/utility/regex.typ b/tests/typ/utility/regex.typ new file mode 100644 index 000000000..4cc7d1ea3 --- /dev/null +++ b/tests/typ/utility/regex.typ @@ -0,0 +1,10 @@ +// Test regexes. +// Ref: false + +--- +{ + let re = regex("(La)?TeX") + test(re.matches("La"), false) + test(re.matches("TeX"), true) + test(re.matches("LaTeX"), true) +}