How do I calculate the width of content without having context everywhere?

I’m working on a package with displays a syntax tree using cetz, but I’m having trouble calculating the width of the leaf nodes. Currently it only works for strings with an approximation of label.len() / 7 + 0.5 which has worked surprisingly well, but I want to do better.

This is what I’ve been trying:

let width(node) = context {
  let (_label, children) = node
  if type(children) == content {
    measure(children).width.cm
  } else if type(children) == array {
    children.map(width).sum()
  }
}

But I’m getting Assertion failed: Incorrect type for body: content from cetz.

For reference the broader function and where it’s called from is

let tree-recursive(node, id: (0,), x: 0, y: 0) = context {
  let wid = width(node)
  ...
  // show label with cetz.draw.content
  ...
  // call recursively on children with modified coords
}
cetz.canvas(tree-recursive(node))

Any ideas on how to solve this? My instinct is that I shouldn’t need this much context, but I don’t know how to get any better an approximation without using it.

You need context, but not that much. I suspect that this will basically work if you put the whole CeTZ canvas into the context, and don’t use context anywhere else.

this function would not return a length but content (and children.map(width) would result in an array of contents instead of an array of lengths as well). To learn more about the reasons, take a look at Why is the value I receive from context always content? - #2 by laurmaedje

Since each context expression results in opaque content, everything that depends on the non-opaque values within the block needs to be nested inside the context. Since the CeTZ canvas itself needs structured data (i.e. the CeTZ drawing functions) you can’t put context between the canvas and the drawing functions; from that it follows that you put the context around the canvas.

Is this what you meant? It seems to work, but it thought that context blocks should be minimized? Here basically all the code is in one.

let tree(node, ...) = context {
  // helper functions
  let width(node) = { ... }
  let offset(node) = { ... }
  ...
  // the big function
  let tree-recursive(node, ...) = { ... }
  cetz.canvas(tree-recursive(node))
}

As far as I understand, that is largely not an issue anymore since 0.12. Back with 0.11, there were cases where making context smaller was important, although I’m not sure what the implications were.

In any case, even then you’d have had to make the context big enough and what you’re doing here is already the smallest context scope possible (at least as long that we assume that there’s no way around measuring inside the diagram). And even though it’s quite a bit of code, I’d say one diagram is actually not that big of a scope anyway.

That said, what are the implications of using a large scope? One important one is that the context is “frozen” in the whole scope. Consider this:

#context {
  [#text.size]
  set text(size: 2em)
  [#text.size]
}

both of these will print the same value, but in different font sizes! You access text.size contextually, and the value you access is the one active at the beginning of the context block. One way around this is to break this into two smaller context blocks, but even if that is not possible, you can work around this by nesting context blocks:

#context {
  [#text.size]
  set text(size: 2em)
  context [#text.size]
}

Another implication is that context blocks will sometimes fail in early iterations. Typst will retry when the context changes, but if the source and consumer of that change are in the same block, this won’t work. The workaround is the same:

#let x = state("x")
#context {
  x.update(1)

  // bad: the context fails and the update isn't applied,
  // leading to the context to continue failing
  // assert.eq(x.get(), 1)

  // good: only this context fails, so the update is applied
  // on the next attempt, this succeeds
  context assert.eq(x.get(), 1)
}

You can read more about this behavior here: Is an assert supposed to be able to change behavior when it "doesn't" fail? - #2 by laurmaedje and in the issue linked there.

2 Likes

I’m not sure the last example is really illustrating the retry issue: Isn’t it rather another case of freezing the wrong scope? If you do #context{ x.update(1); x.get() } the get call will use the state frozen before the update.

Maybe this is a better example:

#context {
  // This heading is never inserted because the context block fails
  // due the assert
  heading[A]
  // Adding context in front of the assert fixes the error as then it's
  // only this context that fails
  assert(query(heading).len() > 0)
}
1 Like

You’re right, I didn’t think about that when thinking up an example. Thanks for adding a better one!

1 Like