Sign inSign up

zalven88/pdf-engine

By zalven88

•Updated 8 days ago

A blazingly fast standalone microservice that converts HTML to PDF. Built with Rust.

Image
Integration & delivery
API management
Web servers
1

2.1K

zalven88/pdf-engine repository overview

⁠pdf-engine

HTML + Minijinja⁠ templates → PDF. Rust, rendered in-process by Fulgur⁠ (no browser, no external binaries). Returns the PDF or uploads it to S3.

~1,000 invoices/s on 10 cores · ~2.5 ms per render · amd64 + arm64 image.

⁠Run

docker run -d -p 6767:6767 \
  -e API_BEARER_TOKEN=secret \
  -e STORAGE_URL=http://minio:9000 -e STORAGE_BUCKET=pdfs \
  -e STORAGE_ACCESS_KEY=... -e STORAGE_SECRET_KEY=... \
  zalven88/pdf-engine:1.1.0

STORAGE_* is required even if you only use /api/to-bytes. Inside Docker Compose, use the service name (e.g. http://minio:9000), not localhost.

⁠API

EndpointAuthDoes
POST /api/to-bytesBearerReturns the PDF
POST /api/to-s3BearerUploads, returns JSON with a presigned URL
POST /api/fonts/reloadBearerReloads fonts without a restart
GET /health—{"status":"ok","version":"…"}
GET /metricsMETRICS_TOKEN if setPrometheus metrics (only when enabled)

Form fields (multipart/form-data):

FieldDefault
templaterequiredHTML/Minijinja template
datanullJSON for the template
width / height8.5in / 11inLength in in, mm, cm, pt, pc, px
orientationportraitor landscape
filenamedocument.pdf/api/to-bytes download name
password—Encrypts the PDF
curl -H "Authorization: Bearer secret" \
  --form-string 'template=<h1>Invoice {{ no }}</h1>' -F 'data={"no": 42}' \
  http://localhost:6767/api/to-bytes -o invoice.pdf

Responses are gzip/brotli/zstd compressed (level 6) when the client asks.

⁠Configuration

VariableDefault
API_BEARER_TOKENrequiredAPI token
STORAGE_URL, STORAGE_BUCKET, STORAGE_ACCESS_KEY, STORAGE_SECRET_KEYrequiredS3 / MinIO
STORAGE_REGIONus-east-1
API_PORT6767
RENDER_TIMEOUT_SECS45Per request; 408 when exceeded
ASSET_ALLOWED_HOSTSoffHosts <img> may load from, e.g. cdn.example.com,*.assets.io
ASSET_MAX_BYTES / ASSET_TIMEOUT_MS / ASSET_MAX_IMAGES5242880 / 5000 / 20Per-image limits
ASSET_ALLOW_HTTP / ASSET_ALLOW_PRIVATE_IPSfalseOnly for trusted internal networks
FONT_WATCH / FONT_WATCH_DIRfalse / /usr/share/fonts/pdf-engineReload fonts when the directory changes
METRICS_ENABLED / METRICS_TOKENfalse / —Enable /metrics, optionally protected
OTEL_EXPORTER_OTLP_ENDPOINToffExport traces over OTLP/HTTP (OTEL_SERVICE_NAME, other OTEL_* honoured)
RUST_LOGinfoLog level (stderr)

Everything optional is off by default; set it in .env or the container environment.

⁠Images

<img src="data:image/..."> always works. Remote https:// images load only from ASSET_ALLOWED_HOSTS: no redirects, no private/loopback addresses (checked after DNS), images only, size and time limits. Blocked or broken images are skipped; the PDF still renders.

⁠Fonts

The image ships 29 Noto fonts (Latin, Serif, Mono, CJK, Arabic, Hebrew, Thai, emoji) via fonts/fonts.conf⁠. sans-serif/Arial → Noto Sans, serif → Noto Serif, monospace → Noto Sans Mono. To add one: copy it into /usr/share/fonts/pdf-engine, then POST /api/fonts/reload (or run with FONT_WATCH=true).

⁠CSS

Most document CSS works. Known gaps (e.g. repeating <thead>, float, z-index) and workarounds: docs/css.md⁠.

⁠Performance

20-line invoices, sustained~970 PDFs/s (100k in ~1.7 min)
Invoice render~2.5 ms
Memory~450 MB steady; plan ~1.5 GB for large CJK documents

Full results and sizing: docs/performance.md⁠.

⁠Development

make test       # functional test suites
make check      # fmt + tests (CI)
make bench      # micro-benchmarks
make stress     # load test (MODE=matrix|sweep|soak)
make help       # everything else

No Rust locally? Use the docker- variants (make docker-test, ...).

⁠Release

Bump version in Cargo.toml, add a CHANGELOG.md⁠ entry, commit, then make release. It tags vX.Y.Z and pushes zalven88/pdf-engine:X.Y.Z, X.Y, X and latest for amd64 and arm64.

⁠License

MIT. Rendering by Fulgur (MIT/Apache-2.0); fonts by Noto⁠ (OFL).

⁠Performance

CommandWhat it measuresTakes
make stressLoad test: a release server under concurrent HTTP traffic. Throughput (PDFs/s), latency percentiles and peak memory per scenario, a concurrency sweep, and a 20,000-PDF soak. Use it for capacity planning.~10 min
make benchMicro-benchmarks: single-threaded cost of each step (render per document type, templating, encryption, gzip), measured with Criterion. Use it to compare code or Fulgur versions.~3 min

Results: one machine, 10 CPUs (Linux aarch64, Docker Desktop), via make docker-stress / make docker-bench. Rerun on your own hardware before capacity planning.

⁠Load test: what 100,000 PDFs takes on one 10-core server

WorkloadPDFs/s100k PDFs
20-line invoices, sustained over 20,000~970~1.7 min
Production-like mix (60% invoices / 30% two-page invoices / 10% CJK)~640~2.6 min
10-page reports~295~5.7 min
200-line (multi-page) invoices~200~8.3 min

⁠Load test: scenario matrix (concurrency 32)

ScenarioPDFs/sp50p99Peak memory
Small document2,8418.8 ms31 ms332 MB
20-line invoice1,26922 ms47 ms423 MB
200-line invoice (multi-page)202149 ms212 ms498 MB
10-page report (grid, page breaks)29598 ms132 ms407 MB
Password-encrypted PDF99724 ms82 ms599 MB
500-row table full of CJK + emoji55533 ms912 ms1.1 GB
Mixed traffic64243 ms90 ms838 MB

All 2,100 requests in the matrix returned 200.

⁠Load test: concurrency sweep (20-line invoice)

In-flight requestsPDFs/sp50p99Peak memory
12234.4 ms6.9 ms78 MB
23964.0 ms13.8 ms93 MB
51,0944.4 ms7.8 ms132 MB
101,1937.3 ms18.7 ms231 MB
201,40113.2 ms26.6 ms337 MB
401,42426.1 ms45.7 ms371 MB
801,23654.9 ms89.4 ms540 MB

Throughput peaks at two to four in-flight requests per core. Beyond that, requests queue on the render semaphore (one permit per core), so latency rises while memory stays bounded.

⁠Load test: soak, 20,000 invoices on one server

PDFs so farPDFs/sp99Server memory
0 (idle)73 MB
2,0001,00778 ms428 MB
6,00085299 ms463 MB
10,000938111 ms444 MB
14,00086671 ms453 MB
20,00095768 ms427 MB

Memory plateaus around 450 MB with no sustained growth (mimalloc keeps freed memory for reuse), and every request succeeded.

⁠Micro-benchmarks (single thread)

StepTimeNotes
Render a tiny document1.4 msheading + paragraph
Render a flexbox layout1.8 mstests/fixtures/css/05_flexbox.html
Render a grid layout1.7 ms06_grid.html
Render an invoice3.3 ms08_invoice.html
Render Chinese, Japanese and Korean text6.9 msfont fallback across scripts
Render a 300-row table24.7 msmulti-page
Fill a template with 500 rows0.44 msMinijinja
Encrypt a PDF (password)0.60 mslopdf, RC4 128-bit
Hand the PDF to the response without a password< 1 µszero-copy
gzip level 1 / 6 / 9 a small PDF74 / 160 / 164 µs168 / 78 / 76 MiB/s

Rendering is where the time goes. Templating, encryption and compression together add well under 1 ms. Level 6 compresses almost as well as level 9 at the same cost.

⁠What makes it fast

  • Shared font context: Fulgur normally rebuilds its font collection from fontconfig for every document. A small patch (vendor/fulgur, see PATCHES.md⁠) builds it once per process, which made renders 4–6× faster. Fonts added after startup need a restart.
  • Curated font set: every font fontconfig exposes is enumerated, so the image exposes 29 files instead of ~2,300.
  • mimalloc: rendering allocates heavily; mimalloc is ~10% faster than the system allocator.
  • Zero-copy output: the PDF is one buffer from the renderer to the socket or S3.

⁠Production sizing

  • Memory: budget ~600 MB per instance for invoice-style documents and ~1.5 GB if you render large CJK/emoji-heavy documents. Set a container memory limit with that headroom.
  • Concurrency: the server caps concurrent renders at one per core; scale out with more instances rather than more concurrency per instance.
  • Timeouts: keep RENDER_TIMEOUT_SECS above your p99 for the largest documents you generate. A render can't be interrupted once started; a timed-out render keeps its concurrency slot until it finishes.
  • Fonts: keep FONTCONFIG_FILE pointed at the curated set; every extra font file adds per-render cost.
  • Compression: gzip at level 6 saves ~20% on PDFs; clients that don't send Accept-Encoding get the raw PDF.

⁠Running the benchmarks

make stress                  # load test: matrix + concurrency sweep + 20k soak
make stress MODE=matrix      # one part only: matrix | sweep | soak
make bench                   # micro-benchmarks (local runs also write target/criterion/report/index.html)

# Load-test a deployed instance (no memory numbers):
PDF_URL=https://pdf.example.com API_BEARER_TOKEN=... make stress MODE=matrix

make stress runs scripts/loadtest.sh⁠. Without PDF_URL it builds a release binary and starts a fresh server for every scenario, measuring that server's peak memory. Use the production font set (FONTCONFIG_FILE, or make docker-stress) for representative numbers. Tunables: REQUESTS, CONCURRENCY, SWEEP, SOAK_ROUNDS, SOAK_BATCH.

Each scenario is an ignored test in tests/stress_tests.rs⁠ and can run on its own:

STRESS_URL=http://localhost:6767 API_BEARER_TOKEN=... STRESS_REQUESTS=2000 STRESS_CONCURRENCY=32 \
  cargo test --release --test stress_tests -- --ignored --nocapture stress_to_bytes_invoice

Set STRESS_MAX_P99_MS to make a scenario fail when its p99 latency exceeds the limit; that works as a performance gate in CI. Micro-benchmark sources are in benches/render.rs⁠.

Tag summary

Content type

Image

Digest

sha256:b62005d67…

Size

91.5 MB

Last updated

8 days ago

docker pull zalven88/pdf-engine