Skip to main content

SheetRender

Spreadsheet + HTML template in, a stack of PDFs out.

SheetRender turns a CSV or XLSX file and an HTML template into one well-paginated PDF per row, or per group of rows for documents with line items like invoices and statements. It's the same rendering engine that runs sheetrender.com, pulled out as a standalone MIT library and CLI.

git clone https://github.com/sheetrender/sheetrender && cd sheetrender
uvx sheetrender batch examples/invoice/template.html examples/invoice/data.csv \
  -o out/ --group-by invoice_no --filename "{{ invoice_no }}.pdf" --zip invoices.zip

That renders one invoice per invoice_no, with the group's rows available to the template as items, names each file from a template, and zips the stack. (The clone is only there for the example files. uvx sheetrender itself needs no install at all.)

Why this exists

Every "generate PDFs from a spreadsheet" recipe I found glues a headless browser to a for-loop and hopes. That holds up fine for fifty rows. Somewhere past a few thousand it starts leaking memory, or it wedges, or a webfont request stalls and takes a render slot down with it. I hit all of those running this in production, and the fixes are what's in here:

  • The browser is shared across renders and sits behind a priority-aware concurrency gate. It gets recycled after N renders or N minutes, so a thousand-row batch doesn't leak or wedge.
  • Print CSS behaves. Margins, page sizes, backgrounds, and a bounded wait for webfonts, so a font fetch that never returns degrades to fallback fonts instead of hanging the render.
  • Jinja2 runs sandboxed with StrictUndefined and autoescaping on. Misspell a column name and the render fails loudly instead of handing you 800 documents with a hole in them.
  • Templates are sanitized with nh3, and the browser context blocks every network request except an allowlist (Google Fonts by default).
  • Filenames come from templates, sanitized per-platform and de-duplicated. Rows group with auto-detection, PDFs merge with stamped page numbers, and there's zip output, thumbnails and PDF metadata.

Install

uv add sheetrender          # or: pip install sheetrender
uv run playwright install chromium

Or run the CLI without installing anything: uvx sheetrender --help. You still need Chromium once. Without a local playwright on PATH, that's

uvx --from playwright playwright install chromium

CLI

# One PDF per row
sheetrender batch template.html data.csv -o out/

# One PDF per invoice, line items available as {{ items }}
sheetrender batch template.html data.csv -o out/ --group-by invoice_no

# Name files from row data, merge everything, add page numbers
sheetrender batch template.html data.csv -o out/ \
  --filename "{{ customer }}-{{ invoice_no }}.pdf" \
  --merge all.pdf --page-numbers

# Single document from a JSON object file (or --set key=value)
sheetrender render letter.html -o letter.pdf --data row.json

# What's in this file, and what will the template see?
sheetrender inspect data.xlsx

# PNG thumbnail of the first page
sheetrender thumbnail template.html -o thumb.png --data row.json

Page geometry: --page-size A3|A4|A5|Letter|Legal|Tabloid --landscape --margin 12mm.

Watermarking works on batch only. --watermark-html '<div style="position:fixed;bottom:0">DRAFT</div>' injects your snippet before </body>. Use position:fixed if you want it on every printed page; without it the snippet just sits at the end of the document.

Data files are .csv or .xlsx. The single-document --data flag takes a JSON object file.

Python API

import asyncio
from pathlib import Path

import sheetrender as sr

async def main() -> None:
    out = Path("out")
    out.mkdir(exist_ok=True)
    await sr.start_browser()
    try:
        template = sr.compile_template(Path("template.html").read_text())
        parsed = sr.parse_csv("data.csv")
        async with sr.render_context() as renderer:
            rows = sr.iter_rows("data.csv", parsed["columns"], parsed["read_options"])
            for i, row in enumerate(rows):
                html = sr.render_compiled(template, row)
                pdf = await renderer.render_pdf(html)
                (out / f"row_{i:04}.pdf").write_bytes(pdf)
    finally:
        await sr.stop_browser()

asyncio.run(main())

Renders inside one render_context share the browser and are gated to RenderConfig.concurrency parallel pages. merge_pdfs, zip_files, render_thumbnail, apply_pdf_metadata, and the grouping helpers (grouped_render_units, group_context, detect_group_candidates) are all exported from the package root.

If you store parse_*'s columns to read the file again later, store read_options with them and pass both back to iter_rows (and to the grouping helpers). They record which tab, header row and zero-padded columns the ingest used. Columns stored without them are read the way 0.4.0 read them: the first tab even when it is hidden, and ZIP-code formats as numbers.

