TL;DR: PDF generation is harder than HTML rendering because the output is a final, portable artifact, not a layout that a browser may reflow for its own screen. For 10,000 e-commerce return forms, count the expensive full renders first. Fill an approved template, flatten only the customer copy, retain the template plus structured field data, and render again only when policy requires a reproducible visual record. This moves the dominant work from repeated page construction to bounded template operations, while preserving a clear audit trail.
The bill has two parts. Compute pays for parsing, font work, appearance generation, flattening, and validation. Retention pays for every byte kept across customer downloads, support attachments, backups, and replicas. A system that stores three nearly identical PDFs per return has a storage problem even if each render is fast; one that rebuilds every copy on demand has a latency and reproducibility problem even if storage looks tidy.
Start with measurements instead of a library choice. If a representative flattened form is 420 KB, 10,000 final customer copies occupy about 4.2 GB before replication and backup. Retaining one 600 KB template plus 10,000 records averaging 2 KB is about 20.6 MB. Those are example inputs to the model below, not universal PDF sizes or a benchmark.
from dataclasses import dataclass
@dataclass(frozen=True)
class RetentionPlan:
documents: int
rendered_bytes: int
record_bytes: int
template_bytes: int
copies: int = 1
def rendered_total(self) -> int:
return self.documents * self.rendered_bytes * self.copies
def reconstructable_total(self) -> int:
return (self.template_bytes + self.documents * self.record_bytes) * self.copies
plan = RetentionPlan(
documents=10_000,
rendered_bytes=420_000,
record_bytes=2_000,
template_bytes=600_000,
copies=1,
)
print(f"rendered: {plan.rendered_total() / 1_000_000_000:.2f} GB")
print(f"template plus records: {plan.reconstructable_total() / 1_000_000:.1f} MB")
That comparison does not prove that reconstruction is always preferable. It exposes the decision. If the exact sent artifact matters for a dispute, retain that artifact under a defined schedule. If the form can be regenerated and visual identity is not a record requirement, keep the immutable template, normalized inputs, template version, and output digest instead.
Why Is PDF Generation Harder Than Rendering HTML for Print Layout?
HTML describes a document that is laid out in an environment. The viewport, available fonts, user preferences, and browser all participate. A narrow viewport can move a block down. A missing font can change line breaks. The result may still be acceptable because reflow is part of the medium. A PDF consumer, by contrast, receives pages whose geometry is already committed. Text, graphics, annotations, form fields, resources, and page boundaries have relationships that must survive transfer between systems. PDF is standardized as ISO 32000-2; treating it as a screenshot format misses the object model and the obligations around how a conforming file is interpreted. Forms add another layer. A field value and its visible appearance are related, but they are not the same thing. Filling a value without producing the intended appearance can leave one viewer showing the new value while another exposes a blank-looking box. Flattening then converts interactive content into fixed page content. Do it too early and corrections become costly; skip it for the distributed copy and the recipient may edit what was meant to be final.
Pages do not reflow.
This is where fidelity versus render cost becomes concrete. Rebuilding a return form from HTML asks a layout engine to paginate an approximation of a print design. Filling a controlled PDF template starts from approved page geometry, but it demands stricter handling of fields, appearances, fonts, and flattening. The second path often reduces layout variability; it does not remove validation work.
Model one return before processing 10,000
Use field data with explicit limits. Order identifiers, addresses, return reasons, and refund totals are not innocent strings: a long surname can overflow a box, a multiline address can cross a footer, and an unexpected script can require glyphs absent from the template font. Delivery systems taught backend teams the same lesson with SMS segments and OTP expiry windows: boundaries are part of correctness, not cleanup after launch.
The following validator is deliberately independent of a PDF library. Run it before the adapter touches a file, so rejected records are cheap, observable, and safe to retry after correction.
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
@dataclass(frozen=True)
class ReturnForm:
order_id: str
customer_name: str
return_reason: str
refund_amount: Decimal
def validate(payload: dict[str, str]) -> ReturnForm:
order_id = payload.get("order_id", "").strip()
name = payload.get("customer_name", "").strip()
reason = payload.get("return_reason", "").strip()
if not order_id or len(order_id) > 32:
raise ValueError("order_id must contain 1 to 32 characters")
if not name or len(name) > 80:
raise ValueError("customer_name must contain 1 to 80 characters")
if not reason or len(reason) > 240:
raise ValueError("return_reason must contain 1 to 240 characters")
try:
amount = Decimal(payload["refund_amount"])
except (KeyError, InvalidOperation) as exc:
raise ValueError("refund_amount must be a decimal string") from exc
if amount < 0:
raise ValueError("refund_amount cannot be negative")
return ReturnForm(order_id, name, reason, amount)
sample = validate({
"order_id": "ORD-2026-004281",
"customer_name": "Morgan Lee",
"return_reason": "Wrong size; packaging unopened.",
"refund_amount": "74.50",
})
print(sample)
Character counts are admission controls, not proof of visual fit. Fonts are proportional, shaping matters, and a narrow sequence can fit where a shorter wide sequence does not. The renderer must therefore report overflow or the validator must use metrics from the exact embedded font. Silent shrinking is a poor default for compliance-sensitive totals because it can make the most important text the least readable.
Reject the record early.
Give every template an immutable version. Map business names such as refund_amount to PDF field identifiers in configuration, since production templates often contain opaque names. Reject an unknown template version and an unknown required field. Guessing creates plausible documents, which are more dangerous than loud failures.
Fill first and flatten at the distribution boundary
Keep the workflow split into stages: validate data, load an approved template, fill fields, regenerate appearances, inspect, flatten the outbound copy, and hash the final bytes. The PDF-specific operations belong behind a narrow adapter because different engines expose them differently. The orchestration should not care which conforming implementation performs those operations.
from dataclasses import dataclass
from hashlib import sha256
from typing import Protocol
class PdfFormEngine(Protocol):
def fill(self, template: bytes, values: dict[str, str]) -> bytes: ...
def regenerate_appearances(self, document: bytes) -> bytes: ...
def flatten(self, document: bytes) -> bytes: ...
def inspect(self, document: bytes) -> dict[str, object]: ...
@dataclass(frozen=True)
class Artifact:
content: bytes
digest: str
page_count: int
def produce_customer_copy(
engine: PdfFormEngine,
template: bytes,
values: dict[str, str],
) -> Artifact:
filled = engine.fill(template, values)
visible = engine.regenerate_appearances(filled)
report = engine.inspect(visible)
if report.get("missing_required_fields"):
raise ValueError("required fields are missing")
if report.get("overflow_fields"):
raise ValueError("one or more fields overflow")
final = engine.flatten(visible)
final_report = engine.inspect(final)
if final_report.get("interactive_fields"):
raise ValueError("flattened copy still contains interactive fields")
return Artifact(
content=final,
digest=sha256(final).hexdigest(),
page_count=int(final_report["page_count"]),
)
The order matters. Inspecting only after flattening makes it harder to identify which original field overflowed. Hashing before flattening records the wrong artifact. And flattening the sole working copy destroys the editable state needed for a human correction.
Order is part of correctness.
Idempotency belongs outside the PDF engine. Key work by order, document purpose, template version, and input revision. A retry should return the previously committed digest or create the same logical revision; it should not attach a second return form to the order. Apply bounded concurrency because parsing, font handling, and compression consume memory as well as CPU. Queue depth, render duration, failure class, output bytes, and overflow count are useful operational signals. Customer names and addresses are not.
Test the artifact rather than trusting a successful render
A process exit code of zero proves little. Structural checks should confirm page count, expected page boxes, required content, absence of interactive fields in the flattened copy, and successful reopening by an independent parser. Visual checks should rasterize known fixtures and compare them with reviewed baselines using a declared tolerance. Text extraction can catch missing labels, but it cannot establish that a value stayed inside its box.
Use hostile fixtures. Include the longest permitted order ID, a multiline address, punctuation, non-ASCII names supported by the chosen fonts, a zero refund, a large refund, and a reason at the length boundary. Keep one template fixture for every supported version. A change to a template, font, rendering engine, or operating-system image should run the full visual suite before deployment.
Do not compare raw PDF bytes as the only regression test. Two valid artifacts can differ in metadata or object ordering while rendering identically. Conversely, a stable file size says nothing about a clipped total. Compare the properties that carry business meaning, then record the final digest for custody.
For production, canary a small batch and stop promotion when overflow, parser failure, or page-count drift appears. Retry transient infrastructure failures separately from deterministic document failures. A malformed field mapping will not improve on attempt five.
Choose what to retain when fidelity has a cost
There are three reasonable retention shapes, and the right one depends on the record obligation rather than a universal preference.
| Retained material | Render cost later | Exact historical appearance | Typical use |
|---|---|---|---|
| Final flattened PDF | None for retrieval | Preserved | Customer-facing record or dispute evidence |
| Immutable template plus normalized data and versions | Full reconstruction | Depends on preserving the complete render environment | Regenerable operational forms |
| Both, with separate retention periods | None initially; reconstruction remains possible | Preserved while the artifact exists | Mixed legal and operational needs |
The middle option has a hidden dependency: a template and JSON record alone may not reproduce the same pixels after fonts, renderers, or defaults change. If reproducibility matters, preserve the renderer version, configuration, required font assets, and a digest of each input. If exact evidence matters, keep the final artifact. Policy decides; storage arithmetic only makes the trade-off visible.
My default boundary for this e-commerce flow is straightforward: keep the source template under version control, keep validated field data according to its data-retention class, and retain the flattened customer copy only for the period justified by support and record requirements. Stop keeping intermediate filled-but-editable PDFs, raster previews from successful jobs, and duplicate attachments after delivery is confirmed.
That saves storage and reduces the number of places containing customer data. The cost appears during an incident: without intermediate files, an engineer cannot inspect every stage of an old render. Compensate with template versions, sanitized validation reports, engine metadata, content digests, and a short-lived quarantined artifact only for failed jobs. This is a deliberate loss of forensic convenience, not free optimization.
PDF work becomes manageable once “generate a document” is replaced by explicit commitments: fixed page geometry, visible field appearances, immutable template versions, a tested flattening boundary, bounded retries, and a retention decision. HTML can defer layout to a browser. A distributed PDF cannot defer responsibility.
Further reading
- ISO 32000-2, Portable Document Format: https://www.iso.org/standard/75839.html
Top comments (0)