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:
#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.
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)
}
}
}
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