A snippet to debug font by visualize baseline, cap-height, etc

Here is a snippet to debug font by visualize baseline, x-height, cap-height, descender, ascender, and bounds.
It might be helpful if you meet issues on baseline alignment between mixed scripts.

Example usage

#import "debug-font.typ": debug-font
#set page(height: auto, width: auto, margin: (left: 1em, right: 3.5em, y: 0.5em))
#debug-font({
  set text(font: "Source Han Serif")
  [Typst 国王]
})

Source of the above example

This text will be hidden

#set page(height: auto, width: auto, margin: 0.5em)

#set text(fallback: false, lang: "en", font: ("Libertinus Serif", "Source Han Serif"))
#show heading: set text(1.2em)
#show heading: pad.with(bottom: 0.25em)

#let debug-font-big(body) = debug-font({
  set text(2em)
  body
})


= Libertinus Serif (default)
#debug-font-big({
  set text(font: "Libertinus Serif")
  [“Typst”,.:!? αβωΩ uüUÜ]
})

= New Computer Modern
#debug-font-big({
  set text(font: "Libertinus Serif")
  [“Typst”,.:!? αβωΩ uüUÜ]
})

= Source Han Serif / Noto Serif CJK / 思源宋体
#debug-font-big({
  set text(font: "Source Han Serif")
  [Typst,.:!“国王《永》”,。:!]
})

= SimSun / 中易宋体
#debug-font-big({
  set text(font: "SimSun")
  [Typst,.:!“国王《永》”,。:!]
})

There are many versions of SimSun, and the above is the one \ included with Windows.
However, some old crazy versions \ set ascender = cap-height = x-height.

= Source Han Sans / Noto Sans CJK / 思源黑体
#debug-font-big({
  set text(font: "Source Han Sans")
  [Typst,.:!“国王《永》”,。:!]
})

= SimHei / 中易黑体
#debug-font-big({
  set text(font: "SimHei")
  [Typst,.:!“国王《永》”,。:!]
})

= FangSong / 中易仿宋
#debug-font-big({
  set text(font: "FangSong")
  [Typst,.:!“国王《永》”,。:!]
})

= Kaiti / 中易楷体
#debug-font-big({
  set text(font: "Kaiti")
  [Typst,.:!“国王《永》”,。:!]
})

= Notes
#debug-font-big({
  set text(font: "Libertinus Serif")
  [u]
})
#h(2em)
#debug-font-big({
  set text(font: "Libertinus Serif")
  [gÜ]
})

Bounds may vary with the specific text.

Relevant docs

#set text(top-edge: …, bottom-edge: …)
#set par(leading: …, spacing: …)

Definition of debug-font

/// Visualize the conceptual frame around an example text.
///
/// https://typst.app/docs/reference/text/text/#parameters-top-edge
/// https://typst.app/docs/reference/text/text/#parameters-bottom-edge
/// https://forum.typst.app/t/a-snippet-to-debug-font-by-visualize-baseline-cap-height-etc/4597/
#let debug-font(
  models: (
    (top-edge: "bounds"),
    (top-edge: "ascender"),
    (top-edge: "cap-height"),
    (top-edge: "x-height"),
    (top-edge: "baseline"),
    (bottom-edge: "descender"),
    (bottom-edge: "bounds"),
  ),
  palette: (aqua, fuchsia, green, yellow, fuchsia, aqua),
  example-body,
) = {
  assert(models.len() - 1 == palette.len())

  context {
    // Measure heights and sort increasingly
    let edge-heights = models
      .map(raw-m => {
        // Fill with defaults
        let m = (top-edge: "baseline", bottom-edge: "baseline", ..raw-m)

        // Measure the height of the example
        let h = measure(text(..m, example-body)).height

        // Calculate the sign of the height
        if m.top-edge != "baseline" and m.bottom-edge == "baseline" {
          (m.top-edge, h)
        } else if m.top-edge == "baseline" and m.bottom-edge != "baseline" {
          (m.bottom-edge, -h)
        } else {
          assert(m.top-edge == m.bottom-edge and m.bottom-edge == "baseline")
          assert(h == 0pt)
          (m.top-edge, h)
        }
      })
      .sorted(key: ((e, h)) => h)

    // Make sure `place(bottom, dy: …)` is relative to the baseline
    set text(bottom-edge: "baseline")

    box({
      // Draw stripes
      let heights = edge-heights.map(((e, h)) => h)
      for (h-low, h-high, fill) in heights.slice(0, -1).zip(heights.slice(1), palette) {
        place(bottom, dy: -h-low, box(height: h-high - h-low, fill: fill, hide(example-body)))
      }

      // Write the example
      example-body
    })

    // Write annotations
    box({
      let last-h = none
      let long-arrow = false
      for (edge, h) in edge-heights {
        // if too narrow, change the arrow size
        if last-h != none and calc.abs(h - last-h) < 0.2em.to-absolute() {
          long-arrow = not long-arrow
        } else {
          long-arrow = false
        }
        let arrow-size = if long-arrow { 6em } else { 1em }

        place(
          bottom,
          dy: -h + 0.3em / 2,
          text(
            0.3em,
            bottom-edge: "descender",
            text(black.transparentize(50%), $stretch(arrow.l, size: #arrow-size)$) + edge,
          ),
        )

        last-h = h
      }
    })
  }
}
15 Likes

An example bad font

The following is not a good font, because its x-height = cap-height = ascender.

An Arabic example

#set page(height: auto, width: auto, margin: (left: 1em, right: 4em, top: 0.5em, bottom: 1.2em))
#debug-font({
  set text(lang: "ar", font: "Noto Sans Arabic")
  [نَبْكِ اللِّوَى الدَّخُول]
})