Templates

Templates are plain HTML and CSS rendered by Chromium's print pipeline, with Jinja2 for data. Each row's columns become top-level variables, so a header of Invoice No. is available as {{ invoice_no }} (sheetrender inspect shows the exact mapping). The environment is sandboxed, autoescaped and strict: referencing a column that doesn't exist is an error, not a silent blank.

The formatting filters are null-safe, so bad input renders as an empty string rather than crashing halfway through a batch:

Filter Example output Notes
money $1,234 whole-dollar
money2 $1,234.50 cents
money_k $234K, $1.2M compact, negatives as -$…
comma / comma2 1,234 / 1,234.50 no currency symbol
pct 89% rounds to whole percent
bar_width 0–100 clamped, for CSS bar charts
sign_class positive / negative takes the value to compare against: {{ actual | sign_class(target) }}
yesno_class "" / no empty string for truthy (default styling), no for falsy
sumcol {{ items | sumcol('amount') | money2 }} Decimal-exact column sum. Strips $€£ and commas, treats (123) as negative

Rendering is deterministic across machines. The browser context is pinned to en-US and UTC, so dates and numbers format the same everywhere.

Grouped documents

--group-by customer_id (or grouped_render_units in Python) renders one document per group. The template sees the first row's fields at the top level plus three reserved names: items (every row in the group), item_count and group_key. See examples/invoice/ for a complete line-item invoice.

Security model

This is built to render templates you didn't write yourself:

  • Jinja2 runs in a SandboxedEnvironment, so there's no attribute traversal to dangerous internals, and autoescape is on.
  • Template HTML goes through nh3 (allowlist-based) before it reaches the browser.
  • The browser context intercepts every network request and blocks anything outside RenderConfig.allowed_egress_hosts (Google Fonts by default), so a malicious template can't exfiltrate row data through an <img> beacon.
  • Chromium keeps its sandbox on, which is why you shouldn't run the engine as root.
  • Author @page rules are stripped, so template CSS can't override the page geometry you asked for.

Configuration

from sheetrender import RenderConfig, configure

configure(RenderConfig(
    concurrency=4,                      # parallel Chromium pages
    recycle_max_renders=300,            # recycle the browser after N renders…
    recycle_max_age_minutes=30,         # …or N minutes, whichever comes first
    allowed_egress_hosts=frozenset({"fonts.googleapis.com", "fonts.gstatic.com"}),
    pdf_producer=None,                  # PDF metadata; default "sheetrender/<version>"
))

Call configure() once, and call it before start_browser() (or before your first render, which starts the browser for you). concurrency sizes the gate when the browser starts, so changing it after that does nothing.

Development

You don't need a local Python. The test suite runs in containers:

scripts/test.sh            # unit suite (Chromium-dependent tests self-skip)
scripts/test.sh --render   # real-Chromium tests in the Playwright image
scripts/test.sh --all      # both
scripts/lint.sh            # ruff

Hosted version

sheetrender.com is the hosted product built on this engine: a template wizard with AI design generation, Google Sheets sync, scheduled runs, and email/Drive delivery. If you'd rather not run Python, that's the two-minute path.

License

MIT

Metadata

Release files for sheetrender 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sheetrender 0.5.0
File Size Uploaded
sheetrender-0.5.0.tar.gz 134.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sheetrender 0.5.0
File Interpreter ABI Platform
sheetrender-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 185.5 kB

Release files / sheetrender-0.5.0.tar.gz

Download URL sheetrender-0.5.0.tar.gz
Size 134.6 kB
Tags Source
SHA-256 checksum
How to use checksums
6ef1810329d6445ae8d317f09089ad001821a9906b45c7724ce12696567ce711
BLAKE2b-256 checksum
How to use checksums
3a9ad24a5a84d064712b6eb10f297cad64420a7b50ed09a48974c8b8ba956703
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release files / sheetrender-0.5.0-py3-none-any.whl

Download URL sheetrender-0.5.0-py3-none-any.whl
Size 50.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
bc2a978d5df33b64f8f69b82d8b91480a0522d6ab8c22056d1c0a6c62bc21109
BLAKE2b-256 checksum
How to use checksums
3f4666da6d984773ca92d9eac2df20e5681db1e718a0e393ef6bf541c604b18e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page