Can a set or show rule be used to style the text content in a figure from outside of the figure?

Good day!

My employer uses Quarto for automated reporting, and I’m currently working on a typst template that will be applied to our reports as a custom format. All of our figures (images and tables) include several text annotations at the bottom of the figure, which need to be styled. I can achieve the desired result in typst by including align and text in the figure call:

#set figure.caption(position: top)
#show figure.caption: set align(left)

#figure(
  {
    // an image
    rect(fill: red)
    // annotations
    align(
        left,
        text(size: 10pt)[
            Program Path: mypath/file.csv \
            Abbreviations: DV = dependent variable \
        ]
    )
  },
  caption: [My Caption]
)

My question is, can I apply the same styling using a show/set rule outside of the figure, so that I can keep the styling and “artifact” creation steps separate?

I can almost get the desired result by simply using:

#show figure: it => {
  set align(left)
  set text(size: 10pt)
  it
}

#figure(
  {
    rect(fill: red)
    [
        Program Path: mypath/file.csv \
        Abbreviations: DV = dependent variable \
    ]
  },
  caption: [My Caption]
)

Except that the code above also shifts the image (which should be centered), to the left.

Any guidance would be appreciated!

I think you can introduce your own function here, making it

#annotation[
   Program Path: mypath/file.csv \
   Abbreviations: DV = dependent variable \
]

Then you have separated style from markup by only just a function call, but that’s enough. The function itself can either contain the styling, or mark up the content so that it’s recognizable somehow and leave style for a show rule.

Hello. Do you actually want the caption to be not centered (above the image)? If not, then a separate function indeed would be the cleanest solution:

#set figure.caption(position: top)

#let annotations(body) = align(center, block({
  set align(left)
  set text(size: 10pt)
  body
}))

#figure(rect(), caption: [My Caption])
#annotations[
  Program Path: mypath/file.csv \
  Abbreviations: DV = dependent variable
]

image

Thank you both for your replies. Yes, @Andrew, the caption should be left-aligned as well. Based on your response, would the simplest solution be to add a show rule to your suggestion, or did you have something else in mind?

#show figure.caption: set align(left)

How caption should behave if it’s longer than annotations or image?

It should wrap with a hanging indent.

I’ll include the actual test case from my template below. If it muddies the water, let me know and I’ll edit the post to remove it.

The relevant template code is:

// FIGURES
    #show figure: set block(sticky: true, breakable: true)
    #set figure.caption(position: top)
    #show figure.caption: it => context {
        set text(font: "Amaranth", weight: "bold")
        set align(left)
        let n = if it.kind == image {
            counter(figure.where(kind: image)).display()
        } else if it.kind == table {
            counter(figure.where(kind: table)).display()
        } else {
            none
        }
        // hanging indent only works if included inside the block
        // block is needed so that sticky figures don't widow their titles 
        block(
            par(
                hanging-indent: 1in,
                box(width: 1in, it.supplement + [ ] + n + [:]) + it.body
            )
        )
    }
}

And the call:

#figure(
    {
        image("pathtofile.png")
        align(
            left,
            text(size: 10pt)[
                Program Path: artifacts/create-data.jl \
                Legend: #lorem(60) \ 
                Abbreviations: CL = clearance, \
            ]
        )
    },
    placement: none,
    caption: [Schematic Representation of Proposed PK Model],
    kind: image,
    supplement: [Figure]
)


There are a lot of explicit things that are default anyway, so they basically just don’t give anything other than more code. I also noticed that basically the width of everything is the max width of caption/annotations/body. If this is what you want, then all three do have to be inside the same block, i.e., inside the figure. I think a basic wrapper with additional argument is the way.

#set figure.caption(position: top)
#show figure: set block(sticky: true, breakable: true)
#show figure.caption: it => context {
  let numbering = if it.kind == image {
    [~] + counter(figure.where(kind: image)).display()
  } else if it.kind == table {
    [~] + counter(figure.where(kind: table)).display()
  } // kind: raw is excluded
  set text(font: "Amaranth", weight: "bold")
  set align(left)
  grid(
    columns: (1in, auto),
    [#it.supplement#numbering#it.separator], it.body,
  )
}

#let figure(annotations: none, body, ..args) = std.figure(..args, {
  body
  set align(left)
  set text(10pt)
  annotations
})

#figure(
  rect(width: 15cm, height: 9cm),
  caption: lorem(15),
  annotations: [
    Program Path: artifacts/create-data.jl \
    Legend: #lorem(60) \
    Abbreviations:
    CL = clearance, \
  ],
)

#v(5em)

#figure(
  table(columns: 2)[][][],
  caption: lorem(15),
  annotations: [
    Program Path: artifacts/create-data.jl \
    Legend: #lorem(10) \
    Abbreviations:
    CL = clearance, \
  ],
)

Thanks, Andrew.

Can you elaborate here? Also, is there a reason for using grid over a box?

The solution you proposed works if the code is in the same document as the content; however, if I remove it to a template I get an error, unexpected argument: annotations

//temp.typ
#let report(doc) = {
  //solution code
  doc
} 

// test.typ
#import "temp.typ": *
#show: report.with()

#figure(
  rect(width: 15cm, height: 9cm),
  caption: lorem(15),
  annotations: [
    Program Path: artifacts/create-data.jl \
    Legend: #lorem(60) \
    Abbreviations:
    CL = clearance, \
  ],
)

#outline(
  title: "List of Figures",
  target: figure.where(kind: image)
)

There’s also an error with the subsequent #outline that, where() can only be called on element functions .

1 Like

The figure.placement is none by default. The figure.kind is determined by the content automatically. Even with the wrapper, it works as expected, so also no reason to set it. The same goes to figure.supplement.

The grid is used instead of box and par (and block). It’s just better suited for this. See Horizontal `stack` does not correctly resize its children · Issue #2422 · typst/typst · GitHub.

The things defined in the template function are scoped to that function and not accessible from outside, i.e., define it outside.

You don’t have a comma after title: "List of Figures". The target: figure.where(kind: image) uses custom wrapper function, so either use std.figure or rename the function.

Thank you for your help and the additional clarification, @Andrew. Everything is working.

1 Like