sheetrender 0.1.0__tar.gz → 0.1.2__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 (39) hide show
  1. sheetrender-0.1.2/CHANGELOG.md +18 -0
  2. {sheetrender-0.1.0 → sheetrender-0.1.2}/LICENSE +1 -1
  3. {sheetrender-0.1.0 → sheetrender-0.1.2}/PKG-INFO +64 -58
  4. {sheetrender-0.1.0 → sheetrender-0.1.2}/README.md +62 -56
  5. {sheetrender-0.1.0 → sheetrender-0.1.2}/SECURITY.md +1 -1
  6. {sheetrender-0.1.0 → sheetrender-0.1.2}/pyproject.toml +2 -2
  7. {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/templating.py +49 -1
  8. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_templating.py +33 -0
  9. {sheetrender-0.1.0 → sheetrender-0.1.2}/uv.lock +1 -1
  10. sheetrender-0.1.0/CHANGELOG.md +0 -7
  11. {sheetrender-0.1.0 → sheetrender-0.1.2}/.github/workflows/ci.yml +0 -0
  12. {sheetrender-0.1.0 → sheetrender-0.1.2}/.github/workflows/publish.yml +0 -0
  13. {sheetrender-0.1.0 → sheetrender-0.1.2}/.gitignore +0 -0
  14. {sheetrender-0.1.0 → sheetrender-0.1.2}/CONTRIBUTING.md +0 -0
  15. {sheetrender-0.1.0 → sheetrender-0.1.2}/examples/invoice/README.md +0 -0
  16. {sheetrender-0.1.0 → sheetrender-0.1.2}/examples/invoice/data.csv +0 -0
  17. {sheetrender-0.1.0 → sheetrender-0.1.2}/examples/invoice/template.html +0 -0
  18. {sheetrender-0.1.0 → sheetrender-0.1.2}/scripts/lint.sh +0 -0
  19. {sheetrender-0.1.0 → sheetrender-0.1.2}/scripts/test.sh +0 -0
  20. {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/__init__.py +0 -0
  21. {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/cli.py +0 -0
  22. {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/config.py +0 -0
  23. {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/filenames.py +0 -0
  24. {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/grouping.py +0 -0
  25. {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/html_sanitize.py +0 -0
  26. {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/render.py +0 -0
  27. {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/sanitize.py +0 -0
  28. {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/sheets.py +0 -0
  29. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/conftest.py +0 -0
  30. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/data/performance-reports.xlsx +0 -0
  31. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_config.py +0 -0
  32. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_filenames.py +0 -0
  33. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_group_detect.py +0 -0
  34. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_grouping.py +0 -0
  35. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_html_sanitize.py +0 -0
  36. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_render.py +0 -0
  37. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_sanitize.py +0 -0
  38. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_sheets.py +0 -0
  39. {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_sheets_csv.py +0 -0
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ ## 0.1.2
4
+
5
+ - The template sandbox intercepts `*` and `**` and refuses repeats over one
6
+ million characters or exponents over 10,000 before they allocate. The
7
+ rendered-size check ran after evaluation, so `{{ 'x' * 10**9 }}` in a
8
+ template or filename pattern could exhaust memory first.
9
+
10
+ ## 0.1.1
11
+
12
+ - Contact address is now contact@sheetrender.com (metadata-only release).
13
+
14
+ ## 0.1.0
15
+
16
+ - Initial extraction of the SheetRender render engine: Chromium PDF rendering
17
+ with browser recycling, sandboxed Jinja templating, CSV/XLSX ingestion, row
18
+ grouping, filename templates, merge/zip, and the `sheetrender` CLI.
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Final Dynamics
3
+ Copyright (c) 2026 SheetRender
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sheetrender
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Spreadsheet + HTML template in, a PDF per row out. The batch rendering engine behind sheetrender.com.
5
5
  Project-URL: Homepage, https://sheetrender.com
6
6
  Project-URL: Source, https://github.com/sheetrender/sheetrender
7
7
  Project-URL: Issues, https://github.com/sheetrender/sheetrender/issues
8
8
  Project-URL: Changelog, https://github.com/sheetrender/sheetrender/blob/master/CHANGELOG.md
9
- Author-email: SheetRender <info@finaldynamics.com>
9
+ Author-email: SheetRender <contact@sheetrender.com>
10
10
  License-Expression: MIT
11
11
  License-File: LICENSE
12
12
  Keywords: batch,csv,html-to-pdf,invoice,jinja2,mail-merge,pdf,playwright,spreadsheet,xlsx
@@ -35,9 +35,9 @@ Description-Content-Type: text/markdown
35
35
  **Spreadsheet + HTML template in, a stack of PDFs out.**
36
36
 
37
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
38
+ well-paginated PDF per row, or per group of rows for documents with line items
39
+ like invoices and statements. It's the same rendering engine that runs
40
+ [sheetrender.com](https://sheetrender.com), pulled out as a standalone MIT
41
41
  library and CLI.
42
42
 
43
43
  ```sh
@@ -48,31 +48,32 @@ uvx sheetrender batch examples/invoice/template.html examples/invoice/data.csv \
48
48
 
49
49
  That renders one invoice per `invoice_no`, with the group's rows available to
50
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.)
51
+ (The clone is only there for the example files. `uvx sheetrender` itself needs
52
+ no install at all.)
53
53
 
54
54
  ## Why this exists
55
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.
56
+ Every "generate PDFs from a spreadsheet" recipe I found glues a headless
57
+ browser to a for-loop and hopes. That holds up fine for fifty rows. Somewhere
58
+ past a few thousand it starts leaking memory, or it wedges, or a webfont request
59
+ stalls and takes a render slot down with it. I hit all of those running this in
60
+ production, and the fixes are what's in here:
61
+
62
+ - The browser is shared across renders and sits behind a priority-aware
63
+ concurrency gate. It gets recycled after N renders or N minutes, so a
64
+ thousand-row batch doesn't leak or wedge.
65
+ - Print CSS behaves. Margins, page sizes, backgrounds, and a bounded wait for
66
+ webfonts, so a font fetch that never returns degrades to fallback fonts
67
+ instead of hanging the render.
68
+ - Jinja2 runs sandboxed with `StrictUndefined` and autoescaping on. Misspell a
69
+ column name and the render fails loudly instead of handing you 800 documents
70
+ with a hole in them.
71
+ - Templates are sanitized with [nh3](https://github.com/messense/nh3), and the
72
+ browser context blocks every network request except an allowlist (Google
73
+ Fonts by default).
74
+ - Filenames come from templates, sanitized per-platform and de-duplicated. Rows
75
+ group with auto-detection, PDFs merge with stamped page numbers, and there's
76
+ zip output, thumbnails and PDF metadata.
76
77
 
77
78
  ## Install
78
79
 
@@ -81,8 +82,8 @@ uv add sheetrender # or: pip install sheetrender
81
82
  uv run playwright install chromium
82
83
  ```
83
84
 
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
85
+ Or run the CLI without installing anything: `uvx sheetrender --help`. You still
86
+ need Chromium once. Without a local playwright on PATH, that's
86
87
 
87
88
  ```sh
88
89
  uvx --from playwright playwright install chromium
@@ -113,11 +114,15 @@ sheetrender thumbnail template.html -o thumb.png --data row.json
113
114
  ```
114
115
 
115
116
  Page geometry: `--page-size A3|A4|A5|Letter|Legal|Tabloid --landscape
116
- --margin 12mm`. Watermarking (`batch` only):
117
+ --margin 12mm`.
118
+
119
+ Watermarking works on `batch` only.
117
120
  `--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
+ your snippet before `</body>`. Use `position:fixed` if you want it on every
122
+ printed page; without it the snippet just sits at the end of the document.
123
+
124
+ Data files are `.csv` or `.xlsx`. The single-document `--data` flag takes a JSON
125
+ object file.
121
126
 
122
127
  ## Python API
123
128
 
@@ -153,14 +158,14 @@ exported from the package root.
153
158
 
154
159
  ## Templates
155
160
 
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
161
+ Templates are plain HTML and CSS rendered by Chromium's print pipeline, with
162
+ Jinja2 for data. Each row's columns become top-level variables, so a header of
158
163
  `Invoice No.` is available as `{{ invoice_no }}` (`sheetrender inspect` shows
159
- the exact mapping). The environment is sandboxed, autoescaped, and strict:
164
+ the exact mapping). The environment is sandboxed, autoescaped and strict:
160
165
  referencing a column that doesn't exist is an error, not a silent blank.
161
166
 
162
- Null-safe formatting filters (bad input renders as an empty string, never a
163
- crash mid-batch):
167
+ The formatting filters are null-safe, so bad input renders as an empty string
168
+ rather than crashing halfway through a batch:
164
169
 
165
170
  | Filter | Example output | Notes |
166
171
  |---|---|---|
@@ -170,34 +175,35 @@ crash mid-batch):
170
175
  | `comma` / `comma2` | `1,234` / `1,234.50` | no currency symbol |
171
176
  | `pct` | `89%` | rounds to whole percent |
172
177
  | `bar_width` | `0`–`100` | clamped, for CSS bar charts |
173
- | `sign_class` | `positive` / `negative` | `{{ actual \| sign_class(target) }}` — compares two values |
178
+ | `sign_class` | `positive` / `negative` | takes the value to compare against: `{{ actual \| sign_class(target) }}` |
174
179
  | `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 |
180
+ | `sumcol` | `{{ items \| sumcol('amount') \| money2 }}` | Decimal-exact column sum. Strips `$€£` and commas, treats `(123)` as negative |
176
181
 
177
- Rendering is deterministic across machines: the browser context is pinned to
178
- `en-US` / UTC, so dates and numbers format the same everywhere.
182
+ Rendering is deterministic across machines. The browser context is pinned to
183
+ `en-US` and UTC, so dates and numbers format the same everywhere.
179
184
 
180
185
  ### Grouped documents
181
186
 
182
187
  `--group-by customer_id` (or `grouped_render_units` in Python) renders one
183
188
  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
189
+ plus three reserved names: `items` (every row in the group), `item_count` and
185
190
  `group_key`. See [`examples/invoice/`](examples/invoice/) for a complete
186
191
  line-item invoice.
187
192
 
188
193
  ## Security model
189
194
 
190
- Designed for rendering templates you didn't write:
195
+ This is built to render templates you didn't write yourself:
191
196
 
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
197
+ - Jinja2 runs in a `SandboxedEnvironment`, so there's no attribute traversal to
198
+ dangerous internals, and autoescape is on.
199
+ - Template HTML goes through nh3 (allowlist-based) before it reaches the
195
200
  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
+ - The browser context intercepts every network request and blocks anything
202
+ outside `RenderConfig.allowed_egress_hosts` (Google Fonts by default), so a
203
+ malicious template can't exfiltrate row data through an `<img>` beacon.
204
+ - Chromium keeps its sandbox **on**, which is why you shouldn't run the engine
205
+ as root.
206
+ - Author `@page` rules are stripped, so template CSS can't override the page
201
207
  geometry you asked for.
202
208
 
203
209
  ## Configuration
@@ -214,13 +220,13 @@ configure(RenderConfig(
214
220
  ))
215
221
  ```
216
222
 
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.
223
+ Call `configure()` once, and call it before `start_browser()` (or before your
224
+ first render, which starts the browser for you). `concurrency` sizes the gate
225
+ when the browser starts, so changing it after that does nothing.
220
226
 
221
227
  ## Development
222
228
 
223
- No local Python needed — the test suite runs in containers:
229
+ You don't need a local Python. The test suite runs in containers:
224
230
 
225
231
  ```sh
226
232
  scripts/test.sh # unit suite (Chromium-dependent tests self-skip)
@@ -233,8 +239,8 @@ scripts/lint.sh # ruff
233
239
 
234
240
  [sheetrender.com](https://sheetrender.com) is the hosted product built on this
235
241
  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.
242
+ scheduled runs, and email/Drive delivery. If you'd rather not run Python, that's
243
+ the two-minute path.
238
244
 
239
245
  ## License
240
246
 
@@ -3,9 +3,9 @@
3
3
  **Spreadsheet + HTML template in, a stack of PDFs out.**
4
4
 
5
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
6
+ well-paginated PDF per row, or per group of rows for documents with line items
7
+ like invoices and statements. It's the same rendering engine that runs
8
+ [sheetrender.com](https://sheetrender.com), pulled out as a standalone MIT
9
9
  library and CLI.
10
10
 
11
11
  ```sh
@@ -16,31 +16,32 @@ uvx sheetrender batch examples/invoice/template.html examples/invoice/data.csv \
16
16
 
17
17
  That renders one invoice per `invoice_no`, with the group's rows available to
18
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.)
19
+ (The clone is only there for the example files. `uvx sheetrender` itself needs
20
+ no install at all.)
21
21
 
22
22
  ## Why this exists
23
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.
24
+ Every "generate PDFs from a spreadsheet" recipe I found glues a headless
25
+ browser to a for-loop and hopes. That holds up fine for fifty rows. Somewhere
26
+ past a few thousand it starts leaking memory, or it wedges, or a webfont request
27
+ stalls and takes a render slot down with it. I hit all of those running this in
28
+ production, and the fixes are what's in here:
29
+
30
+ - The browser is shared across renders and sits behind a priority-aware
31
+ concurrency gate. It gets recycled after N renders or N minutes, so a
32
+ thousand-row batch doesn't leak or wedge.
33
+ - Print CSS behaves. Margins, page sizes, backgrounds, and a bounded wait for
34
+ webfonts, so a font fetch that never returns degrades to fallback fonts
35
+ instead of hanging the render.
36
+ - Jinja2 runs sandboxed with `StrictUndefined` and autoescaping on. Misspell a
37
+ column name and the render fails loudly instead of handing you 800 documents
38
+ with a hole in them.
39
+ - Templates are sanitized with [nh3](https://github.com/messense/nh3), and the
40
+ browser context blocks every network request except an allowlist (Google
41
+ Fonts by default).
42
+ - Filenames come from templates, sanitized per-platform and de-duplicated. Rows
43
+ group with auto-detection, PDFs merge with stamped page numbers, and there's
44
+ zip output, thumbnails and PDF metadata.
44
45
 
45
46
  ## Install
46
47
 
@@ -49,8 +50,8 @@ uv add sheetrender # or: pip install sheetrender
49
50
  uv run playwright install chromium
50
51
  ```
51
52
 
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
53
+ Or run the CLI without installing anything: `uvx sheetrender --help`. You still
54
+ need Chromium once. Without a local playwright on PATH, that's
54
55
 
55
56
  ```sh
56
57
  uvx --from playwright playwright install chromium
@@ -81,11 +82,15 @@ sheetrender thumbnail template.html -o thumb.png --data row.json
81
82
  ```
82
83
 
83
84
  Page geometry: `--page-size A3|A4|A5|Letter|Legal|Tabloid --landscape
84
- --margin 12mm`. Watermarking (`batch` only):
85
+ --margin 12mm`.
86
+
87
+ Watermarking works on `batch` only.
85
88
  `--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
+ your snippet before `</body>`. Use `position:fixed` if you want it on every
90
+ printed page; without it the snippet just sits at the end of the document.
91
+
92
+ Data files are `.csv` or `.xlsx`. The single-document `--data` flag takes a JSON
93
+ object file.
89
94
 
90
95
  ## Python API
91
96
 
@@ -121,14 +126,14 @@ exported from the package root.
121
126
 
122
127
  ## Templates
123
128
 
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
129
+ Templates are plain HTML and CSS rendered by Chromium's print pipeline, with
130
+ Jinja2 for data. Each row's columns become top-level variables, so a header of
126
131
  `Invoice No.` is available as `{{ invoice_no }}` (`sheetrender inspect` shows
127
- the exact mapping). The environment is sandboxed, autoescaped, and strict:
132
+ the exact mapping). The environment is sandboxed, autoescaped and strict:
128
133
  referencing a column that doesn't exist is an error, not a silent blank.
129
134
 
130
- Null-safe formatting filters (bad input renders as an empty string, never a
131
- crash mid-batch):
135
+ The formatting filters are null-safe, so bad input renders as an empty string
136
+ rather than crashing halfway through a batch:
132
137
 
133
138
  | Filter | Example output | Notes |
134
139
  |---|---|---|
@@ -138,34 +143,35 @@ crash mid-batch):
138
143
  | `comma` / `comma2` | `1,234` / `1,234.50` | no currency symbol |
139
144
  | `pct` | `89%` | rounds to whole percent |
140
145
  | `bar_width` | `0`–`100` | clamped, for CSS bar charts |
141
- | `sign_class` | `positive` / `negative` | `{{ actual \| sign_class(target) }}` — compares two values |
146
+ | `sign_class` | `positive` / `negative` | takes the value to compare against: `{{ actual \| sign_class(target) }}` |
142
147
  | `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 |
148
+ | `sumcol` | `{{ items \| sumcol('amount') \| money2 }}` | Decimal-exact column sum. Strips `$€£` and commas, treats `(123)` as negative |
144
149
 
145
- Rendering is deterministic across machines: the browser context is pinned to
146
- `en-US` / UTC, so dates and numbers format the same everywhere.
150
+ Rendering is deterministic across machines. The browser context is pinned to
151
+ `en-US` and UTC, so dates and numbers format the same everywhere.
147
152
 
148
153
  ### Grouped documents
149
154
 
150
155
  `--group-by customer_id` (or `grouped_render_units` in Python) renders one
151
156
  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
157
+ plus three reserved names: `items` (every row in the group), `item_count` and
153
158
  `group_key`. See [`examples/invoice/`](examples/invoice/) for a complete
154
159
  line-item invoice.
155
160
 
156
161
  ## Security model
157
162
 
158
- Designed for rendering templates you didn't write:
163
+ This is built to render templates you didn't write yourself:
159
164
 
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
165
+ - Jinja2 runs in a `SandboxedEnvironment`, so there's no attribute traversal to
166
+ dangerous internals, and autoescape is on.
167
+ - Template HTML goes through nh3 (allowlist-based) before it reaches the
163
168
  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
+ - The browser context intercepts every network request and blocks anything
170
+ outside `RenderConfig.allowed_egress_hosts` (Google Fonts by default), so a
171
+ malicious template can't exfiltrate row data through an `<img>` beacon.
172
+ - Chromium keeps its sandbox **on**, which is why you shouldn't run the engine
173
+ as root.
174
+ - Author `@page` rules are stripped, so template CSS can't override the page
169
175
  geometry you asked for.
170
176
 
171
177
  ## Configuration
@@ -182,13 +188,13 @@ configure(RenderConfig(
182
188
  ))
183
189
  ```
184
190
 
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.
191
+ Call `configure()` once, and call it before `start_browser()` (or before your
192
+ first render, which starts the browser for you). `concurrency` sizes the gate
193
+ when the browser starts, so changing it after that does nothing.
188
194
 
189
195
  ## Development
190
196
 
191
- No local Python needed — the test suite runs in containers:
197
+ You don't need a local Python. The test suite runs in containers:
192
198
 
193
199
  ```sh
194
200
  scripts/test.sh # unit suite (Chromium-dependent tests self-skip)
@@ -201,8 +207,8 @@ scripts/lint.sh # ruff
201
207
 
202
208
  [sheetrender.com](https://sheetrender.com) is the hosted product built on this
203
209
  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.
210
+ scheduled runs, and email/Drive delivery. If you'd rather not run Python, that's
211
+ the two-minute path.
206
212
 
207
213
  ## License
208
214
 
@@ -19,7 +19,7 @@ allowlist bypasses, or a template that can read another render's data.
19
19
 
20
20
  ## Reporting
21
21
 
22
- Email **info@finaldynamics.com** with a proof-of-concept template/data pair.
22
+ Email **contact@sheetrender.com** with a proof-of-concept template/data pair.
23
23
  Please don't open a public issue for suspected vulnerabilities. We'll respond
24
24
  within a few days, and credit you in the changelog unless you'd rather not be
25
25
  named.
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "sheetrender"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "Spreadsheet + HTML template in, a PDF per row out. The batch rendering engine behind sheetrender.com."
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
8
8
  requires-python = ">=3.11,<3.14"
9
- authors = [{ name = "SheetRender", email = "info@finaldynamics.com" }]
9
+ authors = [{ name = "SheetRender", email = "contact@sheetrender.com" }]
10
10
  keywords = [
11
11
  "pdf",
12
12
  "html-to-pdf",
@@ -3,8 +3,17 @@ from __future__ import annotations
3
3
  from decimal import Decimal, InvalidOperation
4
4
 
5
5
  from jinja2 import StrictUndefined, Template, TemplateError, select_autoescape
6
+ from jinja2.exceptions import SecurityError
6
7
  from jinja2.sandbox import SandboxedEnvironment
7
8
 
9
+ # Ceilings for the two operators that can allocate far more than the template
10
+ # text suggests. The rendered-size check runs after the expression has already
11
+ # been evaluated, so `{{ 'x' * 10**9 }}` or `10**10**8` would exhaust memory or
12
+ # CPU before it ever fired. Both limits are generous for any real document.
13
+ MAX_REPEAT_LENGTH = 1_000_000
14
+ MAX_POWER_EXPONENT = 10_000
15
+ MAX_POWER_BASE_BITS = 4_096
16
+
8
17
 
9
18
  def money_k(x):
10
19
  try:
@@ -113,8 +122,47 @@ def yesno_class(v):
113
122
  return "" if str(v).strip().lower() in {"yes", "true", "1"} else "no"
114
123
 
115
124
 
125
+ class BoundedSandboxedEnvironment(SandboxedEnvironment):
126
+ """SandboxedEnvironment that refuses runaway `*` and `**` at evaluation time."""
127
+
128
+ intercepted_binops = frozenset({"*", "**"})
129
+
130
+ def call_binop(self, context, operator, left, right):
131
+ if operator == "*":
132
+ _check_repeat(left, right)
133
+ elif operator == "**":
134
+ _check_power(left, right)
135
+ return super().call_binop(context, operator, left, right)
136
+
137
+
138
+ def _sized_length(value) -> int | None:
139
+ if isinstance(value, (str, bytes, list, tuple)):
140
+ return len(value)
141
+ return None
142
+
143
+
144
+ def _check_repeat(left, right) -> None:
145
+ for seq, count in ((left, right), (right, left)):
146
+ length = _sized_length(seq)
147
+ if length is None or isinstance(count, bool) or not isinstance(count, int):
148
+ continue
149
+ if count > 0 and length * count > MAX_REPEAT_LENGTH:
150
+ raise SecurityError(
151
+ f"repeating a value {count} times would exceed {MAX_REPEAT_LENGTH} characters"
152
+ )
153
+
154
+
155
+ def _check_power(base, exponent) -> None:
156
+ if isinstance(exponent, bool) or not isinstance(exponent, int):
157
+ return
158
+ if abs(exponent) > MAX_POWER_EXPONENT:
159
+ raise SecurityError(f"exponent {exponent} exceeds {MAX_POWER_EXPONENT}")
160
+ if isinstance(base, int) and not isinstance(base, bool) and base.bit_length() > MAX_POWER_BASE_BITS:
161
+ raise SecurityError("power base is too large")
162
+
163
+
116
164
  def get_env(*, autoescape: bool = True) -> SandboxedEnvironment:
117
- env = SandboxedEnvironment(
165
+ env = BoundedSandboxedEnvironment(
118
166
  autoescape=select_autoescape(default=True) if autoescape else False,
119
167
  undefined=StrictUndefined,
120
168
  )
@@ -153,3 +153,36 @@ def test_syntax_error_raises():
153
153
  with pytest.raises(TemplateError) as exc_info:
154
154
  validate_and_render("{% for %}", {})
155
155
  assert str(exc_info.value).count("Template render error:") == 1
156
+
157
+
158
+ class TestRunawayOperators:
159
+ """`*` and `**` are intercepted before they allocate, not after."""
160
+
161
+ def _render(self, source: str) -> str:
162
+ from sheetrender.templating import get_env
163
+
164
+ return get_env().from_string(source).render()
165
+
166
+ def test_ordinary_arithmetic_and_repeats_still_work(self):
167
+ assert self._render("{{ 3 * 4 }} {{ 2 ** 10 }} {{ '-' * 20 }}") == "12 1024 " + "-" * 20
168
+
169
+ def test_huge_string_repeat_is_refused(self):
170
+ from jinja2.exceptions import SecurityError
171
+
172
+ with pytest.raises(SecurityError):
173
+ self._render("{{ 'x' * 10**9 }}")
174
+ with pytest.raises(SecurityError):
175
+ self._render("{{ 10**9 * 'x' }}")
176
+ with pytest.raises(SecurityError):
177
+ self._render("{{ [1] * 5000000 }}")
178
+
179
+ def test_huge_power_is_refused(self):
180
+ from jinja2.exceptions import SecurityError
181
+
182
+ with pytest.raises(SecurityError):
183
+ self._render("{{ 10 ** 100000 }}")
184
+ with pytest.raises(SecurityError):
185
+ self._render("{{ (2 ** 5000) ** 2 }}")
186
+
187
+ def test_float_and_negative_exponents_are_not_flagged(self):
188
+ assert self._render("{{ 2 ** -1 }} {{ 2.0 ** 3 }}") == "0.5 8.0"
@@ -469,7 +469,7 @@ wheels = [
469
469
 
470
470
  [[package]]
471
471
  name = "sheetrender"
472
- version = "0.1.0"
472
+ version = "0.1.2"
473
473
  source = { editable = "." }
474
474
  dependencies = [
475
475
  { name = "jinja2" },
@@ -1,7 +0,0 @@
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.
File without changes
File without changes
File without changes
File without changes