DEV Community

EllsworthPierce7528
EllsworthPierce7528

Posted on

Go PDF Generation: Why Print Layout Is Harder Than HTML Rendering

PDF generation becomes an operations problem the moment a document can cross a page boundary. HTML gets a viewport that can keep growing; PDF has fixed pages, so a renderer must commit to where content stops, what repeats, and what moves to the next page.

TL;DR: For a service that merges and splits document bundles, choose the rendering approach by who owns the template and its print rules. Keep templates beside application code when engineers own every change. Use a managed API when several runtimes must submit the same job shape and the team wants the rendering boundary outside the application. In either case, test long fixtures, make bundle assembly idempotent, and treat page count as output rather than an input promise.

The incident-shaped scenario is easy to recognize without inventing a dramatic outage. A three-page sample passes review. Production data adds a long table, one row moves, the header does not repeat, and a later split extracts the wrong contractual attachment because its assumed page offset changed. I would write that review around one invariant: bundle parts are identified by logical document boundaries, never by page numbers predicted before rendering.

Short inputs lie.

Why is PDF generation harder than rendering HTML for print?

A browser lays content into a continuous canvas. It can expand a table, reflow a paragraph after a font loads, and let the reader scroll. A PDF conforms to a fixed-page document model. Before emitting the file, the rendering system has to resolve pagination: which line stays with a heading, whether a table row may split, where a footer lands, and what happens when an image is taller than the remaining area.

That is why print stylesheets exist. Screen layout does not translate automatically. Rules such as @page, print margins, break controls, and repeated table headers describe decisions that a screen renderer does not need to make. They are policy, not polish.

Bundle processing raises the stakes. Suppose a developer-tools platform produces a cover sheet, a variable-length audit report, and two appendices, then merges them for delivery and splits them later for retention. The audit report might be 7 pages today and 11 tomorrow. If the split plan says “appendix A starts on page 9,” content has leaked into orchestration. If it says “merge these four independently rendered artifacts, retain the artifact IDs, and split only at recorded part boundaries,” pagination remains inside the template owner’s domain.

Headers and repeated table rows are where this distinction becomes visible. A renderer may understand the CSS rule, but the template still has to define the correct header, reserve its space, and exercise it against enough rows to create a second page. A one-page golden file proves almost nothing about that contract.

Template ownership is the real architecture decision

There are two useful ownership models. In an application-owned model, the Go service stores the HTML and print CSS, pins the renderer, and reviews template changes with code. This gives one team tight control over fonts, release timing, and regression fixtures. It also makes that team responsible for browser or renderer upgrades, asset loading, and every pagination discrepancy.

In a service-owned model, the application sends work across an API boundary and the rendering service owns execution. This is attractive when Go, Node.js, and batch jobs must all produce the same class of bundle. Infrai offers one plain REST API, one API key, and one consolidated bill across 295 routes in 20 modules; it requires no vendor SDK, and its self-describing public discovery surface returns the full request JSON Schema and response schema without authentication. For a bundle worker, this means one credential can cover PDF generation and later storage or queue work, while the worker validates the current PDF job shape before submission. None of these conveniences removes the need to own print CSS and adversarial fixtures somewhere.

Do not confuse service ownership with template ownership. A managed renderer can execute a template while your repository remains the authority for its markup, styles, version, and approval history. Conversely, a self-hosted binary does not guarantee control if templates live in an unreviewed admin screen.

My decision rule is blunt: put the template where the people accountable for a broken page can review and roll back it. Then make the job record carry the template version and logical bundle-part IDs. Renderer choice comes after that.

Comparing the credible options fairly

The products below occupy different points on the ownership boundary. None can infer whether a heading, signature block, or attachment is legally allowed to split. The comparison is about where the renderer runs and where operational responsibility lands.

Option Execution and template boundary Good fit Limit to plan for
WeasyPrint Python library driven by HTML and CSS; templates and execution stay with the application team Teams comfortable operating a Python rendering component and keeping print rules in source control The team owns dependency upgrades, fonts, capacity, and regression testing
Prince Dedicated HTML-to-PDF engine with extensive print-CSS support Documents whose pagination and print typography justify a specialized engine Licensing and engine deployment become part of the platform lifecycle
DocRaptor Hosted document API using HTML/CSS input Teams that want a managed request boundary while retaining HTML template control External-service behavior, data handling, and retry semantics must enter the runbook
Gotenberg Containerized API for document conversion, commonly operated in your infrastructure Platform teams that want an HTTP boundary but need to run the service themselves You still own scaling, upgrades, fonts, and renderer isolation
Plain REST platform HTTP API with no required SDK; the application can keep versioned templates and submit jobs from any HTTP-capable runtime Polyglot systems that value one consistent API boundary Template review, long-content fixtures, and logical bundle metadata remain application responsibilities

WeasyPrint is the natural starting point when Python is already part of the platform and the team wants a library, not another network dependency. Prince is a stronger candidate when sophisticated paged-media behavior is the primary requirement and a commercial engine is acceptable. DocRaptor moves execution outside your infrastructure. Gotenberg keeps an API boundary while leaving operations with you.

