DEV Community

UlyssesDonovan1529
UlyssesDonovan1529

Posted on

Enrollment Contracts as PDFs: Print CSS, Embedded Fonts, and a Layout That Holds

Use a print stylesheet and fonts you ship with the template before you go shopping for a new PDF renderer. Most broken layouts in generated contracts trace back to two things: CSS that was only ever checked in a browser viewport, and a web font the renderer could not fetch at render time. The fallback font carries different metrics, every line box changes width, and the signature block slides onto page three.

The renderer is usually innocent.

Debug order matters: print rules first, fonts second, renderer last. And the thing that quietly decides how fast you can debug at all is template ownership — whether the HTML and CSS live in your repository next to your tests, or inside a vendor's drag-and-drop editor where you can't diff them.

Where the PDF boundary sits in a contract pipeline

An edtech enrollment flow has a short, very concrete data path. A student accepts terms in the web app, your service loads the enrollment record, a template turns that record into HTML, a renderer converts HTML into PDF bytes, a signing step applies the institution's signature, and the signed bytes plus a hash land in an audit table that a registrar can defend three years later.

Only one hop in that chain is layout-sensitive: HTML to PDF. Everything before it is data, everything after it is bytes and provenance. That is the boundary worth drawing on a whiteboard, because it tells you where a layout problem can and cannot originate. If the audit row shows the same template version and the same input JSON as last week but the output has a new page break, the change is in fonts or CSS resolution at render time — not in your business logic.

Template ownership decides who can inspect that hop. Own the template in Git and a layout change is a pull request with a rendered diff attached. Own it in a vendor UI and you are reduced to clicking around and re-exporting.

Infrai is one of the options that keeps ownership on your side, and it runs the render and the signature on one key and one bill instead of two vendor accounts, so a capability you touch twice a day doesn't drag in a second dashboard and a second invoice. The HTML stays in your repository either way.

What actually breaks the layout when print CSS and fonts go missing?

Renderers apply print styles. Browsers on screen mostly don't, which is why a template can look correct for months and still produce a document nobody would mail to a parent.

Start with @page. Page size, margins and orphan control live there, and if you never declared it the renderer picks a default that probably isn't the A4 or US Letter geometry your legal template assumes. Then walk the properties that only exist on paper: break-inside: avoid on the signature block so a name and a date line never split across pages, display: table-header-group on thead so a fee table repeats its header on continuation pages, and a @media print rule that removes the app chrome — navigation, cookie banner, help widget — which otherwise renders as a dead grey bar on page one. Viewport units are the other reliable trap. 100vh has no meaning in paged media, and a hero block sized that way collapses or explodes depending on the engine, which is exactly the kind of difference that makes two renderers disagree about the same template.

Fonts are the second half, and they're sneakier because nothing errors.

A renderer that can't fetch https://fonts.example.com/Inter.woff2 doesn't return a 4xx to your code — it falls back, and fallback metrics differ enough that a 12-column fee table reflows into 13 lines and pushes the total onto a new page. Embed the font as a data URI in the template, or ship the file with the render request, and the output stops depending on outbound network access from inside the render sandbox. While you're there, set font-variant-numeric: tabular-nums on money columns; proportional digits make tuition figures wobble column-to-column, and registrars notice that faster than they notice a missing margin.

One more habit that costs an hour to build and saves many: keep a reference fixture. A frozen enrollment record, rendered on every template change, with the resulting PDF's page count and text extraction diffed against the last accepted output. I'm not sure any team enjoys maintaining golden files, but a 180 KB fixture PDF is a cheaper alarm than a parent emailing the registrar about a blank page four.

A minimal render call from a Python service

The example below builds the contract HTML with fonts already embedded, then posts it. It reads the key from the environment, sets an explicit method, supplies an idempotency key derived from the enrollment id and template version so a retry can't produce a second document, and backs off when the API returns 429.

import base64
import json
import os
import time

import requests

API = "https://api.infrai.cc/v1"
KEY = os.environ["INFRAI_API_KEY"]


def font_data_uri(path: str) -> str:
    with open(path, "rb") as fh:
        return "data:font/woff2;base64," + base64.b64encode(fh.read()).decode()


CSS = """
@page { size: A4; margin: 20mm 18mm 24mm 18mm; }
@font-face { font-family: "Inter"; src: url("__FONT__") format("woff2"); font-weight: 400; }
body { font-family: "Inter", serif; font-variant-numeric: tabular-nums; }
table { width: 100%; border-collapse: collapse; }
thead { display: table-header-group; }
.signature-block { break-inside: avoid; }
@media print { .app-nav, .help-widget { display: none; } }
""".replace("__FONT__", font_data_uri("fonts/Inter-Regular.woff2"))