2 Likes

My previous script assumes baselines of top-edge and bottom-edge are identical. However, it’s not true for most math equations, as shown below.

#context {
  let measure-height(body) = {
    measure(text(
      top-edge: "baseline",
      bottom-edge: "baseline",
      body,
    )).height
  }

  assert.eq(
    ([x], $x$, [f], $f$).map(measure-height),
    (0pt, 0pt, 0pt, 2.75pt),
  )
}

I’ve updated my script to cover this possibility. (Unfortunately, I can’t edit my original post, because it was posted long ago.)

Math examples

$x$: Two baselines are identical and collapse into one.

$f x$: Two baselines differ.

$1/2$:

$ x $: It looks like that the display style breaks all assumptions.

$ 1/2 $

Full code
#import "debug-font.typ": debug-font

#debug-font({
  set text(3em)
  $x$ // or other examples
})

Updated version

/// Visualize the conceptual frame around an example text.
///
/// https://typst.app/docs/reference/text/text/#parameters-top-edge
/// https://typst.app/docs/reference/text/text/#parameters-bottom-edge
/// https://forum.typst.app/t/a-snippet-to-debug-font-by-visualize-baseline-cap-height-etc/4597/
#let debug-font(
  models: (
    (top-edge: "bounds"),
    (top-edge: "ascender"),
    (top-edge: "cap-height"),
    (top-edge: "x-height"),
    // Usually, top and bottom baselines are identical
    // However, it's not true for most math equations.
    // You can specify both of them, and they will collapse into one or display separately, depending whether they are identical.
    (top-edge: "baseline"),
    (bottom-edge: "baseline"),
    (bottom-edge: "descender"),
    (bottom-edge: "bounds"),
  ),
  palette: (aqua, fuchsia, green, yellow, fuchsia, aqua, yellow),
  example-body,
) = context {
  // Measure the distance between top and bottom baselines
  // Usually, the two baselines are identical, and `d == 0`.
  // However, it's not true for most math equations.
  let d = measure(text(
    top-edge: "baseline",
    bottom-edge: "baseline",
    example-body,
  )).height

  // Measure heights and sort increasingly
  // A list of (edge name, signed height relative to the bottom baseline)
  let edge-heights = models
    .map(raw-m => {
      // Fill with defaults
      let m = (top-edge: "baseline", bottom-edge: "baseline", ..raw-m)

      // Measure the height of the example
      let h = measure(text(..m, example-body)).height

      // Calculate the sign of the height
      if m.top-edge != "baseline" and m.bottom-edge == "baseline" {
        (m.top-edge, h)
      } else if m.top-edge == "baseline" and m.bottom-edge != "baseline" {
        (m.bottom-edge, d - h)
      } else {
        assert(m.top-edge == m.bottom-edge and m.bottom-edge == "baseline")
        assert.eq(
          h,
          d,
          message: "Measuring the distance between top and bottom baselines twice gives inconsistent results. How did you achieve that?",
        )
        if d == 0pt {
          ("baseline", h)
        } else {
          let key = raw-m.keys().first()
          (
            "baseline (" + key + ")",
            if key == "top-edge" { h } else { h - d },
          )
        }
      }
    })
    .sorted(key: ((e, h)) => h)
    .dedup() // Collapse two baselines into one, if they are identical


  // Check there are enough colors
  assert(
    edge-heights.len() - 1 <= palette.len(),
    message: "There are too few colors in `palette` to fill between all edge lines in `models`. Please set more colors.",
  )

  // Make sure `place(bottom, dy: …)` is relative to the baseline
  set text(bottom-edge: "baseline")

  box({
    // Draw stripes
    let heights = edge-heights.map(((e, h)) => h)
    for (h-low, h-high, fill) in heights
      .slice(0, -1)
      .zip(heights.slice(1), palette) {
      place(bottom, dy: -h-low, box(
        height: h-high - h-low,
        fill: fill,
        hide(example-body),
      ))
    }

    // Write the example
    example-body
  })

  // Write annotations
  box({
    let last-h = none
    let long-arrow = false
    for (edge, h) in edge-heights {
      // if too narrow, change the arrow size
      if last-h != none and calc.abs(h - last-h) < 0.2em.to-absolute() {
        long-arrow = not long-arrow
      } else {
        long-arrow = false
      }
      let arrow-size = if long-arrow { 6em } else { 1em }

      place(
        bottom,
        dy: -h + 0.3em / 2,
        text(
          0.3em,
          bottom-edge: "descender",
          text(
            black.transparentize(50%),
            $stretch(arrow.l, size: #arrow-size)$,
          )
            + edge,
        ),
      )

      last-h = h
    }
  })
}
2 Likes

Update: Jeremy Gao helps publish the script as a package, and future updates will go into GitHub - WenSimEHRP/eeaabb: Extract Element AABBs.

#import "@preview/eeaabb:0.1.0": debug-font

#set page(
  height: auto,
  width: auto,
  margin: (left: 1em, right: 3.5em, y: 0.5em),
)

#debug-font({
  set text(font: "Noto Serif CJK SC")
  [Typst 国王]
})

Besides, the package is also capable of visualize other per-character metrics.

1 Like