How to add arrows between equation lines, like latex `witharrows`, in Typst?

Hello everyone!
I’ve been using typst for quite a while now, and there is always this one thing that i can’t do thats bugging me.

In latex there is a package called witharrows that lets you write equations and have arrows explainging what i did to get from the first equation to the seconed, but in typst no matter how hard i tried i just couldn’t find how to do it.

This is what im talking about:

\usepackage{witharrows}

\[
	\begin{WithArrows}
		x^2+5x&=2x^2\Arrow{\( -2x^2 \)}\\
		-x^2 +5x&=0\Arrow{Quadratic Formula}\\
		x_{1,2}&=\frac{-5 \pm \sqrt{25}}{-2}\Arrow{Simplify}\\
		x_{1,2}&=0,5
	\end{WithArrows}
\]

which gives me this:

Do you know how to do this in typst?

Hi @Michael_Ronin,

Would something like pinit – Typst Universe do the trick for you?

Or else… Search — Typst: Universe

I have tried to use pinit but i just couldn’t get it to do what i needed.

It will be really helpful if you’ll create the provided example with it so that i could see how to use it.

Thanks for the reply :grinning:

Here is an implementation in typst. It uses the smart querying that I learned/came up with after discussion in issue #2425. It uses two functions, mimicing the original, witharrows() and explain()


/// A "with arrows" environment
/// Adding arrows with explanations between lines.
/// The equation must be multiline and every line, including the last one, must
/// be terminated with a linebreak (backslash).
///
/// Explanation arrows are inserted using the `explain` function.
///
/// by Ulrik Sverdrup "bluss", dual licensed MIT/Apache-2.0
#let witharrows(eq, pad-x: 1em, stroke: 0.06em, arrowhead-scale: 1) = {
  show linebreak: it => [#metadata(none)<_witharrows_linebreak>] + it
  context {
    let parts = query(
      selector.or(<_witharrows_linebreak>, <_witharrows_explain>)
        .after(here())
        .before(selector(<_witharrows_end>).after(here())),
    )
    let lines = ()
    let explanations = ()
    for part in parts {
      // part is either a marked linebreak
      // or an explanation
      if part.label == <_witharrows_linebreak> {
        lines.push(part.location().position())
      } else if part.label == <_witharrows_explain> {
        explanations.push((line: lines.len(), expr: part.value))
      } else {
        panic("unknown part: " + repr(part))
      }
    }
    let max-x = calc.max(0pt, ..lines.map(elt => elt.x))
    eq
    context {
      let pos = here().position()
      let lineheight = 1em
      for expl in explanations {
        let dx = max-x - pos.x + pad-x
        if expl.line + 1 >= lines.len() {
          panic("Missing linebreak: every equation line must be terminated by linebreak")
        }
        let starty = lines.at(expl.line).y
        let endy = lines.at(expl.line + 1).y
        let midy = (starty + endy) / 2
        let mdy = midy - pos.y - lineheight / 2
        let sdy = starty - pos.y - lineheight / 4
        let edy = endy - pos.y - lineheight / 4

        box(place(top + left, dx: dx + pad-x, dy: mdy, math.equation(expl.expr)))

        let curve-pad = 0.15em
        let curve-len = edy - sdy - curve-pad * 2
        let cont-y = curve-len * 0.3 // control point lengths
        let cont-x = curve-len * 0.2
        let arrow-unit = 0.12em * arrowhead-scale

        // Draw arrow in pure typst to simplify
        box(place(top + left, dx: dx, dy: sdy + curve-pad, {
          curve(
            stroke: stroke,
            curve.cubic((cont-x, cont-y), (cont-x, curve-len - cont-y), (0pt, curve-len)),
            curve.move((0pt, curve-len)),
            curve.line((2 * arrow-unit, curve-len - arrow-unit)),
            curve.line((0pt, curve-len)),
            curve.line((0pt, curve-len - arrow-unit * 2.15)),
          )
        }))
      }
    }
  }
  [#metadata(none)<_witharrows_end>]
}

#let explain(expr) = {
  [#metadata(expr)<_witharrows_explain>]
}


= Witharrow Examples

#show math.equation.where(block: true): set align(left)

#witharrows($
    x^2 + 5 x & = 2x^2 explain(-2x^2) \
  -x^2 + 5 x & = 0 explain("Quadratic Formula") \
      x_(1,2) & = (-5 plus.minus sqrt(25)) / (-2) explain("Simplify") \
      x_(1,2) & = 0,5 \
$)