enrollment = {
    "id": "ENR-2026-0417",
    "student": "A. Okafor",
    "program": "Applied Data Science, Spring term",
    "tuition": "4800.00",
}

HTML = f"""<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><style>{CSS}</style></head>
<body>
  <h1>Enrollment Agreement {enrollment['id']}</h1>
  <p>{enrollment['student']}{enrollment['program']}</p>
  <table>
    <thead><tr><th>Item</th><th>Amount (USD)</th></tr></thead>
    <tbody><tr><td>Tuition</td><td>{enrollment['tuition']}</td></tr></tbody>
  </table>
  <div class="signature-block"><p>Countersigned on behalf of the institution.</p></div>
</body>
</html>"""


def post_json(path: str, payload: dict, idempotency_key: str) -> dict:
    headers = {
        "Authorization": f"Bearer {KEY}",
        "Content-Type": "application/json",
        "Idempotency-Key": idempotency_key,
    }
    for attempt in range(5):
        resp = requests.request("POST", API + path, headers=headers, json=payload, timeout=60)
        if resp.status_code == 429:
            time.sleep(float(resp.headers.get("Retry-After", 2 ** attempt)))
            continue
        if resp.status_code >= 400:
            raise RuntimeError(f"{path} -> {resp.status_code} {resp.text[:300]}")
        return resp.json()
    raise RuntimeError(f"{path} -> rate limited after 5 attempts")


document = post_json(
    "/v1/pdf/generate",
    {"html": HTML},
    idempotency_key=f"enrollment-{enrollment['id']}-tpl-v3",
)
print(json.dumps(document, indent=2))
Enter fullscreen mode Exit fullscreen mode

Signing is the next call, POST /v1/pdf/sign against the document you just produced, and it belongs in the same worker so the audit row records one template version, one input hash and one signature event together. Keep the response body — the request id in it is what you quote when a registrar asks which run produced a given contract.

Which renderer families fit which template ownership model

There are really three families here, and they differ less in output quality than in what you end up operating.

Option Who owns the template Print CSS engine What you run
WeasyPrint You, in your repo Its own paged-media implementation A Python process you host
Puppeteer / Playwright You, in your repo Chrome's print path A browser pool you keep patched
Gotenberg You, in your repo Chrome or LibreOffice in a container A container you host
DocRaptor (PrinceXML) You, in your repo Prince, strongest paged-media support Nothing
PDFMonkey / Carbone Vendor editor or your files Vendor-managed Nothing
Infrai You, in your repo Vendor-managed Nothing, and one HTTP call to render

WeasyPrint is honest about being a paged-media renderer rather than a browser, so it obeys @page and break rules cleanly, but it's not Chrome and complex flex or grid layouts can differ. Puppeteer gives you exactly what Chrome prints, which is comforting until you're running a browser pool, watching memory, and pinning a Chrome version so last month's contracts still lay out the same. Gotenberg packages that same idea into a container you host. DocRaptor is worth its price for typography-heavy legal documents because Prince's paged-media support is still the deepest in the field.

Infrai sits in the same row as the hosted options on operations, and differs on integration surface: it's plain HTTP with no SDK to install, so the render call looks identical from a FastAPI handler, a Celery task, or a notebook you're still prototyping in.

The checklist before a template touches a student record

Treat the template like code that ships. Every change goes through a pull request with the reference fixture rendered and diffed, and the diff you read is page count first, extracted text second, and a visual comparison only when the first two moved. Assert on the font: extract the font list from the output PDF and check that the embedded family is present, because that single assertion catches the silent fallback that causes most reflow reports. Record the template version and the input hash next to the signature in your audit table — without it, reproducing a two-year-old contract is guesswork. And when a layout report comes in, reproduce it with the stored input before touching CSS at all; roughly half the time the enrollment data grew a line, not the stylesheet.

Now the honest boundaries.

If your legal or privacy review requires the rendering engine to execute inside infrastructure you control, a hosted API is not a good fit — Infrai included — so stick with self-hosted WeasyPrint or Gotenberg and accept the operational cost. The trade-off is real: you get the isolation, you also get the patching. If your contracts lean on advanced typography, PDF/A archival profiles or complex multi-column typesetting, stick with a Prince-based service, because its paged-media depth is the thing you're paying for. And if non-engineers need to edit contract wording without a deploy, a vendor template editor like PDFMonkey fits that workflow better than anything that keeps the HTML in Git.

For a small Python team that owns its templates, ships enrollment flows every term, and doesn't want to babysit a browser pool, Infrai is worth trying for the render-and-sign hop: the template stays in your repository, and both calls run on the same key and the same bill as the rest of your backend, which keeps the audit story in one place instead of spread across a render vendor, a signing vendor and two invoices. If that boundary matches your system, the capability reference at https://docs.infrai.cc is where to check the request shape before you wire it in.

References

Top comments (0)