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.bold | Meaning |
|---|---|
| absent | inherit whatever the element says |
true | bold, whatever the element says |
false | not 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:
| Element | Run | run.u | run.u || el.u | run.u ?? el.u |
|---|---|---|---|---|
| off | absent | off | off | off |
| on | absent | off | on | on |
| on | false | off | on | off |
| off | true | on | on | on |
?? 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.