Reading a render's problems
A template can render without any error at all and still produce the wrong PDF — an asset that never arrived, a script that threw, a font the image does not have. A debug render watches the browser while it works and reports what it noticed. This page is about reading that report: where it comes from, what it looks like, and what each finding means.
Where a report comes from
Three surfaces produce the same findings:
pdf-server debug renderprints them and writes them into the bundle it produces — see CLI & configuration.- The
/_debugbundle routes return them inside the archive — see OpenAPI / Swagger. They exist only on a server started with--debug-enabled. - The tester UI's Problems tab lists them for the render you are looking at — see Tester UI.
An ordinary /templates/…/render request reports nothing. Collecting
diagnostics costs browser round trips, so a production render subscribes to
none of it.
The two artifacts
Every bundle carries both reports, whatever --artifacts asked for — they are
the one thing a debug render always produces:
| File | For |
|---|---|
problems.txt | reading — one line per finding, aligned into columns |
problems.jsonl | machines — one JSON object per line, in the same order |
The text report
A clean render is a single line:
ok pages=1 no problems
Anything else is a header line followed by one line per finding:
pages=1
! console-error fixture: console error
! js-exception Error: fixture: uncaught exception
~ console-warn fixture: console warning
A render that produced no document at all is headed by its failure instead of by measurements, and carries the reason as a finding of its own:
failed (data) pages=0
! render-failed data validation error: invalid data schema: missing properties: 'name'
The word ok appears only on a render that was actually judged, so a clean
first line and a non-zero exit code can never disagree. The classes are listed
under CLI & configuration.
The first character is the severity — ! error, ~ warning, . info — so the
worst findings are greppable. The columns after it are the kind, the page
(p2, or p2/3 when a finding spans pages), a kind-specific detail summary,
where it happened (a CSS selector, or the URL for a network finding), and the
offending text in quotes. A column is left empty when the finding has nothing
to put there, so the report stays aligned and diffable.
The JSON report
One object per line, the same findings in the same order:
{"kind":"http-error","severity":"error","source":"browser","detail":{"status":404,"url":"http://…/assets/logo.png"}}
| Field | Always | Meaning |
|---|---|---|
kind | yes | Which finding this is — the table below |
severity | yes | error, warn or info |
source | yes | Who noticed: browser, rule, custom or engine |
detail | yes | Kind-specific fields; the table below names them |
page | no | 1-based page the finding sits on, when it has one |
sel | no | CSS selector of the element, when it is about one |
text | no | A short excerpt of the offending text |
severity is what --fail-on compares against: with its default of error,
a console-error fails the command and a console-warn does not.
--fail-on warn fails on both, --fail-on never on neither, and the exit code
is the whole point in a pipeline.
source says who is talking, and the difference is between a fact and an
opinion. browser is something the browser told us about the document.
rule is a built-in layout rule's reading of the measured page — inferred,
not observed. custom is a rule the template itself ships (see
Layout rules). engine is the renderer talking about its
own run — not a defect in your template, but a fact about how the report itself
was produced.
What each kind means
From the browser
| Kind | Severity | detail | What it means |
|---|---|---|---|
asset-failed | error | url, error | The request never got an answer: an unreachable host, a refused connection, a blocked port. error is the browser's own net:: code. |
http-error | error | url, status | The request was answered with an error status. A 404 on an assets/… URL means the template names a file it does not ship. |
js-exception | error | message, stack | A script threw and nothing caught it. stack is a list of frames, capped by --diagnostics-stack-frames. |
console-error | error | message | The page called console.error. |
console-warn | warn | message | The page called console.warn. |
csp-violation | warn | message | The page's own Content-Security-Policy refused something it contains — most often an inline script. |
font-substituted | warn | requested, used, nodes | The page named a family, and the browser painted a different one — used — because the requested one is not in the image, or not in the weight or style the page asked for. Different advances mean different line breaks. A family's own weight or slope shipped under its own name (Open Sans SemiBold for Open Sans at 600) is the face that was asked for and is not reported; a width (Open Sans Condensed) is. See Assets and --diagnostics-accepted-font-substitutions. |
font-default | info | used, nodes | Some text was painted in a family the page never named, and used is what actually drew the glyphs. Not a defect on its own — a document can be happy with the browser's font — but it is the only visible trace of one that is: a stylesheet the browser could not parse names nothing, so the page renders in the default with nothing else to complain about. A page that names a family (even a generic one like sans-serif) for everything it paints reports nothing. |
lifecycle-timeout | error | awaited, reached | The page navigated but never reached the lifecycle event the render was waiting for. reached lists the events that did arrive, which is usually the answer: a page that reached networkIdle and was awaited on callback never called the callback. |
From the layout rules
These read the layout snapshot — the
measurement taken after the browser paginated the document — so every one of
them is about the printed result rather than the markup. sel, page and
text name the element, and by_px distances are CSS px.
| Kind | Severity | detail | What it means |
|---|---|---|---|
overflow-x | error | by_px | The box reaches past the right edge of the content box by by_px. Whatever is out there is off the paper. |
overflow-y | warn | by_px | A box the browser cannot break hangs by_px past the bottom of the page it started on. Only reported for a box marked break-inside: avoid or a replaced element — content flowing across a break is ordinary pagination, not a defect. |
break-split | warn | pages | An element the document declared unbreakable — break-inside: avoid, in either spelling, or a replaced element — was split anyway, across the pages pages lists. The declaration is a request; a block taller than the page cannot be honoured, and this says it was not. The rule reads the declaration from computed style, so any class name works and a keep-together class without the CSS is not checked. |
text-clipped | error | by_px | The browser reports the element's own content overflowing a box that hides it — part of it was never painted at all. by_px is how far that box itself reaches past the content box, which is 0 for a clipped box that sits inside the page. An element reported here is never also reported as overflow-x: the browser's own clip is the more specific finding for the same box. |
zero-size-image | error | url | A replaced element (img, svg, canvas) rendered with no area — most often an image that never loaded, in which case a http-error or asset-failed says why. url is the source it tried; it is empty for an element with none to read, such as a canvas. |
outside-content-box | error | by_px, edge | The box strays past the content box's left or top edge by by_px. These are the two edges overflow does not cover, since it only measures excess beyond the right and the bottom. |
empty-page | warn | page | Page page had no element laid out on it — a page a reader would open and find blank. |
sparse-final-page | info | page, filled_pct | The last page of a multi-page document is barely used: its content covers filled_pct of the content height, and that came out under 10%. A fact, not a verdict — plenty of documents legitimately run to a second page, and pages=N alone cannot tell two full pages from one page and a widow. It is the cue to open that page's image. |
orphaned-final-block | info | page | The last page exists to carry one block: everything on it is one top-level box and what sits inside that box. The widow a reader notices first, and the one a fill percentage cannot name — a page can measure a third full and still be one, because the block pushed onto it is a tall one. Info for the same reason as sparse-final-page: Chromium will not always honour break-after across a table boundary, and this is often not the author's to fix. |
tiny-font | info | size_px | An element's own text is set below 8px (6pt), the floor for readable print. Only an element that paints text of its own is reported — a container inherits a size it never draws anything at. A document that meant to set type this small can read past an info line. |
filled_pct is how much of the last page's content height the boxes assigned
to it cover between them, counting overlapping boxes — a table and its rows —
once. It is a coarse figure in one direction: the element table is a sample of
the significant boxes rather than every painted node, so a page reads emptier
than it looks, and the 10% threshold is set low to match. The practical
consequence is that a final page holding a single row and a totals block can
measure above the threshold and go unreported; the record catches the page that
is nearly bare, not every page that is thin.
empty-page and sparse-final-page stand down, reporting nothing, whenever
the element table is not the whole story: when pages_approximate says the
printed page count and the measured geometry disagreed, and when
elements_truncated says the cap dropped boxes. In both cases the elements that would have claimed the page may simply
be missing from the measurement, and reporting a page full of content as blank
is the one mistake a page-level rule must not make.
A cut table is reported in its own right, as diagnostics-capped with
cap: elements — because standing down is itself invisible, and so is a
template's own rule keyed on an element the cap dropped. Both report nothing,
and nothing is what a healthy document reports too. A render whose measurement
did not cover the whole document therefore never prints ok no problems.
From the template's own rules
A template can ship its own rules — see Layout rules.
Their findings are reported beside the built-in ones with source: custom, and
their kind, severity and detail are whatever the rule chose. One
spelling is reserved:
| Kind | Severity | detail | What it means |
|---|---|---|---|
custom-rule-failed | error | engine, rule, reason, and error or produced+cap | A rule did not run, or did not finish. A template may not emit it. |
custom-rule-failed carries the engine (jq or js) and the rule's path
relative to the template directory (diagnostics/totals-on-last-page.js), so a
template with a directory of rules says which one it is talking about. It gives
one of three reasons:
reason | Means | Extra detail |
|---|---|---|
load-error | The file could not be loaded at all: a syntax error, a missing exports.check, or a schema_version this build does not support. | error |
evaluation-error | It ran and failed: a thrown error, a jq runtime error, a malformed emitted problem, or an interrupt after the rule timeout. | error |
volume-cap-exceeded | It emitted more problems than one rule may contribute. The first cap are kept, the rest dropped. | produced, cap |
The render itself succeeded in every one of these cases, so failing it would be
a lie — but a clean report from a rule that never ran would be worse. The
failure is a problem in its own right at error severity, which is what keeps
--fail-on error from passing a template nothing actually checked.
From the engine
| Kind | Severity | detail | What it means |
|---|---|---|---|
diagnostics-capped | info | cap, limit, dropped, kind | A bound was reached and something was left out. The report is incomplete by exactly dropped — it never truncates silently. Raise the bound with the matching --diagnostics-* flag, or --layout-max-elements where cap is elements. |
render-retried | info | attempts, failed_attempts, earlier_problems | The render succeeded, but not on the first attempt. The findings you are reading come from the attempt that worked. |
multi-document-render | info | documents | The findings come from several documents rendered under one call — which is what a composition does. Without this line the merged list would read as one document's findings. |
policy-clamped | info | setting, asked, applied | The template's params.json asked for a diagnostics setting the operator's mode did not allow. setting names it, asked is what the template wrote and applied is what the render used. A template can tighten its own gate freely; loosening it is the operator's call — see --diagnostics-template-policy. |
render-failed | error | class, error | No document was produced at all, so nothing that follows is a judgement on the template. class names which kind of thing went wrong — see CLI & configuration — and it is always the report's last record. |
When the render fails
A render can fail outright — the page never reaches the event the render waits for, and the render times out. That is the case these findings are worth the most in, so it still produces a report:
- The bundle is written, and the HTTP routes still answer
200with it. problems.txtandproblems.jsonlcarry what the collectors saw, including thelifecycle-timeoutthat says which event never arrived.- Both are headed by the failure rather than by a clean line:
failed (render) pages=0, with arender-failedfinding carrying the reason. A failure the collectors have nothing to add to — data the schema rejected, say — is reported by that finding alone, never as an empty list. - The artifacts the browser would have produced —
document.pdf, the page images — are listed inmanifest.jsonasunavailable, with the render error as their reason.manifest.jsoncarries nopages. - The HTML parts are written anyway: they come from the template engine, which did its work before the browser ever started.
debug renderexits2. There is no document, so there is no verdict on the template — the report tells you what happened, the exit code tells a pipeline the check did not run.
What is never reported
- A font substitution the page did not ask for. The browser's own default is
measured in the page and never reported as a substitution: a page that set no
font-familychose nothing, so nothing was refused. What did paint the text is reported instead, at info, asfont-default. /favicon.ico. Chromium asks for one on every top-level document; the render server answers204so this harness does not manufacture anhttp-erroragainst every render ever made.- Anything a print header or footer references by URL. Chromium renders
those templates as a document of their own and does not fetch remote
subresources for them — the image is not merely broken, it is never requested.
Inline it as a
data:URI. See Header & footer.
Related
- Layout snapshot — the measurement the rule kinds above are read from, field by field.
- Layout rules — writing a template's own rules.
- CLI & configuration —
debug render,--fail-on, and every--diagnostics-*bound. - OpenAPI / Swagger — the
/_debugroutes. - Tester UI — the same findings, interactively.
- Security model — what
--debug-enabledexposes.