Show Rules for Deeply Updating Bodies of Various Elements

Hello Typsters,

I often have to deal with documents where every heading, paragraph, list item, enum item, figure caption, really every chunk of text, has to be labeled with some kind of marker. For some element types this is totally straightforward, for some I’m left wondering if there’s a better way, and for others I have no idea how to achieve this. For headings and semantic paragraphs this is pretty easy. For this:

#let labelThings(lab : "D", x) = {
  show par: it => block([*(#lab)* ] + it.body)
  show heading : it => block([*(#lab)* ] + it.body)
  x
}

#labelThings[
  = Heading1
  #lorem(10)
]

I get something like:

(I’m only allowed to attach two images as a new user, so take my word for the fact that this works as expected)

For figures, I have to “rebuild” the default caption rendering in order to get what I want, but it works. For this:

#let labelThings(lab : "D", x) = {
  show par: it => block([*(#lab)* ] + it.body)
  show heading : it => block([*(#lab)* ] + it.body)
  show figure.caption: it => block([*(#lab)* ] + it.supplement + [ ] + it.numbering + [: ] + it.body)
  x
}

#labelThings[
  = Heading1
  #lorem(10)
  #figure(caption: [Some Figure],
    block(box(stroke:1pt, inset: 1em, [Some Figure])))
]

I get something like:

The fact that I can only update the entire figure caption with a show rule, rather than strictly the caption contents feels sort of like I’m holding it wrong, but I haven’t worked out a different way to do it.

Lists and enums are where things get strange. If I leave these show rules as-is and add some list and enum elements like so:

#let labelThings(lab : "D", x) = {
  show par: it => block([*(#lab)* ] + it.body)
  show heading : it => block([*(#lab)* ] + it.body)
  show figure.caption: it => block([*(#lab)* ] + it.supplement + [ ] + it.numbering + [: ] + it.body)
  x
}

#labelThings[
  = Heading1
  #lorem(10)
  #figure(caption: [Some Figure],
    block(box(stroke:1pt, inset: 1em, [Some Figure])))
  - First item
  - Second item
    - Subsecond item
  - Third item
    - Subthird item
      - Subsubthird item
  + First enum
  + Second enum
    + Subsecond enum
  + Third enum
    + Subthird enum
      + Subsubthird enum
]

I get something unexpected:

By elimination it’s the par rule that’s causing this. It seems that list and enum elements that have children become paragraphs, but not those without children. I’ve played around with show rules on list and list.item, but similar to figure captions I suspect that the only way I’d be able to render what I want is to make a show rule that re-implements the default rendering.

So, my questions are:

  1. Is there a better way to recursively add something to every text chunk in a body of content like this?
  2. Is there a better way to reach into a figure caption’s body or list/enum item’s body and modify it?
  3. Why do list/enum items become paragraphs when they have children? Is there a good reason for this or is it a bug?

Re-reading the manual sheds some light on 3. The docs for the par function say:

… by adding a parbreak after some content in a container, you can force it to become a paragraph even if it’s just one word. This is, for example, what non-tight lists do to force their items to become paragraphs.

This doesn’t make much sense to me in the context of semantic paragraphs. Why should some list items be semantic paragraphs while other’s shouldn’t be, and why should list tightness have to do with semantic paragraphs? I suppose this made perfect sense before semantic paragraphs were a thing? An example of this:

#show par: it => block(box(stroke: 1pt + blue, inset: 2pt, it))

- Tight list first item, this is not a paragraph.
- Tight list second item, this is a paragraph.
  - Tight list subsecond item, this is a paragraph.
    - Tight list subsubsecond item, this is not a paragraph.
- Tight list third item, this is not a paragraph.

= Break the list

- Loose list first item, this is a paragraph.

  - Loose list second item, this is a paragraph.

    - Loose list subsecond item. This is a paragraph.

      - Loose list subsubsecond item. This is not a paragraph.

- Loose list third item, this is a paragraph.

Gives:

This gets very close to what I want:

#let mapelem(e) = {
  let f = e.func()
  let a = e.fields()
  if repr(f) == "sequence" {
    f(a.children.map(mapelem))
  } else if repr(f) == "space" {
    f(..a)
  } else if repr(f) == "parbreak" {
    f(..a)
  } else if repr(f) == "heading" {
    let b = mapelem(a.remove("body"))
    f(..a, b)
  } else if repr(f) == "item" {
    let b = mapelem(a.remove("body"))
    f(..a,b)
  } else if repr(f) == "text" {
    let t = a.remove("text")
    [*(D)* ] + f(..a, t)
  } else if repr(f) == "figure" {
    let b = a.remove("body")
    if "caption" in a {
      a.insert("caption", mapelem(a.caption))
    }
    f(..a, b)
  } else if repr(f) == "caption" {
    let b = mapelem(a.remove("body"))
    f(..a, b)
  } else [
    #text(red, repr(f))
  ]
}

#mapelem[
  = Heading1
  #lorem(10)
  #figure(caption: [Some Figure],
    block(box(stroke:1pt, inset: 1em, [Some Figure])))
  - First item
  - Second item
    - Subsecond item
  - Third item
    - Subthird item
      - Subsubthird item
  + First enum
  + Second enum
    + Subsecond enum
  + Third enum
    + Subthird enum
      + Subsubthird enum
]

I was not able to work out how to put the (D) in different parts of the figure caption (e.g. before the supplement instead of after), since the default supplement doesn’t actually exist in the element; it must be added automatically at some later point. I’m also curious if there’s a better way to handle positional arguments than the let b = a.remove("body") pattern. The nice thing about recursively rebuilding things like this is the fact that I get to choose where and how to make the recursive calls, whereas a show rule is always recursively called on its output. It seems like the only way to achieve this kind of behavior with show rules is to manually check if you’re at a fixed point of the function. I recall seeing code on this forum that kludges this by adding a label to already processed elements, and then checking for the presence of that label in the show rule function before returning a modified element.

Another thought I had: it might be interesting to be able to run show rules on a given element’s arguments, rather than the whole element. Something like:

#show list.item.body: it => [*(D)* ] + it

If there’s a way to do something like this, I wasn’t able to work it out.

Ah, this approach does not actually work because the magic built in context element cannot be constructed. If I add a clause like:

#let mapelem(e) =
... else if repr(f) == "context" {
    f(..a)
  } ...

I get Cannot be constructed manually. So this doesn’t actually work.

One workaround for dealing with unconstructable elements is define things with corecursive functions and show rules. If I do this:

#let mapelem(eee) = {
  show: e => {
  let f = e.func()
  let a = e.fields()
  if repr(f) == "sequence" {
    f(a.children.map(mapelem))
  } else if repr(f) == "space" {
    f(..a)
  } else if repr(f) == "parbreak" {
    f(..a)
  } else if repr(f) == "heading" {
    let b = mapelem(a.remove("body"))
    f(..a, b)
  } else if repr(f) == "item" {
    let b = mapelem(a.remove("body"))
    f(..a,b)
  } else if repr(f) == "text" {
    let t = a.remove("text")
    [*(D)* ] + f(..a, t)
  } else if repr(f) == "figure" {
    let b = a.remove("body")
    if "caption" in a {
      a.insert("caption", mapelem(a.caption))
    }
    f(..a, b)
  } else if repr(f) == "caption" {
    let b = mapelem(a.remove("body"))
    f(..a, b)
  } else {
    box(stroke: 1pt + red, inset: 3pt, e)
  }
}
eee
}

I’m able to turn a document like this:

#mapelem[
  = Heading1
  #lorem(10)
  #figure(caption: [Some Figure],
    block(box(stroke:1pt, inset: 1em, [Some Figure])))
  - First item
  - Second item
    - Subsecond item
  - Third item
    - Subthird item
      - Subsubthird item
  + First enum
  + Second enum
    + Subsecond enum
  + Third enum
    + Subthird enum
      + Subsubthird enum

#cetz.canvas(length: 2cm, {
  import cetz.draw: *

  set-style(
    mark: (fill: black, scale: 2),
    stroke: (thickness: 0.4pt, cap: "round"),
    angle: (
      radius: 0.3,
      label-radius: .22,
      fill: green.lighten(80%),
      stroke: (paint: green.darken(50%))
    ),
    content: (padding: 1pt)
  )

  grid((-1.5, -1.5), (1.4, 1.4), step: 0.5, stroke: gray + 0.2pt)

  circle((0,0), radius: 1)

  line((-1.5, 0), (1.5, 0), mark: (end: "stealth"))
  content((), $ x $, anchor: "west")
  line((0, -1.5), (0, 1.5), mark: (end: "stealth"))
  content((), $ y $, anchor: "south")

  for (x, ct) in ((-1, $ -1 $), (-0.5, $ -1/2 $), (1, $ 1 $)) {
    line((x, 3pt), (x, -3pt))
    content((), anchor: "north", ct)
  }

  for (y, ct) in ((-1, $ -1 $), (-0.5, $ -1/2 $), (0.5, $ 1/2 $), (1, $ 1 $)) {
    line((3pt, y), (-3pt, y))
    content((), anchor: "east", ct)
  }

  // Draw the green angle
  cetz.angle.angle((0,0), (1,0), (1, calc.tan(30deg)),
    label: text(green, [#sym.alpha]))

  line((0,0), (1, calc.tan(30deg)))

  set-style(stroke: (thickness: 1.2pt))

  line((30deg, 1), ((), "|-", (0,0)), stroke: (paint: red), name: "sin")
  content(("sin.start", 50%, "sin.end"), text(red)[$ sin alpha $])
  line("sin.end", (0,0), stroke: (paint: blue), name: "cos")
  content(("cos.start", 50%, "cos.end"), text(blue)[$ cos alpha $], anchor: "north")
  line((1, 0), (1, calc.tan(30deg)), name: "tan", stroke: (paint: orange))
  content("tan.end", $ text(#orange, tan alpha) = text(#red, sin alpha) / text(#blue, cos alpha) $, anchor: "west")
})
]

#mapelem[

= Heading2


#diagram(cell-size: 15mm, $
	G edge(f, ->) edge("d", pi, ->>) & im(f) \
	G slash ker(f) edge("ur", tilde(f), "hook-->")
$)

== Heading3

#set text(10pt)
#diagram(
	node-stroke: .1em,
	node-fill: gradient.radial(blue.lighten(80%), blue, center: (30%, 20%), radius: 80%),
	spacing: 4em,
	edge((-1,0), "r", "-|>", `open(path)`, label-pos: 0, label-side: center),
	node((0,0), `reading`, radius: 2em),
	edge(`read()`, "-|>"),
	node((1,0), `eof`, radius: 2em),
	edge(`close()`, "-|>"),
	node((2,0), `closed`, radius: 2em, extrude: (-2.5, 0)),
	edge((0,0), (0,0), `read()`, "--|>", bend: 130deg),
	edge((0,0), (2,0), `close()`, "-|>", bend: -40deg),
)
]

Into: