How to write a show rule that only affects the current section?

Is there a way to write a show rule that only applies to the current section (as defined by headings), without having to enclose all such sections in []?

Something like the following:

= A1
// this is a special show rule that's only valid until `A2` (exclusive)
#show: until-next-heading.with(x => text(red, x))

This text will be red.

// This is a special show rule that's only valid until `B1` (exclusive)
// because we are currently inside a heading level 1.
#show: until-next-heading-same-level.with(underline)

And this text will be red and underlined.

== A2
This text will be black and underlined.

== A3
This text will be black and underlined

= B1
This text will be black.

I’m not sure if this is possible in current typst. Maybe using something that minces and reprocesses content (sequence/internal typst type inspection)? But hopefully we prefer solutions that use documented typst functionality instead, if possible.

I found a solution that allows you to specify rules that contain a function, a filter function and a stop function. Extending this to have a specific start function/condition should also be possible. Currently the show rule starts wherever it is called. The first text line in your example is therefore red and underlined (for now).

The idea is to use a general show rule (as you already did in your example) where you iterate over all children of the content sequence. The iteration for each rule is “stopped” when the corresponding function returns true. The filter allows you to exclude content from the rule, e.g. the headings in your case.

This solution is also compatible with other show rules as long as you call them before the general show rule. E.g. the following show rule to change the font color in level 2 headings will work just fine:

#show heading.where(level: 2): set text(blue)

Some comments on the code:

  • If the (general) body does not have the “children” key, the rules are not applied. This happened when I applied other show rules after the general rule. I don’t know how many other cases there are.
  • If you don’t want to apply a filter, just set the return value of the function to true. There is currently no check if a rule has the “filter” key.
  • You can target specific headings in the stop function by checking more than the level. I used the actual content here as an example. This is not very elegant yet, checking the heading label here would probably be better. But I will leave this up to you. :slight_smile:
#let apply-rule(children, rule) = {
  let i = 0
  for child in children {
    if rule.at("stop")(child) { break }
    if rule.at("filter")(child) { (rule.at("func")(child),) } else { (child,) }
    i += 1
  }
  children.slice(i,)
}

#let rules = (
  (
    func: text.with(red),
    filter: child => child.func() != heading,
    stop: child => child.func() == heading and child.depth == 2 and child.body == [A2],
  ),
  (
    func: underline,
    filter: child => child.func() != heading,
    stop: child => child.func() == heading and child.depth == 1,
  ),
)

#show: it => {
  let children = if it.has("children") { it.children } else { return it }
  for rule in rules { children = apply-rule(children, rule) }
  children.join([])
}
Your example will then look like this

1 Like

You can make use of breakpoint-metadata or toggle-state to insert breakpoints in the document. Then either use a global show rule to iterate over content and check for breakpoints, or query the elements in the specified region, if they are queryable. Or use an element show rule and check if it is within the region where a specific styling is needed to be applied. Or the most basic thing: use apply and unapply sets of styling: just restore the initial styling after a specific point. With rule revoking, this will become much easier.

1 Like

I think that’s very nicely expressed in code. If this was used I’d like to do this but recursively, so that it interacts well with all other styling rules. Not easy, but I guess it can be done. Like you say it has a problem with show rules and other transformations that come after it.

Thanks for the ideas - I’ll experiment and see if I understand and can realize that. I think I see what you mean with using state.

Here’s an initial version of the state tracking and local show rules that Andrew alluded to - if I understand correctly.

Idea - a state variable keeps track of the current heading.

A show rule is split into pieces by being applied element by element (for example, to each text or each par or list.) This way we can track state updates that happen between these elements.

Code and Output

bild

#let heading-tracker = state("heading-tracker", none)

#show heading: it => {
  heading-tracker.update(it)
  it
}

/// Apply show rule `rule` to the `body` inside current section
/// The rule is applied to the given `element` similar to `show <element>: rule`
#let show-this-section(rule, body, element: text, subsections: false) = context {
  let start-heading = heading-tracker.get()
  show element: it => context {
    let local-heading = heading-tracker.get()
    let allow-rule = if subsections {
      // any higher level than start is the new ancestor
      let levels = range(1, start-heading.level + 1)
      let ancestor-selector = selector.or(..levels.map(l => heading.where(level: l)))
      let ancestor-heading = query(ancestor-selector
        .after(start-heading.location())
        .before(local-heading.location())
        ).last()
      ancestor-heading == start-heading
    } else {
      start-heading == local-heading
    }
    if allow-rule {
      rule(it)
    } else {
      it
    }
  }
  body
}

