sheetrender 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sheetrender-0.1.0/.github/workflows/ci.yml +40 -0
- sheetrender-0.1.0/.github/workflows/publish.yml +36 -0
- sheetrender-0.1.0/.gitignore +7 -0
- sheetrender-0.1.0/CHANGELOG.md +7 -0
- sheetrender-0.1.0/CONTRIBUTING.md +40 -0
- sheetrender-0.1.0/LICENSE +21 -0
- sheetrender-0.1.0/PKG-INFO +241 -0
- sheetrender-0.1.0/README.md +209 -0
- sheetrender-0.1.0/SECURITY.md +25 -0
- sheetrender-0.1.0/examples/invoice/README.md +14 -0
- sheetrender-0.1.0/examples/invoice/data.csv +7 -0
- sheetrender-0.1.0/examples/invoice/template.html +97 -0
- sheetrender-0.1.0/pyproject.toml +77 -0
- sheetrender-0.1.0/scripts/lint.sh +17 -0
- sheetrender-0.1.0/scripts/test.sh +80 -0
- sheetrender-0.1.0/src/sheetrender/__init__.py +101 -0
- sheetrender-0.1.0/src/sheetrender/cli.py +539 -0
- sheetrender-0.1.0/src/sheetrender/config.py +27 -0
- sheetrender-0.1.0/src/sheetrender/filenames.py +74 -0
- sheetrender-0.1.0/src/sheetrender/grouping.py +255 -0
- sheetrender-0.1.0/src/sheetrender/html_sanitize.py +110 -0
- sheetrender-0.1.0/src/sheetrender/render.py +1064 -0
- sheetrender-0.1.0/src/sheetrender/sanitize.py +32 -0
- sheetrender-0.1.0/src/sheetrender/sheets.py +193 -0
- sheetrender-0.1.0/src/sheetrender/templating.py +194 -0
- sheetrender-0.1.0/tests/conftest.py +18 -0
- sheetrender-0.1.0/tests/data/performance-reports.xlsx +0 -0
- sheetrender-0.1.0/tests/test_config.py +211 -0
- sheetrender-0.1.0/tests/test_filenames.py +48 -0
- sheetrender-0.1.0/tests/test_group_detect.py +51 -0
- sheetrender-0.1.0/tests/test_grouping.py +210 -0
- sheetrender-0.1.0/tests/test_html_sanitize.py +99 -0
- sheetrender-0.1.0/tests/test_render.py +993 -0
- sheetrender-0.1.0/tests/test_sanitize.py +26 -0
- sheetrender-0.1.0/tests/test_sheets.py +18 -0
- sheetrender-0.1.0/tests/test_sheets_csv.py +90 -0
- sheetrender-0.1.0/tests/test_templating.py +155 -0
- sheetrender-0.1.0/uv.lock +518 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
lint:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
- run: uv run ruff check src tests
|
|
15
|
+
|
|
16
|
+
test:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
strategy:
|
|
19
|
+
matrix:
|
|
20
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: astral-sh/setup-uv@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: ${{ matrix.python-version }}
|
|
26
|
+
- run: uv sync --frozen
|
|
27
|
+
- run: uv run pytest -q
|
|
28
|
+
|
|
29
|
+
render:
|
|
30
|
+
# Real-Chromium pass: the unit job self-skips every test that needs a
|
|
31
|
+
# browser, so this job is what actually proves rendering works.
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v4
|
|
35
|
+
- uses: astral-sh/setup-uv@v5
|
|
36
|
+
with:
|
|
37
|
+
python-version: "3.12"
|
|
38
|
+
- run: uv sync --frozen
|
|
39
|
+
- run: uv run playwright install --with-deps chromium
|
|
40
|
+
- run: uv run pytest tests/test_render.py -q
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: astral-sh/setup-uv@v5
|
|
13
|
+
# The tag is the release request; the version in pyproject is what
|
|
14
|
+
# actually ships. Refuse to publish a mismatch.
|
|
15
|
+
- run: |
|
|
16
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
17
|
+
version="$(uv version --short)"
|
|
18
|
+
[ "$tag" = "$version" ] || { echo "tag v$tag != project version $version" >&2; exit 1; }
|
|
19
|
+
- run: uv build
|
|
20
|
+
- uses: actions/upload-artifact@v4
|
|
21
|
+
with:
|
|
22
|
+
name: dist
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
needs: build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
environment: pypi
|
|
29
|
+
permissions:
|
|
30
|
+
id-token: write # PyPI Trusted Publishing (OIDC) — no API token stored
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/download-artifact@v4
|
|
33
|
+
with:
|
|
34
|
+
name: dist
|
|
35
|
+
path: dist/
|
|
36
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for looking under the hood.
|
|
4
|
+
|
|
5
|
+
## Running the tests
|
|
6
|
+
|
|
7
|
+
You don't need Python installed — everything runs in containers (Docker or
|
|
8
|
+
rootless Podman):
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
scripts/test.sh # unit suite; Chromium-dependent tests self-skip
|
|
12
|
+
scripts/test.sh --render # the real-Chromium tests, in Playwright's Python image
|
|
13
|
+
scripts/test.sh --all # both — run this before opening a PR
|
|
14
|
+
scripts/lint.sh # ruff
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The first run downloads dependencies into named volumes
|
|
18
|
+
(`sheetrender-lib-uv-*`); later runs are fast. Any pytest arguments pass
|
|
19
|
+
through: `scripts/test.sh tests/test_templating.py -k money`.
|
|
20
|
+
|
|
21
|
+
If you prefer a local environment: `uv sync`, `uv run playwright install
|
|
22
|
+
chromium`, `uv run pytest`.
|
|
23
|
+
|
|
24
|
+
## Ground rules
|
|
25
|
+
|
|
26
|
+
- **A green plain run is not a passing suite.** The unit run skips the seven
|
|
27
|
+
Chromium test cases; `--all` is the bar.
|
|
28
|
+
- New behavior needs a test. Bug fixes need a test that fails without the fix.
|
|
29
|
+
- This engine also powers [sheetrender.com](https://sheetrender.com), which
|
|
30
|
+
pins exact versions — behavior changes to rendering output (pagination,
|
|
31
|
+
stamping, sanitization) get extra scrutiny because a thousand production
|
|
32
|
+
templates depend on them.
|
|
33
|
+
- Dependencies: floors only in `pyproject.toml`; `uv.lock` is committed. Bump
|
|
34
|
+
deliberately, one PR per bump.
|
|
35
|
+
|
|
36
|
+
## Reporting bugs
|
|
37
|
+
|
|
38
|
+
A failing template is the perfect bug report: open an issue with the smallest
|
|
39
|
+
HTML + a few CSV rows that reproduce it. For anything security-relevant, see
|
|
40
|
+
[SECURITY.md](SECURITY.md) instead of a public issue.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Final Dynamics
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sheetrender
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Spreadsheet + HTML template in, a PDF per row out. The batch rendering engine behind sheetrender.com.
|
|
5
|
+
Project-URL: Homepage, https://sheetrender.com
|
|
6
|
+
Project-URL: Source, https://github.com/sheetrender/sheetrender
|
|
7
|
+
Project-URL: Issues, https://github.com/sheetrender/sheetrender/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/sheetrender/sheetrender/blob/master/CHANGELOG.md
|
|
9
|
+
Author-email: SheetRender <info@finaldynamics.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: batch,csv,html-to-pdf,invoice,jinja2,mail-merge,pdf,playwright,spreadsheet,xlsx
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Office/Business
|
|
20
|
+
Classifier: Topic :: Printing
|
|
21
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
22
|
+
Requires-Python: <3.14,>=3.11
|
|
23
|
+
Requires-Dist: jinja2>=3.1
|
|
24
|
+
Requires-Dist: nh3>=0.3
|
|
25
|
+
Requires-Dist: openpyxl>=3.1
|
|
26
|
+
Requires-Dist: pikepdf>=10
|
|
27
|
+
Requires-Dist: pillow>=11
|
|
28
|
+
Requires-Dist: playwright>=1.44
|
|
29
|
+
Requires-Dist: pypdf>=6
|
|
30
|
+
Requires-Dist: pypdfium2>=5
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# SheetRender
|
|
34
|
+
|
|
35
|
+
**Spreadsheet + HTML template in, a stack of PDFs out.**
|
|
36
|
+
|
|
37
|
+
SheetRender turns a CSV or XLSX file and an HTML template into one
|
|
38
|
+
well-paginated PDF per row — or per group of rows, for documents with line
|
|
39
|
+
items like invoices and statements. It is the exact rendering engine behind
|
|
40
|
+
[sheetrender.com](https://sheetrender.com), extracted as a standalone MIT
|
|
41
|
+
library and CLI.
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
git clone https://github.com/sheetrender/sheetrender && cd sheetrender
|
|
45
|
+
uvx sheetrender batch examples/invoice/template.html examples/invoice/data.csv \
|
|
46
|
+
-o out/ --group-by invoice_no --filename "{{ invoice_no }}.pdf" --zip invoices.zip
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That renders one invoice per `invoice_no`, with the group's rows available to
|
|
50
|
+
the template as `items`, names each file from a template, and zips the stack.
|
|
51
|
+
(The clone is only for the example files — `uvx sheetrender` itself needs no
|
|
52
|
+
install at all.)
|
|
53
|
+
|
|
54
|
+
## Why this exists
|
|
55
|
+
|
|
56
|
+
Every "generate PDFs from a spreadsheet" recipe on the internet glues a
|
|
57
|
+
headless browser to a for-loop and hopes. This engine has rendered documents
|
|
58
|
+
in production for a long time, and the parts that took real debugging are the
|
|
59
|
+
parts you get for free:
|
|
60
|
+
|
|
61
|
+
- **Chromium rendering with lifecycle management** — a shared browser with a
|
|
62
|
+
priority-aware concurrency gate, recycled after N renders or N minutes, so
|
|
63
|
+
thousand-row batches don't leak memory or wedge.
|
|
64
|
+
- **Print CSS that behaves** — margins, page sizes, backgrounds, webfont
|
|
65
|
+
readiness with a bounded wait (a stalled font fetch degrades to fallback
|
|
66
|
+
fonts instead of hanging a render slot).
|
|
67
|
+
- **Safe templating over untrusted data** — sandboxed Jinja2 with
|
|
68
|
+
`StrictUndefined` (typos fail loudly instead of rendering blanks) and
|
|
69
|
+
autoescaping on.
|
|
70
|
+
- **HTML sanitization + network egress control** — templates are sanitized
|
|
71
|
+
with [nh3](https://github.com/messense/nh3), and the browser context blocks
|
|
72
|
+
all network requests except an allowlist (Google Fonts by default).
|
|
73
|
+
- **Batch ergonomics** — filename templates with cross-platform sanitization
|
|
74
|
+
and de-duplication, row grouping with auto-detection, merged PDFs with
|
|
75
|
+
stamped page numbers, zip output, thumbnails, PDF metadata.
|
|
76
|
+
|
|
77
|
+
## Install
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
uv add sheetrender # or: pip install sheetrender
|
|
81
|
+
uv run playwright install chromium
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Or run the CLI without installing anything: `uvx sheetrender --help`. You
|
|
85
|
+
still need Chromium once; without a local playwright on PATH that's
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
uvx --from playwright playwright install chromium
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## CLI
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
# One PDF per row
|
|
95
|
+
sheetrender batch template.html data.csv -o out/
|
|
96
|
+
|
|
97
|
+
# One PDF per invoice, line items available as {{ items }}
|
|
98
|
+
sheetrender batch template.html data.csv -o out/ --group-by invoice_no
|
|
99
|
+
|
|
100
|
+
# Name files from row data, merge everything, add page numbers
|
|
101
|
+
sheetrender batch template.html data.csv -o out/ \
|
|
102
|
+
--filename "{{ customer }}-{{ invoice_no }}.pdf" \
|
|
103
|
+
--merge all.pdf --page-numbers
|
|
104
|
+
|
|
105
|
+
# Single document from a JSON object file (or --set key=value)
|
|
106
|
+
sheetrender render letter.html -o letter.pdf --data row.json
|
|
107
|
+
|
|
108
|
+
# What's in this file, and what will the template see?
|
|
109
|
+
sheetrender inspect data.xlsx
|
|
110
|
+
|
|
111
|
+
# PNG thumbnail of the first page
|
|
112
|
+
sheetrender thumbnail template.html -o thumb.png --data row.json
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Page geometry: `--page-size A3|A4|A5|Letter|Legal|Tabloid --landscape
|
|
116
|
+
--margin 12mm`. Watermarking (`batch` only):
|
|
117
|
+
`--watermark-html '<div style="position:fixed;bottom:0">DRAFT</div>'` injects
|
|
118
|
+
your snippet before `</body>` — use `position:fixed` if it should repeat on
|
|
119
|
+
every printed page rather than sit at the end of the document. Data files are
|
|
120
|
+
`.csv` or `.xlsx`; the single-document `--data` flag takes a JSON object file.
|
|
121
|
+
|
|
122
|
+
## Python API
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import asyncio
|
|
126
|
+
from pathlib import Path
|
|
127
|
+
|
|
128
|
+
import sheetrender as sr
|
|
129
|
+
|
|
130
|
+
async def main() -> None:
|
|
131
|
+
out = Path("out")
|
|
132
|
+
out.mkdir(exist_ok=True)
|
|
133
|
+
await sr.start_browser()
|
|
134
|
+
try:
|
|
135
|
+
template = sr.compile_template(Path("template.html").read_text())
|
|
136
|
+
parsed = sr.parse_csv("data.csv")
|
|
137
|
+
async with sr.render_context() as renderer:
|
|
138
|
+
for i, row in enumerate(sr.iter_rows("data.csv", parsed["columns"])):
|
|
139
|
+
html = sr.render_compiled(template, row)
|
|
140
|
+
pdf = await renderer.render_pdf(html)
|
|
141
|
+
(out / f"row_{i:04}.pdf").write_bytes(pdf)
|
|
142
|
+
finally:
|
|
143
|
+
await sr.stop_browser()
|
|
144
|
+
|
|
145
|
+
asyncio.run(main())
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Renders inside one `render_context` share the browser and are gated to
|
|
149
|
+
`RenderConfig.concurrency` parallel pages. `merge_pdfs`, `zip_files`,
|
|
150
|
+
`render_thumbnail`, `apply_pdf_metadata`, and the grouping helpers
|
|
151
|
+
(`grouped_render_units`, `group_context`, `detect_group_candidates`) are all
|
|
152
|
+
exported from the package root.
|
|
153
|
+
|
|
154
|
+
## Templates
|
|
155
|
+
|
|
156
|
+
Templates are plain HTML + CSS rendered by Chromium's print pipeline, with
|
|
157
|
+
Jinja2 for data. Each row's columns become top-level variables — a header of
|
|
158
|
+
`Invoice No.` is available as `{{ invoice_no }}` (`sheetrender inspect` shows
|
|
159
|
+
the exact mapping). The environment is sandboxed, autoescaped, and strict:
|
|
160
|
+
referencing a column that doesn't exist is an error, not a silent blank.
|
|
161
|
+
|
|
162
|
+
Null-safe formatting filters (bad input renders as an empty string, never a
|
|
163
|
+
crash mid-batch):
|
|
164
|
+
|
|
165
|
+
| Filter | Example output | Notes |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| `money` | `$1,234` | whole-dollar |
|
|
168
|
+
| `money2` | `$1,234.50` | cents |
|
|
169
|
+
| `money_k` | `$234K`, `$1.2M` | compact, negatives as `-$…` |
|
|
170
|
+
| `comma` / `comma2` | `1,234` / `1,234.50` | no currency symbol |
|
|
171
|
+
| `pct` | `89%` | rounds to whole percent |
|
|
172
|
+
| `bar_width` | `0`–`100` | clamped, for CSS bar charts |
|
|
173
|
+
| `sign_class` | `positive` / `negative` | `{{ actual \| sign_class(target) }}` — compares two values |
|
|
174
|
+
| `yesno_class` | `""` / `no` | empty string for truthy (default styling), `no` for falsy |
|
|
175
|
+
| `sumcol` | `{{ items \| sumcol('amount') \| money2 }}` | Decimal-exact column sum; strips `$€£` and commas, treats `(123)` as negative |
|
|
176
|
+
|
|
177
|
+
Rendering is deterministic across machines: the browser context is pinned to
|
|
178
|
+
`en-US` / UTC, so dates and numbers format the same everywhere.
|
|
179
|
+
|
|
180
|
+
### Grouped documents
|
|
181
|
+
|
|
182
|
+
`--group-by customer_id` (or `grouped_render_units` in Python) renders one
|
|
183
|
+
document per group. The template sees the first row's fields at the top level
|
|
184
|
+
plus three reserved names: `items` (every row in the group), `item_count`, and
|
|
185
|
+
`group_key`. See [`examples/invoice/`](examples/invoice/) for a complete
|
|
186
|
+
line-item invoice.
|
|
187
|
+
|
|
188
|
+
## Security model
|
|
189
|
+
|
|
190
|
+
Designed for rendering templates you didn't write:
|
|
191
|
+
|
|
192
|
+
- Jinja2 runs in `SandboxedEnvironment` — no attribute traversal to
|
|
193
|
+
dangerous internals, autoescape on.
|
|
194
|
+
- Template HTML is sanitized with nh3 (allowlist-based) before it reaches the
|
|
195
|
+
browser.
|
|
196
|
+
- The browser context intercepts all network requests and blocks everything
|
|
197
|
+
outside `RenderConfig.allowed_egress_hosts` (default: Google Fonts) — a
|
|
198
|
+
malicious template can't exfiltrate row data via an `<img>` beacon.
|
|
199
|
+
- Chromium runs with its sandbox left **on** (don't run the engine as root).
|
|
200
|
+
- Author `@page` rules are stripped so template CSS can't override the page
|
|
201
|
+
geometry you asked for.
|
|
202
|
+
|
|
203
|
+
## Configuration
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from sheetrender import RenderConfig, configure
|
|
207
|
+
|
|
208
|
+
configure(RenderConfig(
|
|
209
|
+
concurrency=4, # parallel Chromium pages
|
|
210
|
+
recycle_max_renders=300, # recycle the browser after N renders…
|
|
211
|
+
recycle_max_age_minutes=30, # …or N minutes, whichever comes first
|
|
212
|
+
allowed_egress_hosts=frozenset({"fonts.googleapis.com", "fonts.gstatic.com"}),
|
|
213
|
+
pdf_producer=None, # PDF metadata; default "sheetrender/<version>"
|
|
214
|
+
))
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Call `configure()` once, before `start_browser()` (or the first render, which
|
|
218
|
+
starts it) — `concurrency` sizes the browser's gate at startup and changes
|
|
219
|
+
after that are ignored.
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
No local Python needed — the test suite runs in containers:
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
scripts/test.sh # unit suite (Chromium-dependent tests self-skip)
|
|
227
|
+
scripts/test.sh --render # real-Chromium tests in the Playwright image
|
|
228
|
+
scripts/test.sh --all # both
|
|
229
|
+
scripts/lint.sh # ruff
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Hosted version
|
|
233
|
+
|
|
234
|
+
[sheetrender.com](https://sheetrender.com) is the hosted product built on this
|
|
235
|
+
engine: a template wizard with AI design generation, Google Sheets sync,
|
|
236
|
+
scheduled runs, and email/Drive delivery. If you'd rather not run Python,
|
|
237
|
+
that's the two-minute path.
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# SheetRender
|
|
2
|
+
|
|
3
|
+
**Spreadsheet + HTML template in, a stack of PDFs out.**
|
|
4
|
+
|
|
5
|
+
SheetRender turns a CSV or XLSX file and an HTML template into one
|
|
6
|
+
well-paginated PDF per row — or per group of rows, for documents with line
|
|
7
|
+
items like invoices and statements. It is the exact rendering engine behind
|
|
8
|
+
[sheetrender.com](https://sheetrender.com), extracted as a standalone MIT
|
|
9
|
+
library and CLI.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
git clone https://github.com/sheetrender/sheetrender && cd sheetrender
|
|
13
|
+
uvx sheetrender batch examples/invoice/template.html examples/invoice/data.csv \
|
|
14
|
+
-o out/ --group-by invoice_no --filename "{{ invoice_no }}.pdf" --zip invoices.zip
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That renders one invoice per `invoice_no`, with the group's rows available to
|
|
18
|
+
the template as `items`, names each file from a template, and zips the stack.
|
|
19
|
+
(The clone is only for the example files — `uvx sheetrender` itself needs no
|
|
20
|
+
install at all.)
|
|
21
|
+
|
|
22
|
+
## Why this exists
|
|
23
|
+
|
|
24
|
+
Every "generate PDFs from a spreadsheet" recipe on the internet glues a
|
|
25
|
+
headless browser to a for-loop and hopes. This engine has rendered documents
|
|
26
|
+
in production for a long time, and the parts that took real debugging are the
|
|
27
|
+
parts you get for free:
|
|
28
|
+
|
|
29
|
+
- **Chromium rendering with lifecycle management** — a shared browser with a
|
|
30
|
+
priority-aware concurrency gate, recycled after N renders or N minutes, so
|
|
31
|
+
thousand-row batches don't leak memory or wedge.
|
|
32
|
+
- **Print CSS that behaves** — margins, page sizes, backgrounds, webfont
|
|
33
|
+
readiness with a bounded wait (a stalled font fetch degrades to fallback
|
|
34
|
+
fonts instead of hanging a render slot).
|
|
35
|
+
- **Safe templating over untrusted data** — sandboxed Jinja2 with
|
|
36
|
+
`StrictUndefined` (typos fail loudly instead of rendering blanks) and
|
|
37
|
+
autoescaping on.
|
|
38
|
+
- **HTML sanitization + network egress control** — templates are sanitized
|
|
39
|
+
with [nh3](https://github.com/messense/nh3), and the browser context blocks
|
|
40
|
+
all network requests except an allowlist (Google Fonts by default).
|
|
41
|
+
- **Batch ergonomics** — filename templates with cross-platform sanitization
|
|
42
|
+
and de-duplication, row grouping with auto-detection, merged PDFs with
|
|
43
|
+
stamped page numbers, zip output, thumbnails, PDF metadata.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
uv add sheetrender # or: pip install sheetrender
|
|
49
|
+
uv run playwright install chromium
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Or run the CLI without installing anything: `uvx sheetrender --help`. You
|
|
53
|
+
still need Chromium once; without a local playwright on PATH that's
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
uvx --from playwright playwright install chromium
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## CLI
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
# One PDF per row
|
|
63
|
+
sheetrender batch template.html data.csv -o out/
|
|
64
|
+
|
|
65
|
+
# One PDF per invoice, line items available as {{ items }}
|
|
66
|
+
sheetrender batch template.html data.csv -o out/ --group-by invoice_no
|
|
67
|
+
|
|
68
|
+
# Name files from row data, merge everything, add page numbers
|
|
69
|
+
sheetrender batch template.html data.csv -o out/ \
|
|
70
|
+
--filename "{{ customer }}-{{ invoice_no }}.pdf" \
|
|
71
|
+
--merge all.pdf --page-numbers
|
|
72
|
+
|
|
73
|
+
# Single document from a JSON object file (or --set key=value)
|
|
74
|
+
sheetrender render letter.html -o letter.pdf --data row.json
|
|
75
|
+
|
|
76
|
+
# What's in this file, and what will the template see?
|
|
77
|
+
sheetrender inspect data.xlsx
|
|
78
|
+
|
|
79
|
+
# PNG thumbnail of the first page
|
|
80
|
+
sheetrender thumbnail template.html -o thumb.png --data row.json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Page geometry: `--page-size A3|A4|A5|Letter|Legal|Tabloid --landscape
|
|
84
|
+
--margin 12mm`. Watermarking (`batch` only):
|
|
85
|
+
`--watermark-html '<div style="position:fixed;bottom:0">DRAFT</div>'` injects
|
|
86
|
+
your snippet before `</body>` — use `position:fixed` if it should repeat on
|
|
87
|
+
every printed page rather than sit at the end of the document. Data files are
|
|
88
|
+
`.csv` or `.xlsx`; the single-document `--data` flag takes a JSON object file.
|
|
89
|
+
|
|
90
|
+
## Python API
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import asyncio
|
|
94
|
+
from pathlib import Path
|
|
95
|
+
|
|
96
|
+
import sheetrender as sr
|
|
97
|
+
|
|
98
|
+
async def main() -> None:
|
|
99
|
+
out = Path("out")
|
|
100
|
+
out.mkdir(exist_ok=True)
|
|
101
|
+
await sr.start_browser()
|
|
102
|
+
try:
|
|
103
|
+
template = sr.compile_template(Path("template.html").read_text())
|
|
104
|
+
parsed = sr.parse_csv("data.csv")
|
|
105
|
+
async with sr.render_context() as renderer:
|
|
106
|
+
for i, row in enumerate(sr.iter_rows("data.csv", parsed["columns"])):
|
|
107
|
+
html = sr.render_compiled(template, row)
|
|
108
|
+
pdf = await renderer.render_pdf(html)
|
|
109
|
+
(out / f"row_{i:04}.pdf").write_bytes(pdf)
|
|
110
|
+
finally:
|
|
111
|
+
await sr.stop_browser()
|
|
112
|
+
|
|
113
|
+
asyncio.run(main())
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Renders inside one `render_context` share the browser and are gated to
|
|
117
|
+
`RenderConfig.concurrency` parallel pages. `merge_pdfs`, `zip_files`,
|
|
118
|
+
`render_thumbnail`, `apply_pdf_metadata`, and the grouping helpers
|
|
119
|
+
(`grouped_render_units`, `group_context`, `detect_group_candidates`) are all
|
|
120
|
+
exported from the package root.
|
|
121
|
+
|
|
122
|
+
## Templates
|
|
123
|
+
|
|
124
|
+
Templates are plain HTML + CSS rendered by Chromium's print pipeline, with
|
|
125
|
+
Jinja2 for data. Each row's columns become top-level variables — a header of
|
|
126
|
+
`Invoice No.` is available as `{{ invoice_no }}` (`sheetrender inspect` shows
|
|
127
|
+
the exact mapping). The environment is sandboxed, autoescaped, and strict:
|
|
128
|
+
referencing a column that doesn't exist is an error, not a silent blank.
|
|
129
|
+
|
|
130
|
+
Null-safe formatting filters (bad input renders as an empty string, never a
|
|
131
|
+
crash mid-batch):
|
|
132
|
+
|
|
133
|
+
| Filter | Example output | Notes |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `money` | `$1,234` | whole-dollar |
|
|
136
|
+
| `money2` | `$1,234.50` | cents |
|
|
137
|
+
| `money_k` | `$234K`, `$1.2M` | compact, negatives as `-$…` |
|
|
138
|
+
| `comma` / `comma2` | `1,234` / `1,234.50` | no currency symbol |
|
|
139
|
+
| `pct` | `89%` | rounds to whole percent |
|
|
140
|
+
| `bar_width` | `0`–`100` | clamped, for CSS bar charts |
|
|
141
|
+
| `sign_class` | `positive` / `negative` | `{{ actual \| sign_class(target) }}` — compares two values |
|
|
142
|
+
| `yesno_class` | `""` / `no` | empty string for truthy (default styling), `no` for falsy |
|
|
143
|
+
| `sumcol` | `{{ items \| sumcol('amount') \| money2 }}` | Decimal-exact column sum; strips `$€£` and commas, treats `(123)` as negative |
|
|
144
|
+
|
|
145
|
+
Rendering is deterministic across machines: the browser context is pinned to
|
|
146
|
+
`en-US` / UTC, so dates and numbers format the same everywhere.
|
|
147
|
+
|
|
148
|
+
### Grouped documents
|
|
149
|
+
|
|
150
|
+
`--group-by customer_id` (or `grouped_render_units` in Python) renders one
|
|
151
|
+
document per group. The template sees the first row's fields at the top level
|
|
152
|
+
plus three reserved names: `items` (every row in the group), `item_count`, and
|
|
153
|
+
`group_key`. See [`examples/invoice/`](examples/invoice/) for a complete
|
|
154
|
+
line-item invoice.
|
|
155
|
+
|
|
156
|
+
## Security model
|
|
157
|
+
|
|
158
|
+
Designed for rendering templates you didn't write:
|
|
159
|
+
|
|
160
|
+
- Jinja2 runs in `SandboxedEnvironment` — no attribute traversal to
|
|
161
|
+
dangerous internals, autoescape on.
|
|
162
|
+
- Template HTML is sanitized with nh3 (allowlist-based) before it reaches the
|
|
163
|
+
browser.
|
|
164
|
+
- The browser context intercepts all network requests and blocks everything
|
|
165
|
+
outside `RenderConfig.allowed_egress_hosts` (default: Google Fonts) — a
|
|
166
|
+
malicious template can't exfiltrate row data via an `<img>` beacon.
|
|
167
|
+
- Chromium runs with its sandbox left **on** (don't run the engine as root).
|
|
168
|
+
- Author `@page` rules are stripped so template CSS can't override the page
|
|
169
|
+
geometry you asked for.
|
|
170
|
+
|
|
171
|
+
## Configuration
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from sheetrender import RenderConfig, configure
|
|
175
|
+
|
|
176
|
+
configure(RenderConfig(
|
|
177
|
+
concurrency=4, # parallel Chromium pages
|
|
178
|
+
recycle_max_renders=300, # recycle the browser after N renders…
|
|
179
|
+
recycle_max_age_minutes=30, # …or N minutes, whichever comes first
|
|
180
|
+
allowed_egress_hosts=frozenset({"fonts.googleapis.com", "fonts.gstatic.com"}),
|
|
181
|
+
pdf_producer=None, # PDF metadata; default "sheetrender/<version>"
|
|
182
|
+
))
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Call `configure()` once, before `start_browser()` (or the first render, which
|
|
186
|
+
starts it) — `concurrency` sizes the browser's gate at startup and changes
|
|
187
|
+
after that are ignored.
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
No local Python needed — the test suite runs in containers:
|
|
192
|
+
|
|
193
|
+
```sh
|
|
194
|
+
scripts/test.sh # unit suite (Chromium-dependent tests self-skip)
|
|
195
|
+
scripts/test.sh --render # real-Chromium tests in the Playwright image
|
|
196
|
+
scripts/test.sh --all # both
|
|
197
|
+
scripts/lint.sh # ruff
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## Hosted version
|
|
201
|
+
|
|
202
|
+
[sheetrender.com](https://sheetrender.com) is the hosted product built on this
|
|
203
|
+
engine: a template wizard with AI design generation, Google Sheets sync,
|
|
204
|
+
scheduled runs, and email/Drive delivery. If you'd rather not run Python,
|
|
205
|
+
that's the two-minute path.
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## Model
|
|
4
|
+
|
|
5
|
+
SheetRender is built to render **untrusted templates over untrusted data**:
|
|
6
|
+
|
|
7
|
+
- Jinja2 executes in `SandboxedEnvironment` with autoescaping on.
|
|
8
|
+
- Template HTML passes through an nh3 allowlist sanitizer before reaching the
|
|
9
|
+
browser.
|
|
10
|
+
- The Chromium context intercepts every network request and blocks all hosts
|
|
11
|
+
outside `RenderConfig.allowed_egress_hosts` (default: Google Fonts only), so
|
|
12
|
+
a template cannot exfiltrate row data.
|
|
13
|
+
- Chromium's own sandbox is left enabled — run the engine as an unprivileged
|
|
14
|
+
user, not root.
|
|
15
|
+
|
|
16
|
+
Anything that escapes one of those layers is a vulnerability we want to know
|
|
17
|
+
about: sandbox escapes, sanitizer bypasses that reach script execution, egress
|
|
18
|
+
allowlist bypasses, or a template that can read another render's data.
|
|
19
|
+
|
|
20
|
+
## Reporting
|
|
21
|
+
|
|
22
|
+
Email **info@finaldynamics.com** with a proof-of-concept template/data pair.
|
|
23
|
+
Please don't open a public issue for suspected vulnerabilities. We'll respond
|
|
24
|
+
within a few days, and credit you in the changelog unless you'd rather not be
|
|
25
|
+
named.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Line-item invoice (grouped mode)
|
|
2
|
+
|
|
3
|
+
`data.csv` has one row per line item; `invoice_no` identifies the invoice each
|
|
4
|
+
line belongs to. Grouped rendering emits one PDF per invoice, with the group's
|
|
5
|
+
rows available to the template as `items` and the total computed by
|
|
6
|
+
`{{ items | sumcol('line_total') | money2 }}`.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
uvx sheetrender batch template.html data.csv -o out/ \
|
|
10
|
+
--group-by invoice_no --filename "{{ invoice_no }}.pdf" --zip invoices.zip
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Expected output: `INV-2041.pdf` (3 line items), `INV-2042.pdf` (2),
|
|
14
|
+
`INV-2043.pdf` (1), plus `invoices.zip`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
invoice_no,invoice_date,due_date,customer,contact_email,address_line1,address_line2,description,qty,unit_price,line_total
|
|
2
|
+
INV-2041,2026-08-03,2026-09-02,Harbor & Lane LLC,accounts@harborlane.example,214 Dockside Ave,"Portland, OR 97209",Brand identity refresh,1,"$3,200.00","$3,200.00"
|
|
3
|
+
INV-2041,2026-08-03,2026-09-02,Harbor & Lane LLC,accounts@harborlane.example,214 Dockside Ave,"Portland, OR 97209",Landing page design,2,"$850.00","$1,700.00"
|
|
4
|
+
INV-2041,2026-08-03,2026-09-02,Harbor & Lane LLC,accounts@harborlane.example,214 Dockside Ave,"Portland, OR 97209",Hosting (August),1,"$40.00","$40.00"
|
|
5
|
+
INV-2042,2026-08-05,2026-09-04,Cordova Analytics,billing@cordova.example,88 Ninth St Suite 410,"Seattle, WA 98104",Quarterly data audit,1,"$2,400.00","$2,400.00"
|
|
6
|
+
INV-2042,2026-08-05,2026-09-04,Cordova Analytics,billing@cordova.example,88 Ninth St Suite 410,"Seattle, WA 98104",Dashboard maintenance,3,"$300.00","$900.00"
|
|
7
|
+
INV-2043,2026-08-11,2026-09-10,Bright Fern Studio,hello@brightfern.example,7 Alder Walk,"Eugene, OR 97401",Illustration set (12 pieces),1,"$1,560.00","$1,560.00"
|