(Edited/Updated: This is version 2 or the first version in git)

This recipe is hosted at witharrow · main · bluss / typst-recipes · GitLab and for now, check in there for future bugfixes and updates, if any.

2 Likes

Here is a semi-automated version using mannot:

#import "@preview/mannot:0.3.0": annot-cetz, mark
#import "@preview/cetz:0.4.1"

#let bent-arrow(from, to, body, bend: 0.1, shrink: 0.04) = {
  import cetz.draw: arc-through, content, set-style
  let mid = (rel: (bend, 0), to: (from, 50%, to))
  set-style(mark: (end: ">", fill: black, scale: 0.5))
  arc-through((rel: (0, -shrink), to: from), mid, (rel: (0, shrink), to: to))
  content(mid, anchor: "west", pad(0.5em, body))
}

#let mark-line(tag) = mark(tag: tag)[]

#let arrow(from, to, body) = bent-arrow(from, to, body)

#let arrow-coords(ctx, ..coords) = {
  let (_, ..coords) = cetz.coordinate.resolve(ctx, ..coords)
  let right-most = calc.max(..coords.map(v => v.first())) + 0.2
  coords.map(v => (right-most, v.at(1)))
}

$
   x^2 + 5x & = 2x^2 #mark-line(<a>) \
  -x^2 + 5x & = 0 #mark-line(<b>) \
    x_(1,2) & = (-5 plus.minus sqrt(25)) / (-2) #mark-line(<c>) \
    x_(1,2) & = 0, 5 #mark-line(<d>) \
  #annot-cetz((<a>, <b>, <c>, <d>), cetz, cetz.draw.get-ctx(ctx => {
    let (a, b, c, d) = arrow-coords(ctx, "a", "b", "c", "d")
    arrow(a, b)[$-2x^2$]
    arrow(b, c)[Quadratic Formula]
    arrow(c, d)[Simplify]
  }))
$

A fully automated version would require more code, and less verbose syntax — even more code. Latex witharrows in typst - #4 by bluss does give a somewhat 1 to 1 solution.

2 Likes

Wow!
You two are really good with typst…
I have no idea whats going on with the code but it works so thank you very much, I really appreciate it.

You can read the mannot docs. Should explain most of it. The rest is just layers of abstractions to make it more concise and readable. The main issue is that Typst doesn’t have native arrows, and cetz doesn’t have the customization of marks and lines like fletcher does. But mannot is only integrated with cetz, not fletcher. So the lines are made with manual arcs and the arrows are not exactly properly rotated/skewed.

If you found a solution, don’t forget to mark it.

1 Like

I have updated and bugfixed my example. Out of pure stubbornness (maybe) I drew the arrows in pure typst, because it’s not easy to do natively with mannot, and inserting a cetz diagram on top is also not straightforward.

The example also has a home until further notice at witharrow · main · bluss / typst-recipes · GitLab

That is really nice, but i have a follow up question.
When i try to use a template with this code (both your original one and the new one) suddenly all of the things inside the explain function disappear and i’m left only with the arrows.
do you know how to fix it?

The template is incompatible then, for some reason. Maybe this witharrows function is incompatible with equate for example, I haven’t tried it. It would be best if you could share some short code that reproduces the problem.

hmmm, seems like it happens because my language is set to an rtl language (and my template was in rtl), any idea why this would matter?

Don’t know actually. The equations still look the same way, the equations are LTR, or how do you do it?

just adding #set text(dir: rtl) in the beggining of the example file breaks it:
rtl:

ltr (normal)

I see. I don’t see how rtl can be supported without rewriting it completely. Using the position of the linebreak doesn’t seem to work so well (the linebreak is placed on the right hand side of the line in the equation, but I think for RTL we should/have to place the arrows on the left side of the equation line.), so I would recommend using mannot and Andrew’s code in that case.

I’m not sure what’s going on. You could work around it by inserting this:

      set text(dir: ltr)
      let pos = here().position()

Just before that let pos = (existing line) in the start of the innermost context. That works around it. It will work with equations that have enough space on the left side to show the arrows (so they should be centered or left aligned). It’s clear that the rest of the absolute position-based code doesn’t know how to handle this case (and neither do I, haven’t worked with it).

Amazing, it works.
Thank you very much.