Skip to content
Crixaa
← All posts

Bold got it right and underline did not

The Crixaa team4 min read

Bold in our editor has three states. Underline had two, and nobody noticed until an underlined heading quietly stopped being underlined in the PDF the first time someone fixed a typo in it.

Three states, not two

A text element in a Crixaa template carries a style: font, size, alignment, and the four marks — bold, italic, underline, strikethrough. Its content is either a plain string or a list of runs, where a run is a fragment of the text with its own optional overrides. Optional is the load-bearing word. Each mark on a run has three states, not two:

run.boldMeaning
absentinherit whatever the element says
truebold, whatever the element says
falsenot bold, whatever the element says

The third row is the entire reason the model exists. Without it you cannot say "this whole clause is bold, except the amount" — there is no way to express off where the parent already said on.

The two properties that only read themselves

Bold and italic implemented all three rows. Underline and strikethrough never looked at the element, which is visible the moment you line the four up:

const style = {
  bold: run.bold ?? element.bold,
  italic: run.italic ?? element.italic,
  underline: run.underline,
  strikethrough: run.strikethrough,
}

Two things follow. A run could switch underline on, because true is true. It could never switch it off: with nothing to inherit from, an explicit false and an absent value produced the same result.

The second consequence is worse. An underline set on the element never reached the text at all once that text became runs — and content becomes runs on the first edit, not the first formatting change. So a designer underlines the heading TAX INVOICE and the output is correct. Somebody later corrects a consignee's name in that same heading, the plain string becomes a run list, and the underline is gone from every document generated after that.

Nobody changed the underline. Nobody would think to look at it.

The editor was right, which is why it took a while

The canvas had implemented the three-state rule for all four marks from the beginning, so this never presented as a missing feature. It presented as the editor showing an underline the PDF did not have, which reads like a renderer bug and sends you off measuring things in the PDF pipeline.

It is the same class of problem as measuring layout in two places: a rule with two implementations has two behaviours, and they agree only for as long as nobody edits either one. Mark resolution is part of the rendering contract, so it should have had one implementation that both sides call. Now it does.

The fix that looks right

The obvious repair is to add the fallback with an ||:

underline: run.underline || element.underline  // still wrong

That restores inheritance and re-breaks the override, because false || true is true. You have swapped one collapsed state for the other:

ElementRunrun.urun.u || el.urun.u ?? el.u
offabsentoffoffoff
onabsentoffonon
onfalseoffonoff
offtrueononon

?? is the right operator because it branches on presence rather than truthiness. For any boolean override, || is a bug waiting for someone to explicitly set false.

The distinction has to survive serialisation too: a writer that normalises absent to false on save freezes every run into an explicit override, and the element style stops meaning anything.

The offset that only worked at one size

While we were in there we found the underline offset — the distance from the baseline down to the rule — was a fixed constant rather than a fraction of the font size. Tuned at the default body size, where it was correct, it was wrong at every other size in both directions: too tight under a large heading, where it crowds the descenders, and floating too far away under small print.

Constants like this are easy to ship: you verify them at the size you happen to be looking at. Anything in points describing a relationship between glyphs should scale with the type, and the test is to render one string at your smallest and largest sizes, side by side.

The odd one out

When you have a set of parallel properties, the one that behaves differently is a bug until proven otherwise — very rarely a design decision somebody made and did not write down. Two of our four marks resolved against the parent and two did not, for no reason beyond having been written on different days.

The check is cheap. Put the implementations of properties meant to work the same way next to each other and look for the one with a different shape. Then ask of every optional property whether you can express "off, overriding the parent". If you cannot, it has two states pretending to be three, and something upstream is quietly losing a value.

Try it on your own document

Design a template in the browser and generate a real PDF — free, no card.