Layout rules
A template renders to HTML, and HTML lays out into pages — a step no schema can check. "The totals block must not be split across a page" or "this table must land before the appendix" are invariants about the printed result, not the input data. A layout rule checks exactly that: a small jq or JavaScript script, shipped next to the template, that inspects the rendered document's layout and reports problems.
Layout rules are one input to the layout-and-diagnostics report the pdf-server
debugging surface produces alongside the PDF, and which its --fail-on flag
can turn into a CI gate. This page is about writing the rules themselves —
what they see, the two engines available, and what happens when a rule itself
is broken.
What a rule sees
A rule runs against the snapshot — a JSON description of every significant
element on the page after the browser has laid it out for print: its
document-order position (sel), which page or pages it lands on (pages),
its text (text, for grepping back to the template source), and its geometry.
The document also carries page-level facts — how many pages the document has,
paper size, and so on — at the top level, alongside an elements array.
You do not write the walker that produces this document; you only read it. Two engines can read it:
| Extension | Engine | Best for |
|---|---|---|
.jq | jq (via gojq) | a single filter expression |
.js | JavaScript (via goja) | loops, helper functions, richer logic |
Where rules live
Rules go in a diagnostics/ directory beside the template. Every .jq and
.js file directly inside it is one rule:
invoice/
template.mustache
schema.json
diagnostics/
split-keep-together.jq
totals-on-last-page.js
lib/
geometry.js
Any number of rules, in either engine, in any mix — including none. A template
with no diagnostics/ directory is checked by nothing beyond the built-in
layout rules (overflow, clipped text, and so on), at no cost.
Write one rule per invariant and name the file after it. A rule that checks one thing says what it checks in its filename, fails on its own without taking the others down, and can be deleted on its own when the invariant stops mattering.
Two details are worth knowing:
- Nested directories are not scanned.
lib/geometry.jsabove is not a rule — it is a module the rulesrequire, which is exactly how to share logic between them without the helper being run as a rule and reported for having nocheckto call. - Rules run in filename order, and their problems are reported in that
order. It rarely matters, but when it does, a numeric prefix
(
10-…,20-…) is the way to pin it.
Sharing rules between templates
A rule library several templates use is one copy on disk, symlinked into each of them. Two arrangements work, and the choice between them is whether a template takes the whole library or picks from it:
- Link the directory —
invoice/diagnostics→ the shared library. The template takes every rule in it, and those rules mayrequireshared helpers: the require root follows the link, so the library's ownlib/is inside it. - Link a rule file —
invoice/diagnostics/house-style.jq→ one file in the library, sitting alongside the template's own rules. Reports name it by its path under the template, so they point at a file that exists in the template being debugged. Itsrequirestill resolves under this template'sdiagnostics/, not the library's, so a shared rule that needs shared helpers wants the arrangement above.
What does not work is linking a helper directory into an otherwise local
diagnostics/. require refuses a symlink that leaves the rules directory —
the sandbox cannot tell an author's shortcut from an attempt to read the host —
so the rule that follows it reports custom-rule-failed and the template's
other rules keep running. A link whose target has been moved away is reported
the same way, rather than going quiet.
jq rules
A jq program whose output is an array of problem objects. It must start with a version declaration, a jq comment naming the snapshot schema version the rule was written against:
# schema_version: 2
[ .elements[]
| select(.unsplittable)
| select(.pages | length > 1)
| { kind: "split-keep-together",
severity: "error",
sel: .sel,
page: .pages[0],
text: .text,
detail: { pages: .pages } } ]
This flags every element the author declared unbreakable whose box spans more
than one page. unsplittable is the author's own break-inside: avoid, read
from computed style — the intent does not guarantee the browser honored it, so
the rule checks the result against the declaration rather than against a
class name that may or may not carry the CSS.
A built-in already covers it?
Before writing a rule, check whether a built-in reports it and just needs a different threshold or severity — the diagnostics policy sets both, per deployment and per template, without any code. A custom rule is for the invariant that is yours: a house minimum, a block that must not be orphaned, a total that must land on the last page.
JavaScript rules
Export a schemaVersion number and a check(snapshot) function returning an
array of problem objects:
exports.schemaVersion = 2
exports.check = function (snapshot) {
const totals = snapshot.elements.find((el) => el.classes.indexOf('totals') >= 0)
if (!totals) {
return [{ kind: 'totals-block-missing', severity: 'warn', detail: { selector: '.totals' } }]
}
const landedOn = totals.pages[totals.pages.length - 1]
if (landedOn === snapshot.pages) return []
return [{
kind: 'totals-not-on-last-page',
severity: 'warn',
sel: totals.sel,
page: landedOn,
detail: { landed_on: landedOn, last_page: snapshot.pages },
}]
}
A rule that cannot find its element must say so
totals-block-missing above is a reported problem, not a return [], and that
is the part to copy. A rule whose element is not in the snapshot matches
nothing and reports nothing — which is precisely what a rule reports when the
document is healthy. Shrug at the absence and an invariant can stop being
checked with nothing in the report to show for it.
The absence is not hypothetical: the walker lists the boxes a reader could act on, not every node, so an element can be missing for reasons that have nothing to do with the defect you are checking — see which elements are listed. A container carrying a class its children do not carry is always listed, so selecting by the class you wrote in your markup is safe; a box with no area, one smaller than a few px, or a leaf with no text, border or background of its own is not listed at all.
Rather than guess which of those applies, ask the snapshot you already have.
snapshot.jsonl is one element per line, so the selector a rule is about to
key on can be looked up before the rule is written:
jq -c 'select(.sel | test("tr\\.grand"))' snapshot.jsonl
Nothing back means the rule would have matched nothing. Grepping the class
itself — jq -c 'select(.classes | index("grand"))' snapshot.jsonl — answers
the follow-up question, which box did get listed instead.
A second invariant: the block pushed onto a page of its own
The rule above answers "did the totals end up on the last page". It cannot answer "is the totals block alone on the last page" — the widow a reader notices first — because in that case the totals is on the last page. That is a second rule, and geometry is what separates the block's own descendants from the content that would keep it company:
exports.schemaVersion = 2
exports.check = function (snapshot) {
if (snapshot.pages < 2 || snapshot.pages_approximate || snapshot.elements_truncated) return []
const totals = snapshot.elements.find((el) => el.classes.indexOf('totals') >= 0)
if (!totals || totals.pages.indexOf(snapshot.pages) < 0) return []
const above = (el) => el.box.y + el.box.h <= totals.box.y
const below = (el) => el.box.y >= totals.box.y + totals.box.h
const company = snapshot.elements.filter(
(el) => el.pages.indexOf(snapshot.pages) >= 0 && (above(el) || below(el)),
)
if (company.length > 0) return []
return [{
kind: 'totals-alone-on-last-page',
severity: 'warn',
sel: totals.sel,
page: snapshot.pages,
detail: { page: snapshot.pages },
}]
}
Anything the block contains sits inside its vertical extent, so filtering on boxes that end above it or start below it leaves exactly the elements that are not part of it. The two page-level guards are the ones the built-in rules use, and for the same reason: when the page assignment is indicative rather than measured, "nothing else on this page" is not a fact.
Severity is a judgement, and warn here is a deliberate one. Chromium will not
always honour break-after: avoid across a table boundary, so this can be an
irreducible fact of pagination rather than a defect in the template — the
built-in sparse-final-page
reports the same page at info for that reason. A rule that hard-fails on
something the author cannot fix is a rule people learn to disable.
The runtime has no filesystem or network access — only console and
CommonJS require('./file.js'), scoped to the diagnostics/ directory so a
rule reaches its own helpers and nothing above them. It is the same sandbox the
composition JavaScript planner runs in.
The problem shape
Both engines return the same shape, one object per problem:
| Field | Required | Meaning |
|---|---|---|
kind | yes | A short, author-chosen name matching ^[a-z][a-z0-9-]*$. Printed verbatim in every report. |
severity | yes | One of error, warn, info. |
sel | no | The element's selector, so a reader can jump to it. |
page | no | The page number the problem concerns. |
text | no | A text anchor back into the template source. |
detail | no | Any object — extra data specific to the rule. |
kind may be anything you like, with one exception: custom-rule-failed is
reserved. A rule that returns it — or anything else malformed (a missing
kind, a bad severity, a non-array result) — is treated as a failed
rule, not as a problem it reported; see below.
The schema version gate
The snapshot's shape (schema_version in the contract) is versioned on
purpose: once a rule is written against it, that shape is a promise to the
rule, not an internal detail free to change. A rule that declares a version
this build does not support is treated exactly like any other load-time
failure — see below. A rule is never silently skipped for targeting the wrong
version; a rule that quietly stopped matching would be worse than no rule at
all.
When a rule itself is wrong
A broken rule never fails the render, and it never fails the template either: the template still loads and still renders, whether the rule is broken at load time or only fails later, at evaluation time. What changes is what that one rule reports instead of its normal problems:
- At load time — a jq or JavaScript syntax error, a missing
exports.check, a read error, or a version this build does not support — the broken file is swapped for a stand-in: every time it would have run, it reports onecustom-rule-failedproblem instead, aterrorseverity, carrying the load error and the rule's path in its detail. One broken rule never takes the others down — every file is loaded and gated on its own, which is what makes a directory of small rules safe to keep adding to. - At evaluation time — a jq runtime error, a thrown JS error, a rule that
does not finish within a few seconds (it is looping — a diagnostics rule
runs over at most a few hundred elements, so a rule this slow is broken, not
thorough), or a rule emitting far more problems than the report can carry —
the render itself still succeeded. Failing it would be a lie. You get the
same
custom-rule-failedproblem instead.
Either way, --fail-on error still catches it: a rule that never actually ran
is never reported as a clean pass.
Worked example
examples/templates/layout_rules/ ships a diagnostics/ directory with one
rule per invariant — split-keep-together.jq and totals-on-last-page.js —
and two data sets, rendered live by make test-integration. Read it alongside
this page for a template that exercises them end to end.
The long example lays out cleanly: neither rule has anything to say about it,
and the report reads ok. The split_terms example adds a terms-and-conditions
block longer than a page, which is the case break-inside: avoid cannot honour
— so both rules fire at once, beside the built-in ones:
pages=3 paper=595x842pt content=509x755pt scale=1.0
! split-keep-together p1/2 section.terms "Terms & conditions 1. The supplier warrants…"
~ overflow-y p1 +494.99px section.terms "Terms & conditions 1. The supplier warrants…"
~ break-split p1/2 section.terms "Terms & conditions 1. The supplier warrants…"
~ totals-not-on-last-page p1 landed_on=1 last_page=3 section.totals
The first line is split-keep-together.jq reporting the block it was written
to catch; the last is totals-on-last-page.js noticing that the totals no
longer sit on the final page once the terms pushed the document to three. The
two in between are built-in layout rules — a template's own findings are
reported beside them with no distinction beyond severity.
See also
- Compositions — the same two engines (jq, JavaScript), used to plan which templates render, not to check their layout.
- Data validation — the other kind of authoring check, on the request data rather than the rendered layout.