Vertial alignment of inline math in HTML export

I spent some time tweaking the vertical alignment of inline math formulas. Current solution is still a bit hacky, but it works reasonably. Here is a comparison of the results with and without the y-shift adjustment:

The code:

#let inline-math-count = counter("inline-math-count")
#let shift-inline-math(body) = context {
  // Allocate a new state for each call to this function
  let y-shift = state("y-shift" + str(inline-math-count.get().first()), 0pt)
  inline-math-count.step()
  let begin-loc = here()
  // The wrapper ensures that the viewbox of rendered SVG math matches its bounding box.
  let wrapper = text.with(top-edge: "bounds", bottom-edge: "bounds")
  // For debugging: draw red box around the wrapper
  // let wrapper = it => box(wrapper(it), stroke: red)
  html.elem(
    "span",
    html.frame(wrapper(
      // Add invisible elements below the math body to measure its bottom position.
      math.attach(math.limits(body.body), b: pad([#none<_math_bot>], -1em))
        + sym.wj
        + math.attach(math.limits([#none]), b: pad([#none<_math_ref_bot>], -1em)),
    )),
    attrs: (
      // Rendered SVG defines its width & height in "em" units,
      // so we also convert y-shift relative to text size in "em" units.
      style: "vertical-align: -" + str(calc.round(y-shift.final() / text.size, digits: 2)) + "em;",
      class: "typst-inline-math",
    ),
  )
  context {
    let end-loc = here()
    let math-bot = query(
      selector(<_math_bot>).after(begin-loc).before(end-loc),
    )
    let math-ref-bot = query(
      selector(<_math_ref_bot>).after(begin-loc).before(end-loc),
    )
    if math-ref-bot.len() >= 1 {
      let y1 = math-bot.at(0).location().position().y
      let y2 = math-ref-bot.at(0).location().position().y
      let new-y-shift = y1 - y2
      if new-y-shift > y-shift.get() + 0.1pt {
        y-shift.update(old => new-y-shift)
      }
    }
  }
}

#let html-export-template(doc) = context {
  if target() != "html" {
    return doc
  }
  show math.equation.where(block: false): it => {
    // The target() function can be used to apply html.frame selectively only
    // when the export target is HTML.
    // When html.frame is applied to a figure, the target() for all the elements
    // inside will be set to "paged" instead.
    // https://github.com/typst/typst/issues/721#issuecomment-3064895139
    if target() == "html" {
      shift-inline-math(it)
    } else {
      it
    }
  }
  show math.equation.where(block: true): it => {
    html.elem(
      "div",
      html.frame(it),
      attrs: (class: "typst-display-math"),
    )
  }
  // Wrap code blocks in a div for styling
  show raw.where(block: true): it => {
    html.elem(
      "div",
      it,
      attrs: (class: "typst-code-block"),
    )
  }
  doc
}

The post

helps a lot. Many thanks to @bluss and mannot package. I adopted a similar approach to measure the y-shift.

See more math formula samples in my blog:

6 Likes

The result is good, but the compile time become very long

Thanks for feedback. Can you share an example and the compilation time (with and without y-shift correction)? Does watch help in reducing re-compilation time?

I suspect that the query function may break some locality and cause performance issue.

I should have been more specific. Compiling multiple files (total 900 kb) using an old version of shiroa and --mode static-html, now takes 4m 15s, compared to just a few seconds before.

I optimized the code, and performance is now much better on my test document. The results are:

  • SVG math without y-shift correction: 1.7 s
  • Old y-shift implementation: 55 s
  • New y-shift implementation: 4.7 s

I also tested on larger documents; the runtime remains roughly three times the baseline and appears to scale linearly. In contrast, the old method is clearly super-linear. I’m not sure whether the behavior is quadratic or worse, as it easily runs out of memory on my 16 GB machine.

The new method uses a global query call and calculates the y-shifts for all inline maths. This is inspired by the post:

The code is below.

#let math-bot-label = label("_math_bot_")
#let math-ref-bot-label = label("_math_ref_bot_")

#let y-shifts = state("y-shifts", ())
#let inline-math-count = counter("inline-math-count")

#let shift-inline-math(body) = context {
  let formula-cnt = inline-math-count.get().first()
  inline-math-count.step()
  let begin-loc = here()
  // The wrapper ensures that the viewbox of rendered SVG math matches its bounding box.
  let wrapper = text.with(top-edge: "bounds", bottom-edge: "bounds")
  // For debugging: draw red box around the wrapper
  // let wrapper = it => box(wrapper(it), stroke: red)
  html.elem(
    "span",
    html.frame(wrapper(
      // Add invisible elements below the math body to measure its bottom position.
      math.attach(math.limits(body.body), b: pad([#none#math-bot-label], -1em))
        + sym.wj
        + math.attach(math.limits([#none]), b: pad([#none#math-ref-bot-label], -1em)),
    )),
    attrs: (
      // Rendered SVG defines its width & height in "em" units,
      // so we also convert y-shift relative to text size in "em" units.
      style: "vertical-align: -"
        + str(calc.round(y-shifts.final().at(formula-cnt, default: 0pt) / text.size, digits: 2))
        + "em;",
      class: "typst-inline-math",
    ),
  )
}

#let html-export-template(doc) = context {
  if target() != "html" {
    return doc
  }
  show math.equation.where(block: false): it => {
    // The target() function can be used to apply html.frame selectively only
    // when the export target is HTML.
    // When html.frame is applied to a figure, the target() for all the elements
    // inside will be set to "paged" instead.
    // https://github.com/typst/typst/issues/721#issuecomment-3064895139
    if target() == "html" {
      shift-inline-math(it)
    } else {
      it
    }
  }
  show math.equation.where(block: true): it => {
    html.elem(
      "div",
      html.frame(it),
      attrs: (class: "typst-display-math"),
    )
  }
  // Wrap code blocks in a div for styling
  show raw.where(block: true): it => {
    html.elem(
      "div",
      it,
      attrs: (class: "typst-code-block"),
    )
  }
  doc
  // After the whole document, calculate the y-shift for every inline math.
  // This reduces the number of `query` calls, improving performance.
  context {
    let math-bots = query(math-bot-label)
    let math-ref-bots = query(math-ref-bot-label)
    if math-bots.len() == inline-math-count.get().first() {
      assert(math-bots.len() == math-ref-bots.len())
      let new-y-shifts = math-bots
        .zip(math-ref-bots, exact: true)
        .map(pair => {
          let (math-bot, math-ref-bot) = pair
          let y1 = math-bot.location().position().y
          let y2 = math-ref-bot.location().position().y
          y1 - y2
        })
      y-shifts.update(old => new-y-shifts)
    }
  }
}
2 Likes

It seems that the long compile time is due to typst 0.13 used by the old version of shiroa. After updating deps about typst to 0.14, the old y-shift code cost 6.8s, the new code cost 3.3s, without y-shift cost 1.4s