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.
- sheetrender-0.1.2/CHANGELOG.md +18 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/LICENSE +1 -1
- {sheetrender-0.1.0 → sheetrender-0.1.2}/PKG-INFO +64 -58
- {sheetrender-0.1.0 → sheetrender-0.1.2}/README.md +62 -56
- {sheetrender-0.1.0 → sheetrender-0.1.2}/SECURITY.md +1 -1
- {sheetrender-0.1.0 → sheetrender-0.1.2}/pyproject.toml +2 -2
- {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/templating.py +49 -1
- {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_templating.py +33 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/uv.lock +1 -1
- sheetrender-0.1.0/CHANGELOG.md +0 -7
- {sheetrender-0.1.0 → sheetrender-0.1.2}/.github/workflows/ci.yml +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/.github/workflows/publish.yml +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/.gitignore +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/CONTRIBUTING.md +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/examples/invoice/README.md +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/examples/invoice/data.csv +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/examples/invoice/template.html +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/scripts/lint.sh +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/scripts/test.sh +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/__init__.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/cli.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/config.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/filenames.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/grouping.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/html_sanitize.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/render.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/sanitize.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/src/sheetrender/sheets.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/conftest.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/data/performance-reports.xlsx +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_config.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_filenames.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_group_detect.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_grouping.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_html_sanitize.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_render.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_sanitize.py +0 -0
- {sheetrender-0.1.0 → sheetrender-0.1.2}/tests/test_sheets.py +0 -0
- {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,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sheetrender
|
|
3
|
-
Version: 0.1.
|
|
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 <
|
|
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
|
|
39
|
-
|
|
40
|
-
[sheetrender.com](https://sheetrender.com),
|
|
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
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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`.
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
157
|
-
Jinja2 for data. Each row's columns become top-level variables
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
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) }}`
|
|
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
|
|
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
|
|
178
|
-
`en-US`
|
|
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
|
|
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
|
-
|
|
195
|
+
This is built to render templates you didn't write yourself:
|
|
191
196
|
|
|
192
|
-
- Jinja2 runs in `SandboxedEnvironment
|
|
193
|
-
dangerous internals, autoescape on.
|
|
194
|
-
- Template HTML
|
|
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
|
|
197
|
-
outside `RenderConfig.allowed_egress_hosts` (
|
|
198
|
-
malicious template can't exfiltrate row data
|
|
199
|
-
- Chromium
|
|
200
|
-
|
|
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
|
|
218
|
-
starts
|
|
219
|
-
after that
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
7
|
-
|
|
8
|
-
[sheetrender.com](https://sheetrender.com),
|
|
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
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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`.
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
125
|
-
Jinja2 for data. Each row's columns become top-level variables
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
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) }}`
|
|
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
|
|
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
|
|
146
|
-
`en-US`
|
|
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
|
|
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
|
-
|
|
163
|
+
This is built to render templates you didn't write yourself:
|
|
159
164
|
|
|
160
|
-
- Jinja2 runs in `SandboxedEnvironment
|
|
161
|
-
dangerous internals, autoescape on.
|
|
162
|
-
- Template HTML
|
|
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
|
|
165
|
-
outside `RenderConfig.allowed_egress_hosts` (
|
|
166
|
-
malicious template can't exfiltrate row data
|
|
167
|
-
- Chromium
|
|
168
|
-
|
|
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
|
|
186
|
-
starts
|
|
187
|
-
after that
|
|
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
|
-
|
|
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
|
-
|
|
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 **
|
|
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.
|
|
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 = "
|
|
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 =
|
|
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"
|
sheetrender-0.1.0/CHANGELOG.md
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|