TL;DR: keep the OCR source immutable, render a small representative page set, and compare geometry before you tune CSS. For property-management scans, a broken-looking PDF is often a font-metric or pagination mismatch, not a bad OCR model. The least complex fix is to make font files explicit, apply print rules in a dedicated stylesheet, and reject a render when page geometry drifts beyond a stated tolerance.
A lease packet can be searchable and still be unusable. A tenant name may be present in the text layer while the visible signature block moves to page three, a table clips its final column, or a glyph falls back to a different width. People reviewing maintenance invoices notice the visual defect first; indexing jobs notice the text defect later. Treat both as one pipeline with separate checks.
Ship the gate.
How can property teams debug broken PDF generation layouts?
The first useful debug artifact is a side-by-side record, not a screenshot alone: keep the source image, the normalized OCR text, the HTML snapshot, the print stylesheet revision, and the rendered page metadata together under one fixture id. When a totals row shifts, this record tells you if the source coordinates changed, if a line wrapped earlier, or if the page box was reduced by a margin rule. It also keeps a later font update from being mistaken for an OCR regression. That extra bookkeeping feels slow on a single document, yet it shortens the investigation once a queue contains hundreds of nearly identical leases.
Use two gates. The first gate checks that the generated file is structurally readable: it has a valid PDF header, a page count greater than zero, and extractable text for pages expected to contain text. The second gate checks visual intent: repeated anchors such as “Invoice total”, “Property address”, and “Approval” stay inside their expected rectangles. The anchor list belongs to your document template, not to a rendering vendor.
A practical flow for a scanned work order is: retain the original image, normalize orientation, run OCR, create a semantic HTML view, render that view with print CSS, extract text and geometry from the result, then publish an index record only after both gates pass. The original image remains the audit artifact. The rendered PDF is a derivative.
Here is a compact TypeScript shape for the decision. The renderer and text extractor are interfaces so the same checks can run against a local browser process, a worker service, or a different implementation later.
type Anchor = {
label: string;
expectedPage: number;
minX: number;
minY: number;
maxX: number;
maxY: number;
};
type RenderedPage = {
widthPt: number;
heightPt: number;
text: string;
anchors: Array<{ label: string; x: number; y: number }>;
};
interface DocumentRenderer {
render(html: string, css: string): Promise<RenderedPage[]>;
}
function checkPage(page: RenderedPage, anchors: Anchor[], pageNumber: number): string[] {
const errors: string[] = [];
if (page.widthPt < 500 || page.heightPt < 700) {
errors.push(`page ${pageNumber}: unexpected media box ${page.widthPt}x${page.heightPt}pt`);
}
for (const anchor of anchors.filter((item) => item.expectedPage === pageNumber)) {
const found = page.anchors.find((item) => item.label === anchor.label);
if (!found) {
errors.push(`page ${pageNumber}: missing anchor ${anchor.label}`);
continue;
}
const inside = found.x >= anchor.minX && found.x <= anchor.maxX &&
found.y >= anchor.minY && found.y <= anchor.maxY;
if (!inside) errors.push(`page ${pageNumber}: moved anchor ${anchor.label}`);
}
if (page.text.trim().length === 0) {
errors.push(`page ${pageNumber}: empty text layer`);
}
return errors;
}
export async function validateRender(
renderer: DocumentRenderer,
html: string,
printCss: string,
anchors: Anchor[],
): Promise<{ ok: boolean; errors: string[] }> {
const pages = await renderer.render(html, printCss);
const errors = pages.flatMap((page, index) => checkPage(page, anchors, index + 1));
return { ok: errors.length === 0, errors };
}
The dimensions in this example are policy values for one template family. Measure your own media box and anchor ranges from approved fixtures; do not copy the thresholds blindly. The useful part is the separation between rendering and acceptance.
Which symptom points to a font problem?
Start with the text that moved, not the CSS declaration that looks suspicious. If every line wraps early, compare the loaded font family and weight first. If only one symbol changes width, inspect fallback glyphs and the font’s character coverage. If a footer jumps while body copy looks stable, check line-height, ascent, descent, and the available page area. A browser can lay out the same words differently when a requested font is unavailable at render time.
Make font loading deterministic in print CSS. Use a bundled file with a declared weight, wait for the document’s font set before asking the renderer for a page, and keep synthetic bold or italic disabled for fixtures that matter. A font swap after the first layout is a classic source of a one-line overflow.
export async function waitForPrintFonts(page: {
evaluate<T>(fn: () => Promise<T>): Promise<T>;
}): Promise<void> {
await page.evaluate(async () => {
await (document as Document & { fonts: FontFaceSet }).fonts.ready;
});
}
The call is intentionally small. It does not prove that the desired family loaded. Add a fixture assertion that records the resolved family for a known character, and fail the job when the result is a fallback. Keep that assertion near the renderer adapter so a future runtime cannot silently change the contract.
CSS print rules deserve the same discipline. Put page size, margins, breaks, and visibility in a print-only file. Avoid using screen flex layouts as the source of truth for a form with fixed columns; flex items can shrink in ways that are harmless on screen and destructive on paper. Prefer explicit widths for totals, dates, and account codes. Use break-inside: avoid on a signature or approval group, then test the case where the group is larger than the remaining space. No CSS rule can keep an oversized block on a page without either moving it or overflowing it.
Use four fixtures as the minimum release gate: one short invoice, one long invoice, one sparse work order, and one rotated scan.
How do you isolate pagination drift without guessing?
Render a fixture matrix rather than one happy-path lease. Include a one-page invoice, a two-page invoice with a long vendor name, a work order with an empty optional field, and a packet containing a rotated scan. Store the expected page count and a few anchor boxes as reviewable JSON. A pixel-perfect image diff is noisy across operating systems; anchor geometry and extracted text are cheaper signals for a queue gate. Keep a small visual sample for human review when geometry passes but the document still looks wrong.
| Symptom | First comparison | Likely boundary |
|---|---|---|
| Final table column is clipped | content box versus media box | width, margin, or scale |
| Signature block starts on a new page | remaining page height | break rule or line-height |
| Numbers overlap labels | glyph widths in the loaded font | fallback or synthetic weight |
| Text search misses a visible line | extracted text versus source OCR | text-layer placement |
| Only rotated scans fail | page rotation metadata | normalization before layout |
Log a render fingerprint with the fixture id, template revision, font file hashes, page dimensions, page count, and validation errors. Do not log the tenant’s full document text. A hash plus a redacted anchor label is enough to correlate retries while keeping the operational record narrow.
When a check fails, retain the derivative in quarantine and expose a review link that points to the immutable source image. Retrying the identical inputs against the identical renderer rarely repairs a deterministic layout error. Retry only after a classified transient failure, such as a worker restart or a missing font asset that the deployment can restore.
What does fidelity cost in a property workload?
Fidelity is a budget, not a single switch. A statement sent to a resident may need exact pagination and selectable text. An internal maintenance receipt may need reliable search and a legible total, while a decorative background can be dropped. Define classes such as legal, resident-facing, and internal-search, then assign each class a render policy and a validation depth. This lets a solo team spend CPU on pages where a visual defect has a real operational consequence.
Measure cost per accepted page, not cost per request. A retry that produces a second broken file is pure waste, while an early rejection with a useful error saves downstream OCR and indexing work. Keep the worker concurrency low enough that font loading and memory use remain predictable, and raise it only after queue latency and rejection rate are visible.
Vendor portability follows from the same boundaries. A browser-based renderer, a native PDF library, and a hosted conversion API differ in CSS coverage, font handling, startup time, and observability. Compare those dimensions with the fixture matrix. Do not infer quality from a marketing sample or from a single short invoice. Keep the adapter narrow: render, waitForFonts, and collectGeometry are more portable than a long list of provider-specific routes.
The operational checklist fits in prose: pin the template revision, package the exact fonts, wait for the font set, render representative fixtures, validate page boxes and anchors, quarantine failures, and publish only accepted derivatives. Record enough metadata to reproduce the decision, then review a rotating visual sample. That loop catches the expensive defects before they become search results that nobody trusts.
The limitation is practical: a native library may ignore a print-only CSS feature, while a browser process may consume more memory per worker. That trade-off is acceptable only for fixture classes that need the feature.
Stop guessing.
Further reading
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
- MDN,
@pageCSS at-rule: https://developer.mozilla.org/en-US/docs/Web/CSS/@page - MDN, CSS
break-inside: https://developer.mozilla.org/en-US/docs/Web/CSS/break-inside - MDN, CSS Font Loading API: https://developer.mozilla.org/en-US/docs/Web/API/CSS_Font_Loading_API
- W3C, CSS Paged Media Module Level 3: https://www.w3.org/TR/css-page-3/