These are materially different choices. “HTML to PDF” is too broad a checkbox to select among them. Evaluate the exact print rules in your documents, the permitted data boundary, deployment ownership, and how each option exposes failures and retry behavior. Run the same ugly fixture through every serious candidate. Marketing samples are usually one page for a reason.

The plain REST option is a poor fit when policy requires all document bytes to remain inside your network, or when the team needs specialized paged-media behavior that its service contract does not establish. Choose self-operated Gotenberg in the first case, assuming the platform team accepts the operational load; evaluate Prince in the second. A Python team that wants the smallest local dependency boundary should start with WeasyPrint. Those are real trade-offs, not consolation prizes.

Make bundle assembly retry-safe in Go

The preventative path starts before rendering. Give each logical part a stable identity, reject accidental duplicates, preserve order explicitly, and derive a deterministic operation key from the complete manifest. The renderer can then change page counts without changing what belongs in the bundle.

The request path should carry the same retry discipline. This runnable Go client accepts a JSON body whose fields were obtained from the service's public discovery schema, rather than freezing an undocumented shape in the client. Set DOCGEN_BASE_URL to the configured API v1 base, INFRAI_API_KEY to the secret, PDF_REQUEST_JSON to that validated request, and IDEMPOTENCY_KEY to a stable key derived from the bundle manifest. In a real worker, persist the completed output before acknowledging the queue message. If delivery repeats, return the stored result.

package main

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

func required(name string) string {
    v := os.Getenv(name)
    if v == "" {
        log.Fatalf("%s is required", name)
    }
    return v
}

func retryDelay(h http.Header, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(h.Get("Retry-After")); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Second * time.Duration(1<<attempt)
}

func main() {
    baseURL := strings.TrimRight(required("DOCGEN_BASE_URL"), "/")
    apiKey := required("INFRAI_API_KEY")
    idempotencyKey := required("IDEMPOTENCY_KEY")
    body := []byte(required("PDF_REQUEST_JSON"))
    if !json.Valid(body) {
        log.Fatal("PDF_REQUEST_JSON must be valid JSON")
    }

    client := &http.Client{Timeout: 2 * time.Minute}
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+"/pdf/generate", bytes.NewReader(body))
        if err != nil {
            log.Fatal(err)
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            log.Fatal(err)
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            log.Fatal(readErr)
        }

        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            time.Sleep(retryDelay(resp.Header, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            log.Fatalf("PDF generation failed: status=%d body=%s", resp.StatusCode, responseBody)
        }
        fmt.Println(string(responseBody))
        return
    }
    log.Fatal("PDF generation exhausted retry limit")
}
Enter fullscreen mode Exit fullscreen mode

The small trap is the idempotency key. Derive it from the bundle ID, ordered part IDs, source digests, and template versions. Sorting parts to make the key “stable” would change the requested bundle when order is meaningful. Preserve caller order and require a new bundle ID when that order changes. Idempotency protects a retry of the same intent; it must not collapse two different intents.

Rendering and assembly also need separate states. Record that audit-v8 rendered successfully, along with the resulting artifact identity, before merging. If merge fails after rendering four expensive parts, a retry should reuse those exact artifacts. This is the same discipline used for an at-least-once queue consumer: acknowledge only after the durable result exists, and make every repeated step converge on that result.

Test the page boundary, not the happy path

A useful fixture set is deliberately uneven. Include a one-page document for basic smoke coverage, then inputs that create 2, 3, and 12 pages. Put a heading in the final two lines of a page. Create a table long enough to repeat its header twice. Add one row whose cell wraps to five lines, an image near the printable width, and an appendix whose first page must start on a fresh sheet.

Do not assert only the final byte hash. PDF metadata and renderer versions can change bytes without changing the visible contract. Check the properties that matter: expected logical parts are present and ordered; required text survives; the table header appears after a break; forbidden blank pages do not appear; and the split artifacts map back to recorded part IDs. Visual regression images are useful for a smaller set of critical pages, provided font and renderer versions are controlled.

Three operational numbers belong in the runbook: maximum accepted source size, job timeout, and maximum output size. Their values depend on your system, so measure and set them rather than copying somebody else’s limits. Also define the retry ceiling and quarantine path. A malformed template should not circle through the queue indefinitely.

Keep that limit explicit.

The advice has boundaries. For a fixed, single-page certificate with bounded fields, page-break testing may add little value; strict dimensions and font checks dominate. For archival or regulated documents, conformance profiles, signatures, accessibility, and retention controls deserve their own evaluation. And if users are authoring free-form content, HTML print CSS may not offer enough control: a dedicated composition engine or constrained document model can be the more honest choice.

The durable design is unglamorous. Own the template explicitly, render parts independently, record their identities, and merge from a manifest that survives retries. Pages may move. The bundle must not.

Sources

References used for the document model, print-layout behavior, and product boundaries:

Top comments (0)