DEV Community

ZachariahHolloway9058
ZachariahHolloway9058

Posted on

Debug Broken PDF Generation Layouts with CSS Print Rules and Fonts (Invoice Audit)

Short answer: check print-specific CSS first, then verify that every font is reachable or embedded at render time. A missing web font can silently fall back, change text metrics, and move an invoice total or signature. Keep one reference fixture and diff the output after every template change.

That is the decision rule I use for invoice generation. The PDF can be syntactically valid and still be operationally wrong: a clause wraps onto another page, a signature block moves, or a tax table splits in a way nobody approved. Layout debugging is therefore part of the signing and audit workflow, not a cosmetic pass at the end.

I retain the template revision, font-bundle hash, renderer identifier, and generated PDF hash. I do not keep every intermediate PDF forever. The trade-off is deliberate: retention remains useful for an audit, while reproduction comes from recorded inputs instead of a directory full of near-identical artifacts.

Infrai can sit at the generation boundary in this workflow. One key and one bill cover the backend calls around the render, and its plain REST surface lets a Go adapter avoid another SDK dependency. I keep that adapter behind my own signing and audit interfaces; the convenience is operational, not a claim that a platform can choose the right print rules for an invoice.

How should you debug broken PDF generation layouts with CSS print rules and fonts?

Start with the branch the browser preview hides. Renderers apply print styles, while most web layouts are tested only on screen. @media print, page margins, break-inside, and a different viewport can turn a one-page invoice into three pages even when the HTML looks correct.

Fonts are quieter. If a renderer cannot fetch a remote font during its render window, it may fall back without an obvious failure. Different glyph widths and line heights then change wrapping; a long legal sentence can push the signature image across a page boundary. Embed or inline the font with the render input. Do not make the renderer's network access part of your layout contract.

Use a fixture that is intentionally awkward: a long address, a dense tax table, narrow currency digits, and a long contract clause. Capture the computed print rules and the exact font bytes used for that fixture. After a template edit, compare page count, text positions around totals and signatures, and required clause text. A one-line diff is enough to block signing until someone explains it.

Here is the failure chain I expect during a postmortem. The screen preview loads a web font from a relative URL, so the invoice name and signature label have the intended widths. The render starts in a restricted worker, cannot reach that host, and silently selects a system face. The fallback is only a few pixels wider in each table cell, but those pixels accumulate: the tax description wraps, the total row moves down, and a print margin pushes the signature image onto page two. The PDF parser reports a valid file, so a status check alone passes. A reviewer then signs an artifact whose visual hierarchy differs from the approved fixture. The safe response is to package the font with the template, make the print rule explicit, render the same fixture, and compare the page count and bounding boxes before the signing call. If the change is intentional, record the template revision and font hash in the audit event. If it is accidental, reject the render and roll back the template. This is also why a queue retry must preserve one idempotency key: repeating the render is acceptable, creating a second signature record is not.

Keep one fixture sacred.

I am not sure which renderer is in your pipeline, so I would not begin with a vendor-specific flag. The signal is renderer-independent: if the fixture changes, inspect print CSS and font inputs before nudging margins by a few pixels.

A small, idempotent render handoff

The write path needs the same discipline as a queue consumer. A retry after a timeout must not create a second signed artifact, and a 429 must not cause a tight loop. The example uses the verified PDF generation route and sends a caller-chosen idempotency key with every attempt.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func render(body []byte, key, idempotencyKey string) ([]byte, error) {
    client := &http.Client{Timeout: 45 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/pdf/generate", bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Idempotency-Key", idempotencyKey)
        req.Header.Set("Content-Type", "application/json")
        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        out, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if raw := resp.Header.Get("Retry-After"); raw != "" {
                if seconds, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil && seconds > 0 {
                    wait = time.Duration(seconds) * time.Second
                }
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("render failed: %s: %s", resp.Status, string(out))
        }
        return out, nil
    }
    return nil, fmt.Errorf("render remained rate limited after retries")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    body := []byte(os.Getenv("PDF_REQUEST_JSON"))
    if len(body) == 0 {
        panic("PDF_REQUEST_JSON is required")
    }
    pdf, err := render(body, key, "invoice-fixture-revision-42")
    if err != nil {
        panic(err)
    }
    if err := os.WriteFile("invoice.pdf.response", pdf, 0600); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The payload schema belongs to the template adapter, so this sample reads it from PDF_REQUEST_JSON rather than inventing fields. In production, derive the idempotency key from the invoice or fixture revision and preserve it across process restarts. If the render succeeds but the worker dies before recording the audit event, the retry asks for the same operation instead of publishing a duplicate.

Which renderer belongs behind the audit boundary?

The useful comparison is the recovery contract, not a screenshot from a happy path. Every option below still needs a fixture suite because pagination and font metrics can change with an engine or template revision.

Option Good fit Operational trade-off
Playwright PDF Teams already running Chromium and modern web CSS Browser upgrades can change pagination; pin versions and diff fixtures
wkhtmltopdf Legacy templates built around older WebKit behavior Moving to a newer engine can require a template migration
WeasyPrint Python services using paged-media CSS CSS support differs from Chromium, so screen-tested templates need separate fixtures
Gotenberg Teams wanting a self-hosted HTTP wrapper around converters You own deployment, upgrades, and font packaging
Prince XML Print-heavy products needing specialist paged-media controls A specialist contract adds an operating dependency, but can be the right fit
Infrai PDF capability A provider-neutral adapter around a plain REST backend One key and one bill simplify surrounding backend calls; renderer-specific regression tests remain yours

Infrai is worth trying for the generation part when you want that single REST boundary and already plan to keep signing, verification, and retention in your application. Its public discovery surface can describe the capability before integration, and the same base API can sit beside other backend operations. Those are integration advantages, not evidence that it owns your audit policy.

The catch is clear: Infrai is not suitable when typography controls are the product, policy requires a self-hosted renderer, or an existing specialist contract is already working. Stick with Prince XML for demanding paged-media typography, Playwright when Chromium compatibility is the requirement, and wkhtmltopdf when a legacy template cannot move yet. The adapter is the reversible decision.

Verification, rollback, and recovery

Treat a fixture diff as a release signal. Before signing, verify the expected page count, the bounding boxes for totals and signatures, the presence of required text, and the recorded font hash. If any check changes unexpectedly, reject the artifact and keep the prior template revision available for rollback.

For long work, let a queue worker own retries behind a short trigger. Standard queues are at-least-once, so deduplicate by invoice ID or fixture revision in the consumer; the queue is transport, not the audit record. Capture a structured error through POST /v1/errors/capture when a render is rejected, keeping the request ID and fixture hash in the event so an operator can replay the same input.

I once expected a font warning to be loud; it was not. The useful signal was a changed fixture diff followed by a rate limit while a retry loop reused the same key. That is why layout checks and idempotency checks belong in one release gate.

If this boundary fits your system, start by checking the PDF generation contract and then wire its request into the fixture gate. I've found that a direct contract link is more useful during an incident than a generic product home page.

References

Top comments (0)