diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs index 0bd88501c..f9b95615a 100644 --- a/crates/typst-library/src/lib.rs +++ b/crates/typst-library/src/lib.rs @@ -120,6 +120,7 @@ fn items() -> LangItems { } elem.pack() }, + math_primes: |count| math::PrimesElem::new(count).pack(), math_accent: |base, accent| { math::AccentElem::new(base, math::Accent::new(accent)).pack() }, diff --git a/crates/typst-library/src/math/attach.rs b/crates/typst-library/src/math/attach.rs index fedeb9088..ee65b657c 100644 --- a/crates/typst-library/src/math/attach.rs +++ b/crates/typst-library/src/math/attach.rs @@ -84,6 +84,61 @@ impl LayoutMath for AttachElem { } } +/// Grouped primes. +/// +/// ## Example { #example } +/// ```example +/// $ a'''_b = a^'''_b $ +/// ``` +/// +/// ## Syntax +/// This function has dedicated syntax: use apostrophes instead of primes. They +/// will automatically attach to the previous element, moving superscripts to +/// the next level. +/// +/// Display: Attachment +/// Category: math +#[element(LayoutMath)] +pub struct PrimesElem { + /// The number of grouped primes. + #[required] + pub count: usize, +} + +impl LayoutMath for PrimesElem { + #[tracing::instrument(skip(ctx))] + fn layout_math(&self, ctx: &mut MathContext) -> SourceResult<()> { + match self.count() { + count @ 1..=4 => { + let f = ctx.layout_fragment(&TextElem::packed(match count { + 1 => '′', + 2 => '″', + 3 => '‴', + 4 => '⁗', + _ => unreachable!(), + }))?; + ctx.push(f); + } + count => { + // Custom amount of primes + let prime = ctx.layout_fragment(&TextElem::packed('′'))?.into_frame(); + let width = prime.width() * (count + 1) as f64 / 2.0; + let mut frame = Frame::new(Size::new(width, prime.height())); + frame.set_baseline(prime.ascent()); + + for i in 0..count { + frame.push_frame( + Point::new(prime.width() * (i as f64 / 2.0), Abs::zero()), + prime.clone(), + ) + } + ctx.push(FrameFragment::new(ctx, frame)); + } + } + Ok(()) + } +} + /// Forces a base to display attachments as scripts. /// /// ## Example { #example } diff --git a/crates/typst/src/eval/library.rs b/crates/typst/src/eval/library.rs index 1b05de838..dcd78b89f 100644 --- a/crates/typst/src/eval/library.rs +++ b/crates/typst/src/eval/library.rs @@ -96,6 +96,8 @@ pub struct LangItems { tr: Option, br: Option, ) -> Content, + /// Grouped primes: `a'''`. + pub math_primes: fn(count: usize) -> Content, /// A base with an accent: `arrow(x)`. pub math_accent: fn(base: Content, accent: char) -> Content, /// A fraction in math: `x/2`. diff --git a/crates/typst/src/eval/mod.rs b/crates/typst/src/eval/mod.rs index 97cad97db..b76765e6e 100644 --- a/crates/typst/src/eval/mod.rs +++ b/crates/typst/src/eval/mod.rs @@ -460,6 +460,7 @@ impl Eval for ast::Expr { Self::MathAlignPoint(v) => v.eval(vm).map(Value::Content), Self::MathDelimited(v) => v.eval(vm).map(Value::Content), Self::MathAttach(v) => v.eval(vm).map(Value::Content), + Self::MathPrimes(v) => v.eval(vm).map(Value::Content), Self::MathFrac(v) => v.eval(vm).map(Value::Content), Self::MathRoot(v) => v.eval(vm).map(Value::Content), Self::Ident(v) => v.eval(vm), @@ -733,12 +734,28 @@ impl Eval for ast::MathAttach { #[tracing::instrument(name = "MathAttach::eval", skip_all)] fn eval(&self, vm: &mut Vm) -> SourceResult { let base = self.base().eval_display(vm)?; - let top = self.top().map(|expr| expr.eval_display(vm)).transpose()?; + + let mut top = self.top().map(|expr| expr.eval_display(vm)).transpose()?; + if top.is_none() { + if let Some(primes) = self.primes() { + top = Some(primes.eval(vm)?); + } + } + let bottom = self.bottom().map(|expr| expr.eval_display(vm)).transpose()?; Ok((vm.items.math_attach)(base, top, bottom, None, None, None, None)) } } +impl Eval for ast::MathPrimes { + type Output = Content; + + #[tracing::instrument(name = "MathPrimes::eval", skip_all)] + fn eval(&self, vm: &mut Vm) -> SourceResult { + Ok((vm.items.math_primes)(self.count())) + } +} + impl Eval for ast::MathFrac { type Output = Content; diff --git a/crates/typst/src/ide/highlight.rs b/crates/typst/src/ide/highlight.rs index 2db636e32..c9748e922 100644 --- a/crates/typst/src/ide/highlight.rs +++ b/crates/typst/src/ide/highlight.rs @@ -147,6 +147,7 @@ pub fn highlight(node: &LinkedNode) -> Option { SyntaxKind::MathAttach => None, SyntaxKind::MathFrac => None, SyntaxKind::MathRoot => None, + SyntaxKind::MathPrimes => None, SyntaxKind::Hashtag => highlight_hashtag(node), SyntaxKind::LeftBrace => Some(Tag::Punctuation), @@ -174,6 +175,7 @@ pub fn highlight(node: &LinkedNode) -> Option { _ => Tag::Operator, }), SyntaxKind::Hat => Some(Tag::MathOperator), + SyntaxKind::Prime => Some(Tag::MathOperator), SyntaxKind::Dot => Some(Tag::Punctuation), SyntaxKind::Eq => match node.parent_kind() { Some(SyntaxKind::Heading) => None, diff --git a/crates/typst/src/syntax/ast.rs b/crates/typst/src/syntax/ast.rs index 7d5e2989c..4a0de424d 100644 --- a/crates/typst/src/syntax/ast.rs +++ b/crates/typst/src/syntax/ast.rs @@ -124,6 +124,8 @@ pub enum Expr { MathDelimited(MathDelimited), /// A base with optional attachments in math: `a_1^2`. MathAttach(MathAttach), + /// Grouped math primes + MathPrimes(MathPrimes), /// A fraction in math: `x/2`. MathFrac(MathFrac), /// A root in math: `√x`, `∛x` or `∜x`. @@ -224,6 +226,7 @@ impl AstNode for Expr { SyntaxKind::MathAlignPoint => node.cast().map(Self::MathAlignPoint), SyntaxKind::MathDelimited => node.cast().map(Self::MathDelimited), SyntaxKind::MathAttach => node.cast().map(Self::MathAttach), + SyntaxKind::MathPrimes => node.cast().map(Self::MathPrimes), SyntaxKind::MathFrac => node.cast().map(Self::MathFrac), SyntaxKind::MathRoot => node.cast().map(Self::MathRoot), SyntaxKind::Ident => node.cast().map(Self::Ident), @@ -285,6 +288,7 @@ impl AstNode for Expr { Self::MathAlignPoint(v) => v.as_untyped(), Self::MathDelimited(v) => v.as_untyped(), Self::MathAttach(v) => v.as_untyped(), + Self::MathPrimes(v) => v.as_untyped(), Self::MathFrac(v) => v.as_untyped(), Self::MathRoot(v) => v.as_untyped(), Self::Ident(v) => v.as_untyped(), @@ -841,6 +845,25 @@ impl MathAttach { .skip_while(|node| !matches!(node.kind(), SyntaxKind::Hat)) .find_map(SyntaxNode::cast) } + + /// Extract primes if present. + pub fn primes(&self) -> Option { + self.0.cast_first_match() + } +} + +node! { + /// Grouped primes in math: `a'''`. + MathPrimes +} + +impl MathPrimes { + pub fn count(&self) -> usize { + self.0 + .children() + .filter(|node| matches!(node.kind(), SyntaxKind::Prime)) + .count() + } } node! { diff --git a/crates/typst/src/syntax/kind.rs b/crates/typst/src/syntax/kind.rs index 0c24c6674..491197208 100644 --- a/crates/typst/src/syntax/kind.rs +++ b/crates/typst/src/syntax/kind.rs @@ -65,6 +65,8 @@ pub enum SyntaxKind { MathDelimited, /// A base with optional attachments in math: `a_1^2`. MathAttach, + /// Grouped primes in math: `a'''`. + MathPrimes, /// A fraction in math: `x/2`. MathFrac, /// A root in math: `√x`, `∛x` or `∜x`. @@ -108,6 +110,8 @@ pub enum SyntaxKind { Slash, /// The superscript operator in math: `^`. Hat, + /// The prime in math: `'`. + Prime, /// The field access and method call operator: `.`. Dot, /// The assignment operator: `=`. @@ -378,6 +382,7 @@ impl SyntaxKind { Self::MathAttach => "math attachments", Self::MathFrac => "math fraction", Self::MathRoot => "math root", + Self::MathPrimes => "math primes", Self::Hashtag => "hashtag", Self::LeftBrace => "opening brace", Self::RightBrace => "closing brace", @@ -395,6 +400,7 @@ impl SyntaxKind { Self::Minus => "minus", Self::Slash => "slash", Self::Hat => "hat", + Self::Prime => "prime", Self::Dot => "dot", Self::Eq => "equals sign", Self::EqEq => "equality operator", diff --git a/crates/typst/src/syntax/lexer.rs b/crates/typst/src/syntax/lexer.rs index d95b5b7b8..8fe08f4cc 100644 --- a/crates/typst/src/syntax/lexer.rs +++ b/crates/typst/src/syntax/lexer.rs @@ -422,13 +422,14 @@ impl Lexer<'_> { '|' if self.s.eat_if('|') => SyntaxKind::Shorthand, '~' if self.s.eat_if("~>") => SyntaxKind::Shorthand, '~' if self.s.eat_if('>') => SyntaxKind::Shorthand, - '*' | '\'' | '-' => SyntaxKind::Shorthand, + '*' | '-' => SyntaxKind::Shorthand, '#' => SyntaxKind::Hashtag, '_' => SyntaxKind::Underscore, '$' => SyntaxKind::Dollar, '/' => SyntaxKind::Slash, '^' => SyntaxKind::Hat, + '\'' => SyntaxKind::Prime, '&' => SyntaxKind::MathAlignPoint, '√' | '∛' | '∜' => SyntaxKind::Root, diff --git a/crates/typst/src/syntax/parser.rs b/crates/typst/src/syntax/parser.rs index bd78df20f..5cb31e5ed 100644 --- a/crates/typst/src/syntax/parser.rs +++ b/crates/typst/src/syntax/parser.rs @@ -295,6 +295,18 @@ fn math_expr_prec(p: &mut Parser, min_prec: usize, stop: SyntaxKind) { } } + SyntaxKind::Prime => { + // Means that there is nothing to attach the prime to. + continuable = true; + while p.at(SyntaxKind::Prime) { + let m2 = p.marker(); + p.eat(); + // Eat the group until the space. + while p.eat_if_direct(SyntaxKind::Prime) {} + p.wrap(m2, SyntaxKind::MathPrimes); + } + } + _ => p.expected("expression"), } @@ -306,6 +318,9 @@ fn math_expr_prec(p: &mut Parser, min_prec: usize, stop: SyntaxKind) { p.wrap(m, SyntaxKind::Math); } + // Whether there were _any_ primes in the loop. + let mut primed = false; + while !p.eof() && !p.at(stop) { if p.directly_at(SyntaxKind::Text) && p.current_text() == "!" { p.eat(); @@ -313,10 +328,39 @@ fn math_expr_prec(p: &mut Parser, min_prec: usize, stop: SyntaxKind) { continue; } + let prime_marker = p.marker(); + if p.eat_if_direct(SyntaxKind::Prime) { + // Eat as many primes as possible. + while p.eat_if_direct(SyntaxKind::Prime) {} + p.wrap(prime_marker, SyntaxKind::MathPrimes); + + // Will not be continued, so need to wrap the prime as attachment. + if p.at(stop) { + p.wrap(m, SyntaxKind::MathAttach); + } + + primed = true; + continue; + } + + // Separate primes and superscripts to different attachments. + if primed && p.current() == SyntaxKind::Hat { + p.wrap(m, SyntaxKind::MathAttach); + } + let Some((kind, stop, assoc, mut prec)) = math_op(p.current()) else { + // No attachments, so we need to wrap primes as attachment. + if primed { + p.wrap(m, SyntaxKind::MathAttach); + } + break; }; + if primed && kind == SyntaxKind::MathFrac { + p.wrap(m, SyntaxKind::MathAttach); + } + if prec < min_prec { break; } @@ -335,7 +379,7 @@ fn math_expr_prec(p: &mut Parser, min_prec: usize, stop: SyntaxKind) { math_expr_prec(p, prec, stop); math_unparen(p, m2); - if p.eat_if(SyntaxKind::Underscore) || p.eat_if(SyntaxKind::Hat) { + if p.eat_if(SyntaxKind::Underscore) || (!primed && p.eat_if(SyntaxKind::Hat)) { let m3 = p.marker(); math_expr_prec(p, prec, SyntaxKind::Eof); math_unparen(p, m3); @@ -1451,6 +1495,10 @@ impl<'s> Parser<'s> { self.current == kind && self.prev_end == self.current_start } + /// Eats if at `kind`. + /// + /// Note: In math and code mode, this will ignore trivia in front of the + /// `kind`, To forbid skipping trivia, consider using `eat_if_direct`. fn eat_if(&mut self, kind: SyntaxKind) -> bool { let at = self.at(kind); if at { @@ -1459,6 +1507,15 @@ impl<'s> Parser<'s> { at } + /// Eats only if currently at the start of `kind`. + fn eat_if_direct(&mut self, kind: SyntaxKind) -> bool { + let at = self.directly_at(kind); + if at { + self.eat(); + } + at + } + fn convert(&mut self, kind: SyntaxKind) { self.current = kind; self.eat(); diff --git a/tests/ref/math/opticalsize.png b/tests/ref/math/opticalsize.png index 9fec5520b..5c338ab6e 100644 Binary files a/tests/ref/math/opticalsize.png and b/tests/ref/math/opticalsize.png differ diff --git a/tests/ref/math/syntax.png b/tests/ref/math/syntax.png index 028e21d6b..d828a478d 100644 Binary files a/tests/ref/math/syntax.png and b/tests/ref/math/syntax.png differ diff --git a/tests/typ/math/opticalsize.typ b/tests/typ/math/opticalsize.typ index c96e4a325..6edd24193 100644 --- a/tests/typ/math/opticalsize.typ +++ b/tests/typ/math/opticalsize.typ @@ -28,3 +28,18 @@ $sum_(k in NN)^prime 1/k^2$ $ 1/(x^A) $ #[#set text(size:18pt); $1/(x^A)$] vs. #[#set text(size:14pt); $x^A$] +--- +// Test dedicated syntax for primes +$a'$, $a'''_b$, $'$, $'''''''$ + +--- +// Test spaces between +$a' ' '$, $' ' '$, $a' '/b$ + +--- +// Test complex prime combilnations +$a'_b^c$, $a_b'^c$, $a_b^c'$, $a_b'^c'^d'$ + +$(a'_b')^(c'_d')$, $a'/b'$, $a_b'/c_d'$ + +$∫'$, $∑'$, $ ∑'_S' $