= A1

#show: show-this-section.with(text.with(red))

This text will be red.

#show: show-this-section.with(underline, element: par, subsections: true)

And this text will be red and underlined.

== A2
This text will be black and underlined.

== A3

#show: show-this-section.with(text.with(blue), element: par, subsections: true)
This text will be blue and underlined

= B1
This text will be black.

== B2

Still black
  • Version 1: just using heading name and level in state
  • Version 2: use heading element in state and query for ancestor heading
  • Version 3: Removed extra nesting of show rule that was not necessary
  • Version 4: Fix bug with subsections not reset correctly

This is not everything I’d like to do per section, but it answers the initial question perfectly and so I can’t ask for more in this particular topic. Thanks for the help!

2 Likes

I was thinking about same-element (heading), but I guess you can do this with any:

toggle-state solution + output
#let toggle = state("toggle", false)

#show text: it => context {
  if not toggle.get() { return it }
  let style = query(selector(<style>).before(here()))
  if style.len() == 0 { return it }
  let (wrapper, style) = style.last().value
  set text(..style)
  if wrapper == none { it } else { wrapper(it) }
}

#let unset-text-style() = toggle.update(false)
#let set-text-style(wrapper: none, ..style) = [
  #metadata((wrapper: wrapper, style: style))<style>
  #toggle.update(true)
]

#set-text-style(fill: red)

= A1

This text will be red.

#set-text-style(red, wrapper: underline)

And this text will be red and underlined.

#set-text-style(wrapper: underline)

== A2
This text will be black and underlined.

== A3

#set-text-style(blue, wrapper: underline)

This text will be blue and underlined

#unset-text-style()

= B1
This text will be black.

== B2

Still black

image

For the OP, since you can deduce the implicit breakpoint, you don’t need to manually unset styling.

OP solution + output
#let text-toggle-h = state("text-toggle-h", false)
#let text-toggle-slh = state("text-toggle-slh", false)

#show heading: it => text-toggle-h.update(false) + it
#show heading: it => context {
  if not text-toggle-slh.get() { return it }

  let style = query(selector(<style-slh>).before(here()))
  if style.len() == 0 { return it }

  let headings = query(selector(heading).before(style.last().location()))
  if headings.len() == 0 { return it }

  let level = headings.last().level
  if it.level == level { text-toggle-slh.update(false) }
  it
}

#show text: it => context {
  if not text-toggle-h.get() and not text-toggle-slh.get() { return it }

  show: it => {
    if not text-toggle-h.get() { return it }
    let style = query(selector(<style-h>).before(here()))
    if style.len() == 0 { return it }
    let (wrapper, style) = style.last().value
    set text(..style)
    if wrapper == none { it } else { wrapper(it) }
  }

  show: it => {
    if not text-toggle-slh.get() { return it }
    let style = query(selector(<style-slh>).before(here()))
    if style.len() == 0 { return it }
    let (wrapper, style) = style.last().value
    set text(..style)
    if wrapper == none { it } else { wrapper(it) }
  }

  it
}

#let unset-text-style() = toggle.update(false)
#let set-text-style-until-next-heading(wrapper: none, ..style) = [
  #metadata((wrapper: wrapper, style: style))<style-h>
  #text-toggle-h.update(true)
]
#let set-text-style-until-next-same-level-heading(wrapper: none, ..style) = [
  #metadata((wrapper: wrapper, style: style))<style-slh>
  #text-toggle-slh.update(true)
]

= A1
#set-text-style-until-next-heading(red)

This text will be red.

#set-text-style-until-next-same-level-heading(wrapper: underline)

And this text will be red and underlined.

== A2
This text will be black and underlined.

== A3

This text will be black and underlined

= B1
This text will be black.

image

Of course, naming and stuff can be refactored. You probably can generalize this too by providing element argument, instead of having per-element functions.

1 Like