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
StrictUndefinedand 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
@pagerules 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
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)
| File | Size | Uploaded | |
|---|---|---|---|
| sheetrender-0.5.0.tar.gz | 134.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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