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.
Files changed (38) hide show
  1. sheetrender-0.1.0/.github/workflows/ci.yml +40 -0
  2. sheetrender-0.1.0/.github/workflows/publish.yml +36 -0
  3. sheetrender-0.1.0/.gitignore +7 -0
  4. sheetrender-0.1.0/CHANGELOG.md +7 -0
  5. sheetrender-0.1.0/CONTRIBUTING.md +40 -0
  6. sheetrender-0.1.0/LICENSE +21 -0
  7. sheetrender-0.1.0/PKG-INFO +241 -0
  8. sheetrender-0.1.0/README.md +209 -0
  9. sheetrender-0.1.0/SECURITY.md +25 -0
  10. sheetrender-0.1.0/examples/invoice/README.md +14 -0
  11. sheetrender-0.1.0/examples/invoice/data.csv +7 -0
  12. sheetrender-0.1.0/examples/invoice/template.html +97 -0
  13. sheetrender-0.1.0/pyproject.toml +77 -0
  14. sheetrender-0.1.0/scripts/lint.sh +17 -0
  15. sheetrender-0.1.0/scripts/test.sh +80 -0
  16. sheetrender-0.1.0/src/sheetrender/__init__.py +101 -0
  17. sheetrender-0.1.0/src/sheetrender/cli.py +539 -0
  18. sheetrender-0.1.0/src/sheetrender/config.py +27 -0
  19. sheetrender-0.1.0/src/sheetrender/filenames.py +74 -0
  20. sheetrender-0.1.0/src/sheetrender/grouping.py +255 -0
  21. sheetrender-0.1.0/src/sheetrender/html_sanitize.py +110 -0
  22. sheetrender-0.1.0/src/sheetrender/render.py +1064 -0
  23. sheetrender-0.1.0/src/sheetrender/sanitize.py +32 -0
  24. sheetrender-0.1.0/src/sheetrender/sheets.py +193 -0
  25. sheetrender-0.1.0/src/sheetrender/templating.py +194 -0
  26. sheetrender-0.1.0/tests/conftest.py +18 -0
  27. sheetrender-0.1.0/tests/data/performance-reports.xlsx +0 -0
  28. sheetrender-0.1.0/tests/test_config.py +211 -0
  29. sheetrender-0.1.0/tests/test_filenames.py +48 -0
  30. sheetrender-0.1.0/tests/test_group_detect.py +51 -0
  31. sheetrender-0.1.0/tests/test_grouping.py +210 -0
  32. sheetrender-0.1.0/tests/test_html_sanitize.py +99 -0
  33. sheetrender-0.1.0/tests/test_render.py +993 -0
  34. sheetrender-0.1.0/tests/test_sanitize.py +26 -0
  35. sheetrender-0.1.0/tests/test_sheets.py +18 -0
  36. sheetrender-0.1.0/tests/test_sheets_csv.py +90 -0
  37. sheetrender-0.1.0/tests/test_templating.py +155 -0
  38. 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,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ *.egg-info/
6
+ .pytest_cache/
7
+ .ruff_cache/
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Initial extraction of the SheetRender render engine: Chromium PDF rendering
6
+ with browser recycling, sandboxed Jinja templating, CSV/XLSX ingestion, row
7
+ grouping, filename templates, merge/zip, and the `sheetrender` CLI.
@@ -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"