podpack-pdf 0.2.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.
- podpack_pdf-0.2.2/PKG-INFO +262 -0
- podpack_pdf-0.2.2/README.md +248 -0
- podpack_pdf-0.2.2/pyproject.toml +59 -0
- podpack_pdf-0.2.2/src/podpack_pdf/__init__.py +89 -0
- podpack_pdf-0.2.2/src/podpack_pdf/booklet.py +72 -0
- podpack_pdf-0.2.2/src/podpack_pdf/forms.py +16 -0
- podpack_pdf-0.2.2/src/podpack_pdf/templates/pdf/base.html +18 -0
- podpack_pdf-0.2.2/src/podpack_pdf/templates/pdf/booklet_form.html +25 -0
- podpack_pdf-0.2.2/src/podpack_pdf/templates/pdf/index.html +12 -0
- podpack_pdf-0.2.2/src/podpack_pdf/templates/pdf/pagesplit_form.html +28 -0
- podpack_pdf-0.2.2/src/podpack_pdf/templates/pdf/standalone.html +41 -0
- podpack_pdf-0.2.2/src/podpack_pdf/views.py +147 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: podpack-pdf
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: PDF booklet-imposition and page-splitting tools as an installable Flask app
|
|
5
|
+
Author: Steve Holden
|
|
6
|
+
Author-email: Steve Holden <steve@holdenweb.com>
|
|
7
|
+
Requires-Dist: flask>=2.3.3
|
|
8
|
+
Requires-Dist: flask-wtf>=1.2.1
|
|
9
|
+
Requires-Dist: wtforms>=3.0
|
|
10
|
+
Requires-Dist: pdfrw>=0.4
|
|
11
|
+
Requires-Dist: reportlab>=3.6.12
|
|
12
|
+
Requires-Python: >=3.12, <4.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# podpack-pdf
|
|
16
|
+
|
|
17
|
+
Two PDF utilities packaged as an installable Flask app:
|
|
18
|
+
|
|
19
|
+
- **PDF Booklet Maker** — imposes an A4 document four-up onto A4 sheets so that
|
|
20
|
+
each group of eight pages forms a signature, returned as a zip of the odd and
|
|
21
|
+
even sides for duplex printing.
|
|
22
|
+
- **PDF Page Splitter** — explodes a PDF into one file per page, returned as a
|
|
23
|
+
zip.
|
|
24
|
+
|
|
25
|
+
Extracted from [holdenweb.com](https://holdenweb.com) with its history intact. It
|
|
26
|
+
answers to two contracts and requires neither: a plain Flask blueprint, and a
|
|
27
|
+
[podpack](https://github.com/holdenweb/podpack) app.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv add podpack-pdf
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The distribution is `podpack-pdf`; the module, and the app's name everywhere podpack
|
|
36
|
+
needs one, is `podpack_pdf`.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## As a plain Flask blueprint
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from flask import Flask
|
|
44
|
+
from podpack_pdf import pdf_blueprint
|
|
45
|
+
|
|
46
|
+
app = Flask(__name__)
|
|
47
|
+
app.config["SECRET_KEY"] = "..." # required: the forms are CSRF-protected
|
|
48
|
+
app.register_blueprint(pdf_blueprint, url_prefix="/pdf/")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
That yields three endpoints under the prefix you chose:
|
|
52
|
+
|
|
53
|
+
| Route | Purpose |
|
|
54
|
+
| --- | --- |
|
|
55
|
+
| `/` | Index listing both tools |
|
|
56
|
+
| `/booklet` | Upload a PDF, receive a zip of imposed odd/even sides |
|
|
57
|
+
| `/pagezip` | Upload a PDF, receive a zip of one file per page |
|
|
58
|
+
|
|
59
|
+
Here the mount point is yours: the blueprint is relocatable, so mount it at
|
|
60
|
+
`/tools/pdf` or anywhere else and its internal links follow, because they are
|
|
61
|
+
generated with `url_for`.
|
|
62
|
+
|
|
63
|
+
### Discovery
|
|
64
|
+
|
|
65
|
+
```toml
|
|
66
|
+
[project.entry-points."holdenweb.apps"]
|
|
67
|
+
pdf = "podpack_pdf:pdf_blueprint"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A host site can enumerate its installed apps rather than hard-coding imports:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from importlib.metadata import entry_points
|
|
74
|
+
|
|
75
|
+
for entry_point in entry_points(group="holdenweb.apps"):
|
|
76
|
+
app.register_blueprint(entry_point.load(), url_prefix=f"/{entry_point.name}/")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The entry point resolves to the **blueprint itself**, not to a bespoke
|
|
80
|
+
`register()` callable, so the contract is expressed in Flask's own vocabulary.
|
|
81
|
+
The entry-point *name* is only a mount hint for that loop; it is not the app's
|
|
82
|
+
name.
|
|
83
|
+
|
|
84
|
+
### Setup hooks
|
|
85
|
+
|
|
86
|
+
Anything needed at registration time goes through Flask's own deferred-
|
|
87
|
+
registration hook, which is the equivalent of Django's `AppConfig.ready`. This
|
|
88
|
+
package uses it on itself, to settle its two config keys:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
@pdf_blueprint.record_once
|
|
92
|
+
def _defaults(state):
|
|
93
|
+
state.app.config.setdefault("PODPACK_PDF_BASE_TEMPLATE", STANDALONE_LAYOUT)
|
|
94
|
+
state.app.config.setdefault("PODPACK_PDF_MAX_PAGES", DEFAULT_MAX_PAGES)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## As a podpack app
|
|
100
|
+
|
|
101
|
+
Add the package's **import name** to the site's config file and restart:
|
|
102
|
+
|
|
103
|
+
```toml
|
|
104
|
+
[site]
|
|
105
|
+
apps = ["podpack_pdf"]
|
|
106
|
+
|
|
107
|
+
[apps.pdf]
|
|
108
|
+
max_pages = 200
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
There is no second step. podpack imports the package, reads its module-level
|
|
112
|
+
`site_app`, and takes the mount point, the nav entry and the config namespace
|
|
113
|
+
from it:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
site_app = SiteApp(
|
|
117
|
+
blueprint=pdf_blueprint,
|
|
118
|
+
url_prefix="/pdf",
|
|
119
|
+
nav=(Section("PDF tools", "pdf.root_page"),),
|
|
120
|
+
init=_init,
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The app's name is not declared: podpack derives it from the blueprint's own
|
|
125
|
+
name, so `pdf` — the template namespace, the config section, the data
|
|
126
|
+
directory — follows from `Blueprint("pdf", ...)` in views.py.
|
|
127
|
+
|
|
128
|
+
`url_prefix` is what this app asks for, not what it is entitled to. A site that
|
|
129
|
+
wants these pages somewhere else in its address space says so, and the nav entry
|
|
130
|
+
follows without either side restating it — a `Section` names an endpoint, so
|
|
131
|
+
podpack resolves it with `url_for` as the chrome renders:
|
|
132
|
+
|
|
133
|
+
```toml
|
|
134
|
+
[site.mounts]
|
|
135
|
+
pdf = "/tools/pdf"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
That lives under `[site]`, not in `[apps.pdf]`, because it is the site's
|
|
139
|
+
policy rather than this package's configuration — `app_config()` returns only
|
|
140
|
+
what this app is meant to read, and where it was mounted is not among it.
|
|
141
|
+
|
|
142
|
+
So the mount point is the host's under either contract; only the way of saying
|
|
143
|
+
so differs — an argument to `register_blueprint` there, a line of config here.
|
|
144
|
+
|
|
145
|
+
This app's **name is its blueprint's name**: podpack derives it, so
|
|
146
|
+
`Blueprint("pdf", …)` in `views.py` is what decides the template namespace,
|
|
147
|
+
the data directory and the `[apps.pdf]` config section. There is nothing to
|
|
148
|
+
keep in step by hand.
|
|
149
|
+
|
|
150
|
+
podpack is an **optional** import here — it is on no package index, and this
|
|
151
|
+
package's first contract is to need no framework at all. Where it is absent,
|
|
152
|
+
`podpack_pdf.site_app` is `None` and everything else works unchanged.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## How one package serves both
|
|
157
|
+
|
|
158
|
+
| | Plain Flask | podpack |
|
|
159
|
+
| --- | --- | --- |
|
|
160
|
+
| Discovery | `holdenweb.apps` entry point, or a direct import | import name in the site's `apps` list |
|
|
161
|
+
| What is discovered | the `Blueprint` | `site_app: SiteApp` |
|
|
162
|
+
| Mount point | the host's argument to `register_blueprint` | `[site.mounts] pdf`, defaulting to the app's own |
|
|
163
|
+
| Setup hook | `pdf_blueprint.record_once` | `SiteApp.init` |
|
|
164
|
+
| Page layout | `pdf/standalone.html`, shipped here | the site's `base.html` |
|
|
165
|
+
| Configuration | `app.config["PODPACK_PDF_…"]` | `[apps.pdf]` in the site's TOML |
|
|
166
|
+
| Navigation | the host's business | `nav=(Section(…),)` |
|
|
167
|
+
|
|
168
|
+
The two coexist because the podpack half is a **config translator, not a second
|
|
169
|
+
code path**. Both hosts settle the same two keys before the first request, and
|
|
170
|
+
the views, forms and templates read only those.
|
|
171
|
+
|
|
172
|
+
### Templates, and the layout ladder
|
|
173
|
+
|
|
174
|
+
All templates ship namespaced under `templates/pdf/`, so nothing can collide
|
|
175
|
+
with a host site's own template names. Every page extends `pdf/base.html`,
|
|
176
|
+
which is one line: it extends whatever the host has said should wrap it.
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
a site's own templates/pdf/base.html shadows this package's entirely
|
|
180
|
+
podpack "base.html" -- the site's chrome
|
|
181
|
+
plain Flask pdf/standalone.html, shipped here
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A host of either kind overrides any template here — including the whole page
|
|
185
|
+
layout — by placing a file at the same path in its own template folder. Flask
|
|
186
|
+
searches the application's templates before any blueprint's, so no configuration
|
|
187
|
+
is involved. To wrap these pages in a plain-Flask site's furniture, that is a
|
|
188
|
+
one-line file:
|
|
189
|
+
|
|
190
|
+
```jinja
|
|
191
|
+
{# templates/pdf/base.html in the host site #}
|
|
192
|
+
{% extends "site-base.html" %}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
or a single config key, set before the blueprint is registered:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
app.config["PODPACK_PDF_BASE_TEMPLATE"] = "site-base.html"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Standalone mode does **not** go looking for a `base.html` of its own accord. A
|
|
202
|
+
host opts in, by one of those two routes. Adopting an unrelated layout
|
|
203
|
+
automatically would reparent these pages onto blocks that may not match and
|
|
204
|
+
context this package cannot supply — and a Jinja block that no ancestor renders
|
|
205
|
+
is dropped in silence, so the failure would have no error message.
|
|
206
|
+
|
|
207
|
+
For the same reason this package ships no template called `base.html`: Flask
|
|
208
|
+
searches *every* blueprint's templates for a name the application does not
|
|
209
|
+
supply, so one here would become the site-wide base for a podpack site and
|
|
210
|
+
reparent every other installed app. A test pins that.
|
|
211
|
+
|
|
212
|
+
**Which blocks a child may fill:** `content` and `title`. Both known layouts
|
|
213
|
+
define them. `scripts` exists only in `standalone.html` and vanishes without
|
|
214
|
+
warning under podpack.
|
|
215
|
+
|
|
216
|
+
### A note on upload size
|
|
217
|
+
|
|
218
|
+
A podpack site's `[limits] max_upload_bytes` becomes Flask's
|
|
219
|
+
`MAX_CONTENT_LENGTH`. podpack's lab config sets it to 1 MiB, which will reject
|
|
220
|
+
most real PDFs with a 413 before this app ever sees them. Raise it there.
|
|
221
|
+
|
|
222
|
+
Separately, `max_pages` bounds the splitter: it builds its zip entirely in
|
|
223
|
+
memory, one member per page, so an unbounded document is a way to exhaust the
|
|
224
|
+
process rather than a document that cannot be read. Over the limit it declines
|
|
225
|
+
with a message instead of trying.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Development
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
uv sync
|
|
233
|
+
uv run pytest
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
That runs the standalone suite: it registers the blueprint on a **bare** `Flask`
|
|
237
|
+
app with no framework of any kind, pushes real reportlab-generated PDFs through
|
|
238
|
+
both tools, and asserts that mounting at a non-default prefix, overriding the
|
|
239
|
+
layout, and refusing an oversized document all work. If it passes, the package
|
|
240
|
+
is genuinely self-contained.
|
|
241
|
+
|
|
242
|
+
The podpack conformance suite installs the app into a real podpack site and
|
|
243
|
+
checks the other contract. It needs no setting up:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
uv sync
|
|
247
|
+
uv run pytest
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
podpack is a **dev** dependency, sourced from its repository — never a real
|
|
251
|
+
one, because an app must not pin the framework version of the site installing
|
|
252
|
+
it. It used to be absent from the lock altogether, opted into by hand with
|
|
253
|
+
`uv pip install -e ../podpack`, because podpack was a sibling working tree and
|
|
254
|
+
any declaration would have made `uv lock` fail on a machine that had not
|
|
255
|
+
checked it out. A git source needs a network rather than a neighbour, so that
|
|
256
|
+
restriction is gone.
|
|
257
|
+
|
|
258
|
+
To look at the pages without a host site of any kind:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
uv run python devserver.py # http://127.0.0.1:8459/pdf/
|
|
262
|
+
```
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# podpack-pdf
|
|
2
|
+
|
|
3
|
+
Two PDF utilities packaged as an installable Flask app:
|
|
4
|
+
|
|
5
|
+
- **PDF Booklet Maker** — imposes an A4 document four-up onto A4 sheets so that
|
|
6
|
+
each group of eight pages forms a signature, returned as a zip of the odd and
|
|
7
|
+
even sides for duplex printing.
|
|
8
|
+
- **PDF Page Splitter** — explodes a PDF into one file per page, returned as a
|
|
9
|
+
zip.
|
|
10
|
+
|
|
11
|
+
Extracted from [holdenweb.com](https://holdenweb.com) with its history intact. It
|
|
12
|
+
answers to two contracts and requires neither: a plain Flask blueprint, and a
|
|
13
|
+
[podpack](https://github.com/holdenweb/podpack) app.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv add podpack-pdf
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The distribution is `podpack-pdf`; the module, and the app's name everywhere podpack
|
|
22
|
+
needs one, is `podpack_pdf`.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## As a plain Flask blueprint
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from flask import Flask
|
|
30
|
+
from podpack_pdf import pdf_blueprint
|
|
31
|
+
|
|
32
|
+
app = Flask(__name__)
|
|
33
|
+
app.config["SECRET_KEY"] = "..." # required: the forms are CSRF-protected
|
|
34
|
+
app.register_blueprint(pdf_blueprint, url_prefix="/pdf/")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That yields three endpoints under the prefix you chose:
|
|
38
|
+
|
|
39
|
+
| Route | Purpose |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `/` | Index listing both tools |
|
|
42
|
+
| `/booklet` | Upload a PDF, receive a zip of imposed odd/even sides |
|
|
43
|
+
| `/pagezip` | Upload a PDF, receive a zip of one file per page |
|
|
44
|
+
|
|
45
|
+
Here the mount point is yours: the blueprint is relocatable, so mount it at
|
|
46
|
+
`/tools/pdf` or anywhere else and its internal links follow, because they are
|
|
47
|
+
generated with `url_for`.
|
|
48
|
+
|
|
49
|
+
### Discovery
|
|
50
|
+
|
|
51
|
+
```toml
|
|
52
|
+
[project.entry-points."holdenweb.apps"]
|
|
53
|
+
pdf = "podpack_pdf:pdf_blueprint"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A host site can enumerate its installed apps rather than hard-coding imports:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from importlib.metadata import entry_points
|
|
60
|
+
|
|
61
|
+
for entry_point in entry_points(group="holdenweb.apps"):
|
|
62
|
+
app.register_blueprint(entry_point.load(), url_prefix=f"/{entry_point.name}/")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The entry point resolves to the **blueprint itself**, not to a bespoke
|
|
66
|
+
`register()` callable, so the contract is expressed in Flask's own vocabulary.
|
|
67
|
+
The entry-point *name* is only a mount hint for that loop; it is not the app's
|
|
68
|
+
name.
|
|
69
|
+
|
|
70
|
+
### Setup hooks
|
|
71
|
+
|
|
72
|
+
Anything needed at registration time goes through Flask's own deferred-
|
|
73
|
+
registration hook, which is the equivalent of Django's `AppConfig.ready`. This
|
|
74
|
+
package uses it on itself, to settle its two config keys:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
@pdf_blueprint.record_once
|
|
78
|
+
def _defaults(state):
|
|
79
|
+
state.app.config.setdefault("PODPACK_PDF_BASE_TEMPLATE", STANDALONE_LAYOUT)
|
|
80
|
+
state.app.config.setdefault("PODPACK_PDF_MAX_PAGES", DEFAULT_MAX_PAGES)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## As a podpack app
|
|
86
|
+
|
|
87
|
+
Add the package's **import name** to the site's config file and restart:
|
|
88
|
+
|
|
89
|
+
```toml
|
|
90
|
+
[site]
|
|
91
|
+
apps = ["podpack_pdf"]
|
|
92
|
+
|
|
93
|
+
[apps.pdf]
|
|
94
|
+
max_pages = 200
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
There is no second step. podpack imports the package, reads its module-level
|
|
98
|
+
`site_app`, and takes the mount point, the nav entry and the config namespace
|
|
99
|
+
from it:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
site_app = SiteApp(
|
|
103
|
+
blueprint=pdf_blueprint,
|
|
104
|
+
url_prefix="/pdf",
|
|
105
|
+
nav=(Section("PDF tools", "pdf.root_page"),),
|
|
106
|
+
init=_init,
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The app's name is not declared: podpack derives it from the blueprint's own
|
|
111
|
+
name, so `pdf` — the template namespace, the config section, the data
|
|
112
|
+
directory — follows from `Blueprint("pdf", ...)` in views.py.
|
|
113
|
+
|
|
114
|
+
`url_prefix` is what this app asks for, not what it is entitled to. A site that
|
|
115
|
+
wants these pages somewhere else in its address space says so, and the nav entry
|
|
116
|
+
follows without either side restating it — a `Section` names an endpoint, so
|
|
117
|
+
podpack resolves it with `url_for` as the chrome renders:
|
|
118
|
+
|
|
119
|
+
```toml
|
|
120
|
+
[site.mounts]
|
|
121
|
+
pdf = "/tools/pdf"
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
That lives under `[site]`, not in `[apps.pdf]`, because it is the site's
|
|
125
|
+
policy rather than this package's configuration — `app_config()` returns only
|
|
126
|
+
what this app is meant to read, and where it was mounted is not among it.
|
|
127
|
+
|
|
128
|
+
So the mount point is the host's under either contract; only the way of saying
|
|
129
|
+
so differs — an argument to `register_blueprint` there, a line of config here.
|
|
130
|
+
|
|
131
|
+
This app's **name is its blueprint's name**: podpack derives it, so
|
|
132
|
+
`Blueprint("pdf", …)` in `views.py` is what decides the template namespace,
|
|
133
|
+
the data directory and the `[apps.pdf]` config section. There is nothing to
|
|
134
|
+
keep in step by hand.
|
|
135
|
+
|
|
136
|
+
podpack is an **optional** import here — it is on no package index, and this
|
|
137
|
+
package's first contract is to need no framework at all. Where it is absent,
|
|
138
|
+
`podpack_pdf.site_app` is `None` and everything else works unchanged.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## How one package serves both
|
|
143
|
+
|
|
144
|
+
| | Plain Flask | podpack |
|
|
145
|
+
| --- | --- | --- |
|
|
146
|
+
| Discovery | `holdenweb.apps` entry point, or a direct import | import name in the site's `apps` list |
|
|
147
|
+
| What is discovered | the `Blueprint` | `site_app: SiteApp` |
|
|
148
|
+
| Mount point | the host's argument to `register_blueprint` | `[site.mounts] pdf`, defaulting to the app's own |
|
|
149
|
+
| Setup hook | `pdf_blueprint.record_once` | `SiteApp.init` |
|
|
150
|
+
| Page layout | `pdf/standalone.html`, shipped here | the site's `base.html` |
|
|
151
|
+
| Configuration | `app.config["PODPACK_PDF_…"]` | `[apps.pdf]` in the site's TOML |
|
|
152
|
+
| Navigation | the host's business | `nav=(Section(…),)` |
|
|
153
|
+
|
|
154
|
+
The two coexist because the podpack half is a **config translator, not a second
|
|
155
|
+
code path**. Both hosts settle the same two keys before the first request, and
|
|
156
|
+
the views, forms and templates read only those.
|
|
157
|
+
|
|
158
|
+
### Templates, and the layout ladder
|
|
159
|
+
|
|
160
|
+
All templates ship namespaced under `templates/pdf/`, so nothing can collide
|
|
161
|
+
with a host site's own template names. Every page extends `pdf/base.html`,
|
|
162
|
+
which is one line: it extends whatever the host has said should wrap it.
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
a site's own templates/pdf/base.html shadows this package's entirely
|
|
166
|
+
podpack "base.html" -- the site's chrome
|
|
167
|
+
plain Flask pdf/standalone.html, shipped here
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
A host of either kind overrides any template here — including the whole page
|
|
171
|
+
layout — by placing a file at the same path in its own template folder. Flask
|
|
172
|
+
searches the application's templates before any blueprint's, so no configuration
|
|
173
|
+
is involved. To wrap these pages in a plain-Flask site's furniture, that is a
|
|
174
|
+
one-line file:
|
|
175
|
+
|
|
176
|
+
```jinja
|
|
177
|
+
{# templates/pdf/base.html in the host site #}
|
|
178
|
+
{% extends "site-base.html" %}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
or a single config key, set before the blueprint is registered:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
app.config["PODPACK_PDF_BASE_TEMPLATE"] = "site-base.html"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Standalone mode does **not** go looking for a `base.html` of its own accord. A
|
|
188
|
+
host opts in, by one of those two routes. Adopting an unrelated layout
|
|
189
|
+
automatically would reparent these pages onto blocks that may not match and
|
|
190
|
+
context this package cannot supply — and a Jinja block that no ancestor renders
|
|
191
|
+
is dropped in silence, so the failure would have no error message.
|
|
192
|
+
|
|
193
|
+
For the same reason this package ships no template called `base.html`: Flask
|
|
194
|
+
searches *every* blueprint's templates for a name the application does not
|
|
195
|
+
supply, so one here would become the site-wide base for a podpack site and
|
|
196
|
+
reparent every other installed app. A test pins that.
|
|
197
|
+
|
|
198
|
+
**Which blocks a child may fill:** `content` and `title`. Both known layouts
|
|
199
|
+
define them. `scripts` exists only in `standalone.html` and vanishes without
|
|
200
|
+
warning under podpack.
|
|
201
|
+
|
|
202
|
+
### A note on upload size
|
|
203
|
+
|
|
204
|
+
A podpack site's `[limits] max_upload_bytes` becomes Flask's
|
|
205
|
+
`MAX_CONTENT_LENGTH`. podpack's lab config sets it to 1 MiB, which will reject
|
|
206
|
+
most real PDFs with a 413 before this app ever sees them. Raise it there.
|
|
207
|
+
|
|
208
|
+
Separately, `max_pages` bounds the splitter: it builds its zip entirely in
|
|
209
|
+
memory, one member per page, so an unbounded document is a way to exhaust the
|
|
210
|
+
process rather than a document that cannot be read. Over the limit it declines
|
|
211
|
+
with a message instead of trying.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Development
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
uv sync
|
|
219
|
+
uv run pytest
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
That runs the standalone suite: it registers the blueprint on a **bare** `Flask`
|
|
223
|
+
app with no framework of any kind, pushes real reportlab-generated PDFs through
|
|
224
|
+
both tools, and asserts that mounting at a non-default prefix, overriding the
|
|
225
|
+
layout, and refusing an oversized document all work. If it passes, the package
|
|
226
|
+
is genuinely self-contained.
|
|
227
|
+
|
|
228
|
+
The podpack conformance suite installs the app into a real podpack site and
|
|
229
|
+
checks the other contract. It needs no setting up:
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
uv sync
|
|
233
|
+
uv run pytest
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
podpack is a **dev** dependency, sourced from its repository — never a real
|
|
237
|
+
one, because an app must not pin the framework version of the site installing
|
|
238
|
+
it. It used to be absent from the lock altogether, opted into by hand with
|
|
239
|
+
`uv pip install -e ../podpack`, because podpack was a sibling working tree and
|
|
240
|
+
any declaration would have made `uv lock` fail on a machine that had not
|
|
241
|
+
checked it out. A git source needs a network rather than a neighbour, so that
|
|
242
|
+
restriction is gone.
|
|
243
|
+
|
|
244
|
+
To look at the pages without a host site of any kind:
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
uv run python devserver.py # http://127.0.0.1:8459/pdf/
|
|
248
|
+
```
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "podpack-pdf"
|
|
3
|
+
version = "0.2.2"
|
|
4
|
+
description = "PDF booklet-imposition and page-splitting tools as an installable Flask app"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12, <4.0"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"flask (>=2.3.3)",
|
|
9
|
+
"flask-wtf (>=1.2.1)",
|
|
10
|
+
"wtforms (>=3.0)",
|
|
11
|
+
"pdfrw (>=0.4)",
|
|
12
|
+
"reportlab (>=3.6.12)",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[[project.authors]]
|
|
16
|
+
name = "Steve Holden"
|
|
17
|
+
email = "steve@holdenweb.com"
|
|
18
|
+
|
|
19
|
+
# Discovery hook for a site with no framework: it enumerates its installed apps
|
|
20
|
+
# by iterating this group instead of hard-coding imports, and the value resolves
|
|
21
|
+
# to the blueprint itself. The entry-point *name* is a mount hint -- the
|
|
22
|
+
# documented consumer does url_prefix=f"/{entry_point.name}/" -- and is
|
|
23
|
+
# deliberately not the app's name. podpack does not use entry points at all; it
|
|
24
|
+
# imports `podpack_pdf` by name from the site's app list and reads `site_app`.
|
|
25
|
+
[project.entry-points."holdenweb.apps"]
|
|
26
|
+
pdf = "podpack_pdf:pdf_blueprint"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = [ "uv_build>=0.8.4,<0.9.0",]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
# podpack is here rather than in `dependencies`, which is the important half:
|
|
34
|
+
# an app must not pin the framework version of the site installing it. A
|
|
35
|
+
# dependency group is dev-only metadata and constrains nobody downstream.
|
|
36
|
+
#
|
|
37
|
+
# It used to be absent from this file altogether, opted into by hand with
|
|
38
|
+
# `uv pip install -e ../podpack`, because podpack was a sibling working tree
|
|
39
|
+
# rather than a published package -- so any declaration would have bound at
|
|
40
|
+
# *lock* time and made `uv lock` fail outright on a machine without that
|
|
41
|
+
# checkout. A git source has no such requirement: it needs a network, not a
|
|
42
|
+
# neighbour. `uv sync && uv run pytest` now works anywhere.
|
|
43
|
+
dev = [ "pytest>=9.1.1", "mypy>=1.11", "types-WTForms", "types-reportlab", "podpack",]
|
|
44
|
+
|
|
45
|
+
[tool.uv.sources]
|
|
46
|
+
podpack = { git = "https://github.com/holdenweb/podpack.git" }
|
|
47
|
+
|
|
48
|
+
# Type-checked like its sibling apps, and gated by ci.yml rather than left to
|
|
49
|
+
# be run by hand -- a check nothing runs is a check that is already stale.
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
files = ["src", "tests"]
|
|
52
|
+
|
|
53
|
+
# Stubs exist for WTForms and reportlab and are installed above, so those are
|
|
54
|
+
# checked for real. These two ship neither stubs nor a py.typed marker and have
|
|
55
|
+
# no types-* package, so they are suppressed per module rather than globally --
|
|
56
|
+
# a blanket ignore_missing_imports would also hide a genuinely missing import.
|
|
57
|
+
[[tool.mypy.overrides]]
|
|
58
|
+
module = ["flask_wtf.*", "pdfrw.*"]
|
|
59
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""PDF booklet-imposition and page-splitting tools as an installable Flask app.
|
|
2
|
+
|
|
3
|
+
The package answers to two contracts and requires neither.
|
|
4
|
+
|
|
5
|
+
**As a plain Flask blueprint**, for a site with no framework at all::
|
|
6
|
+
|
|
7
|
+
from podpack_pdf import pdf_blueprint
|
|
8
|
+
|
|
9
|
+
app.register_blueprint(pdf_blueprint, url_prefix="/pdf/")
|
|
10
|
+
|
|
11
|
+
or let the site discover it through the ``holdenweb.apps`` entry-point group,
|
|
12
|
+
whose value resolves to the blueprint itself. Where it is mounted is the site's
|
|
13
|
+
decision, as it is in Django. Anything needed at registration time goes through
|
|
14
|
+
``pdf_blueprint.record_once``, Flask's equivalent of Django's
|
|
15
|
+
``AppConfig.ready`` -- no bespoke install hook is required.
|
|
16
|
+
|
|
17
|
+
**As a podpack app**, by adding this package's import name to ``apps`` in the
|
|
18
|
+
site's config file::
|
|
19
|
+
|
|
20
|
+
[site]
|
|
21
|
+
apps = ["podpack_pdf"]
|
|
22
|
+
|
|
23
|
+
[apps.pdf]
|
|
24
|
+
max_pages = 200
|
|
25
|
+
|
|
26
|
+
podpack reads ``site_app`` below and takes the mount point, the nav entry and the
|
|
27
|
+
config namespace from it.
|
|
28
|
+
|
|
29
|
+
The two coexist because the podpack half is a config translator, not a second
|
|
30
|
+
code path: both hosts settle ``PODPACK_PDF_BASE_TEMPLATE`` and ``PODPACK_PDF_MAX_PAGES``
|
|
31
|
+
before the first request, and the views read only those. So the pages wear the
|
|
32
|
+
site's own chrome under podpack, and a complete layout of their own without it.
|
|
33
|
+
A host of either kind can also override any template in this package by shipping
|
|
34
|
+
a file at the same path -- Flask searches the application's template folder
|
|
35
|
+
before any blueprint's, so ``templates/pdf/base.html`` simply wins.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from importlib.util import find_spec
|
|
39
|
+
|
|
40
|
+
from .views import pdf_blueprint
|
|
41
|
+
|
|
42
|
+
__all__ = ["pdf_blueprint", "site_app"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _init(app):
|
|
46
|
+
"""podpack's registration hook: adapt this app to the site installing it.
|
|
47
|
+
|
|
48
|
+
Runs before the blueprint is registered, so these defaults land ahead of the
|
|
49
|
+
blueprint's own and win. Under podpack ``base.html`` always resolves -- to
|
|
50
|
+
the site's chrome if it ships any, and to podpack's default if it does not.
|
|
51
|
+
"""
|
|
52
|
+
from podpack import app_config
|
|
53
|
+
|
|
54
|
+
# `app_config` resolves the app from `request.blueprint` when called without
|
|
55
|
+
# a name; there is no request here, so name it. It needs an app context
|
|
56
|
+
# either way, and the registry pushes none.
|
|
57
|
+
with app.app_context():
|
|
58
|
+
settings = app_config("pdf")
|
|
59
|
+
|
|
60
|
+
app.config.setdefault("PODPACK_PDF_BASE_TEMPLATE", "base.html")
|
|
61
|
+
if "max_pages" in settings:
|
|
62
|
+
app.config["PODPACK_PDF_MAX_PAGES"] = settings["max_pages"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# podpack is optional and deliberately so: it is on no index, and this package's
|
|
66
|
+
# first contract is to need no framework at all. `find_spec` rather than
|
|
67
|
+
# `except ImportError` for the reason podpack's own registry gives for the same
|
|
68
|
+
# choice -- a genuine failure *inside* podpack, a typo or a missing dependency,
|
|
69
|
+
# must not be mistaken for its absence. Swallowing one would leave `site_app` as
|
|
70
|
+
# None and produce a baffling "exposes no module-level site_app" from the
|
|
71
|
+
# registry in place of the real traceback.
|
|
72
|
+
if find_spec("podpack") is None:
|
|
73
|
+
site_app = None
|
|
74
|
+
else:
|
|
75
|
+
from podpack import Section, SiteApp
|
|
76
|
+
|
|
77
|
+
site_app = SiteApp(
|
|
78
|
+
# This app's name is the blueprint's own -- podpack derives it, so the
|
|
79
|
+
# template namespace, the data directory and the `[apps.pdf]` config
|
|
80
|
+
# section all follow from `Blueprint("pdf", ...)` in views.py.
|
|
81
|
+
blueprint=pdf_blueprint,
|
|
82
|
+
# Where this app asks to be mounted. A site that wants it elsewhere
|
|
83
|
+
# says so in `[site.mounts]`, where its own policy lives -- this app
|
|
84
|
+
# never learns the answer. The nav entry below follows without either
|
|
85
|
+
# side restating it, because it names an endpoint rather than a path.
|
|
86
|
+
url_prefix="/pdf",
|
|
87
|
+
nav=(Section("PDF tools", "pdf.root_page"),),
|
|
88
|
+
init=_init,
|
|
89
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
booklet.py: Produce an 8-page booklet from a single sheet of
|
|
3
|
+
paper printed duplex.
|
|
4
|
+
"""
|
|
5
|
+
import io
|
|
6
|
+
import sys
|
|
7
|
+
from itertools import cycle
|
|
8
|
+
from reportlab.pdfgen.canvas import Canvas
|
|
9
|
+
from reportlab.lib.pagesizes import A4
|
|
10
|
+
from pdfrw import PdfReader
|
|
11
|
+
from pdfrw.buildxobj import pagexobj
|
|
12
|
+
from pdfrw.toreportlab import makerl
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
width, height = A4
|
|
16
|
+
|
|
17
|
+
transforms = [
|
|
18
|
+
(0, 0.5, 0),
|
|
19
|
+
(0.5, 0.5, 0),
|
|
20
|
+
(1, 0.5, 180),
|
|
21
|
+
(0.5, 0.5, 180)
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
page_layouts = (8, 1, 4, 5), (2, 7, 6, 3)
|
|
25
|
+
|
|
26
|
+
def make_booklet(in_doc, out_docs=None):
|
|
27
|
+
doc_pages = PdfReader(in_doc).pages
|
|
28
|
+
if out_docs is None:
|
|
29
|
+
out_docs = (io.BytesIO(), io.BytesIO())
|
|
30
|
+
elif len(out_docs) != 2:
|
|
31
|
+
raise ValueError("An out_docs argument to make_booklet did not have two elements")
|
|
32
|
+
# Create an odd an and even side imposed page stream, to which PDF
|
|
33
|
+
# content will be written.
|
|
34
|
+
imp_sides = []
|
|
35
|
+
for out_doc in out_docs:
|
|
36
|
+
imp_sides.append(imp_side := Canvas(out_doc))
|
|
37
|
+
imp_side.setPageSize(A4)
|
|
38
|
+
# Iterate over the pages in groups of eight, each group
|
|
39
|
+
# of original pages being the two sides of a signature.
|
|
40
|
+
for i in range(0, len(doc_pages), 8):
|
|
41
|
+
pages = doc_pages[i:i+8]
|
|
42
|
+
# Odd pages get sides 8, 1, 4 and 5
|
|
43
|
+
# Even pages get sides 2, 7, 6 and 3
|
|
44
|
+
for imp_side, page_numbers in zip(imp_sides, page_layouts):
|
|
45
|
+
page_data = [(k, t) for (k, t) in zip(page_numbers, transforms)]
|
|
46
|
+
# Each of the four pages in the imposed layout has its own
|
|
47
|
+
# transform: x-offset, y-offset and rotation angle. Offsets are
|
|
48
|
+
# multiples of the unit width and length, respectively, and the
|
|
49
|
+
# rotation angle is in degrees.
|
|
50
|
+
for page_number, (x, y, angle) in page_data:
|
|
51
|
+
# The final imposed page may not contain an exact multiple of
|
|
52
|
+
# four original pages, so we simply ignore requests for
|
|
53
|
+
# non-existent original pages, leaving them blank.
|
|
54
|
+
if page_number <= len(pages):
|
|
55
|
+
orig_page = pages[page_number-1]
|
|
56
|
+
orig_page = makerl(imp_side, pagexobj(orig_page))
|
|
57
|
+
imp_side.saveState()
|
|
58
|
+
imp_side.translate(x*width, y*height)
|
|
59
|
+
imp_side.rotate(angle)
|
|
60
|
+
imp_side.scale(0.5, 0.5)
|
|
61
|
+
imp_side.doForm(orig_page)
|
|
62
|
+
imp_side.restoreState()
|
|
63
|
+
imp_side.showPage()
|
|
64
|
+
# Save the generated PDFs
|
|
65
|
+
for imp_side in imp_sides:
|
|
66
|
+
imp_side.save()
|
|
67
|
+
# And return them to the caller
|
|
68
|
+
return out_docs
|
|
69
|
+
|
|
70
|
+
if __name__ == '__main__':
|
|
71
|
+
with open("out_odd.pdf", "wb") as out_f1, open("out_even.pdf", "wb") as out_f2:
|
|
72
|
+
make_booklet(sys.argv[1], (out_f1, out_f2))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from flask_wtf import FlaskForm
|
|
2
|
+
from wtforms import FileField, SubmitField, StringField
|
|
3
|
+
from wtforms.validators import DataRequired
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PDFBookletForm(FlaskForm):
|
|
7
|
+
file_details = FileField('file_details', validators=[DataRequired()])
|
|
8
|
+
submit = SubmitField("Generate Booklet")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PDFSplitterForm(FlaskForm):
|
|
12
|
+
file_details = FileField('file_details', validators=[DataRequired()])
|
|
13
|
+
file_prefix = StringField('file_prefix')
|
|
14
|
+
submit = SubmitField('Get Pages')
|
|
15
|
+
|
|
16
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{# Every page in this app extends this file, and this file extends whatever the
|
|
2
|
+
host has said should wrap it:
|
|
3
|
+
|
|
4
|
+
a site's own templates/pdf/base.html shadows this file entirely
|
|
5
|
+
podpack "base.html" -- the site's chrome
|
|
6
|
+
plain Flask pdf/standalone.html, shipped here
|
|
7
|
+
|
|
8
|
+
The name arrives as `pdf_layout` from a context processor on the blueprint.
|
|
9
|
+
Jinja resolves `{% extends %}` against a variable perfectly well, but cannot
|
|
10
|
+
be handed a list of candidates to try -- `extends` compiles to
|
|
11
|
+
`environment.get_template`, not `get_or_select_template` -- so the choice is
|
|
12
|
+
made in Python, where it is also inspectable as the config key
|
|
13
|
+
PODPACK_PDF_BASE_TEMPLATE rather than being buried in a template.
|
|
14
|
+
|
|
15
|
+
Child templates may fill `content` and `title`, which both known layouts
|
|
16
|
+
define. A block no ancestor renders is dropped silently, so anything else is
|
|
17
|
+
layout-specific and will vanish without warning on the other one. #}
|
|
18
|
+
{% extends pdf_layout %}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{% extends "pdf/base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<form method="post" enctype="multipart/form-data">
|
|
5
|
+
<fieldset>
|
|
6
|
+
{{ form.hidden_tag() }}
|
|
7
|
+
<legend>Make your own tiny booklets</legend>
|
|
8
|
+
|
|
9
|
+
<p>Use the Choose button to select a PDF file. Hit Submit and you will
|
|
10
|
+
receive a zip file containing two PDF files, one of odd sides and one of
|
|
11
|
+
even sides. Print the even sides, then run the same paper through to print
|
|
12
|
+
the odd sides.</p>
|
|
13
|
+
<p>The returned file is called "pages.zip", and holds "odd.pdf" and
|
|
14
|
+
"even.pdf" in a directory named "pdf".</p>
|
|
15
|
+
|
|
16
|
+
<div class="field">
|
|
17
|
+
{{ form.file_details(size=32) }}
|
|
18
|
+
</div>
|
|
19
|
+
<hr>
|
|
20
|
+
<div class="field">
|
|
21
|
+
{{ form.submit() }}
|
|
22
|
+
</div>
|
|
23
|
+
</fieldset>
|
|
24
|
+
</form>
|
|
25
|
+
{% endblock %}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{% extends "pdf/base.html" %}
|
|
2
|
+
{% block content %}
|
|
3
|
+
<h2>PDF Helper Utilities</h2>
|
|
4
|
+
<ul>
|
|
5
|
+
{# Relative endpoints, so these links survive the blueprint being renamed as
|
|
6
|
+
well as being mounted somewhere other than /pdf/. #}
|
|
7
|
+
<li><a href="{{ url_for('.get_or_post_pagezip') }}"><strong>PDF Splitter</strong></a>
|
|
8
|
+
— split a PDF file into its individual pages</li>
|
|
9
|
+
<li><a href="{{ url_for('.get_or_post_booklet') }}"><strong>PDF Booklet Maker</strong></a>
|
|
10
|
+
— turn any PDF document into an A6 booklet</li>
|
|
11
|
+
</ul>
|
|
12
|
+
{% endblock %}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{% extends "pdf/base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<form method="post" enctype="multipart/form-data">
|
|
5
|
+
<fieldset>
|
|
6
|
+
{{ form.hidden_tag() }}
|
|
7
|
+
<legend>Split PDF documents into individual pages</legend>
|
|
8
|
+
|
|
9
|
+
<p>Use the Choose button to select the PDF file you want to split. Hit
|
|
10
|
+
Submit and you will receive a zip file containing a directory named after
|
|
11
|
+
your document that holds the individual pages, one per file.</p>
|
|
12
|
+
<p>By default the files will be called "page_001.pdf", "page_002.pdf" and so
|
|
13
|
+
on, but you can enter a prefix below to be used instead of "page".</p>
|
|
14
|
+
|
|
15
|
+
<div class="field">
|
|
16
|
+
{{ form.file_details(size=32) }}
|
|
17
|
+
</div>
|
|
18
|
+
<hr>
|
|
19
|
+
<div class="field">
|
|
20
|
+
<label for="file_prefix">Alternate prefix ...</label>
|
|
21
|
+
{{ form.file_prefix(size=32) }}
|
|
22
|
+
</div>
|
|
23
|
+
<div class="field">
|
|
24
|
+
{{ form.submit() }}
|
|
25
|
+
</div>
|
|
26
|
+
</fieldset>
|
|
27
|
+
</form>
|
|
28
|
+
{% endblock %}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{# The layout for a host that supplies none: a complete document, because with
|
|
2
|
+
no site to inherit from there is nothing else to be.
|
|
3
|
+
|
|
4
|
+
Under podpack this file is never reached -- the site's own base.html is. It
|
|
5
|
+
carries its own small stylesheet rather than loading a CSS framework, so that
|
|
6
|
+
the pages have no opinion to impose when they are rendered inside someone
|
|
7
|
+
else's chrome. The marker in the comment below is how the tests tell which of
|
|
8
|
+
the two layouts actually rendered. #}
|
|
9
|
+
<!DOCTYPE html>
|
|
10
|
+
<!-- podpack-pdf standalone layout -->
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<head>
|
|
13
|
+
<meta charset="utf-8">
|
|
14
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
15
|
+
<title>{% block title %}{{ title or "PDF tools" }}{% endblock %}</title>
|
|
16
|
+
{% block styles %}
|
|
17
|
+
<style>
|
|
18
|
+
body { max-width: 46rem; margin: 2rem auto; padding: 0 1rem;
|
|
19
|
+
font-family: system-ui, sans-serif; line-height: 1.5; }
|
|
20
|
+
fieldset { border: 1px solid #ccc; padding: 1rem 1.25rem; }
|
|
21
|
+
legend { font-weight: 600; padding: 0 .5rem; }
|
|
22
|
+
label { display: block; margin-bottom: .25rem; }
|
|
23
|
+
.field { margin: 1rem 0; }
|
|
24
|
+
input[type=submit] { padding: .4rem 1rem; }
|
|
25
|
+
.flashes { color: darkred; }
|
|
26
|
+
</style>
|
|
27
|
+
{% endblock %}
|
|
28
|
+
</head>
|
|
29
|
+
<body>
|
|
30
|
+
<h1>{{ title }}</h1>
|
|
31
|
+
{% with messages = get_flashed_messages() %}
|
|
32
|
+
{% if messages %}
|
|
33
|
+
<ul class="flashes">
|
|
34
|
+
{% for message in messages %}<li>{{ message }}</li>{% endfor %}
|
|
35
|
+
</ul>
|
|
36
|
+
{% endif %}
|
|
37
|
+
{% endwith %}
|
|
38
|
+
{% block content %}{% endblock %}
|
|
39
|
+
{% block scripts %}{% endblock %}
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Views for the PDF helper utilities: booklet imposition and page splitting.
|
|
2
|
+
|
|
3
|
+
Nothing here knows which contract installed it. Both hosts settle the same two
|
|
4
|
+
config keys before the first request -- podpack through `SiteApp.init`, a plain
|
|
5
|
+
Flask app through `record_once` below -- so the view code is written once.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
from io import BytesIO
|
|
9
|
+
from logging import getLogger
|
|
10
|
+
from zipfile import ZipFile
|
|
11
|
+
|
|
12
|
+
from flask import (Blueprint, current_app, flash, render_template, request,
|
|
13
|
+
send_file)
|
|
14
|
+
import pdfrw
|
|
15
|
+
from werkzeug.utils import secure_filename
|
|
16
|
+
|
|
17
|
+
from .booklet import make_booklet
|
|
18
|
+
from .forms import PDFBookletForm, PDFSplitterForm
|
|
19
|
+
|
|
20
|
+
logger = getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# The blueprint's name is also the app's name under podpack, which resolves an
|
|
23
|
+
# app's data directory and config namespace from `request.blueprint`. Keep the
|
|
24
|
+
# two in step; see the SiteApp in __init__.py.
|
|
25
|
+
pdf_blueprint = Blueprint("pdf", __name__, template_folder="templates")
|
|
26
|
+
|
|
27
|
+
# What these pages extend when no host has said otherwise. Namespaced, like every
|
|
28
|
+
# other template here: Flask searches *every* blueprint's templates for a name
|
|
29
|
+
# the application itself does not supply, so a `base.html` in this package would
|
|
30
|
+
# silently become the site-wide base for a podpack site and reparent every other
|
|
31
|
+
# installed app.
|
|
32
|
+
STANDALONE_LAYOUT = "pdf/standalone.html"
|
|
33
|
+
|
|
34
|
+
# The splitter builds its zip entirely in memory, one member per page, so an
|
|
35
|
+
# unbounded document is a way to exhaust the process rather than a document we
|
|
36
|
+
# cannot read. A site raises or lowers this with `[apps.pdf] max_pages`.
|
|
37
|
+
DEFAULT_MAX_PAGES = 200
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pdf_blueprint.record_once
|
|
41
|
+
def _defaults(state):
|
|
42
|
+
"""Settle this app's configuration at registration time.
|
|
43
|
+
|
|
44
|
+
Flask's own deferred-registration hook, which is this package's equivalent of
|
|
45
|
+
Django's ``AppConfig.ready``. ``setdefault`` rather than assignment because a
|
|
46
|
+
host may have decided already: podpack's ``SiteApp.init`` runs before this
|
|
47
|
+
one and points the layout at the site's own chrome.
|
|
48
|
+
"""
|
|
49
|
+
state.app.config.setdefault("PODPACK_PDF_BASE_TEMPLATE", STANDALONE_LAYOUT)
|
|
50
|
+
state.app.config.setdefault("PODPACK_PDF_MAX_PAGES", DEFAULT_MAX_PAGES)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pdf_blueprint.context_processor
|
|
54
|
+
def _layout():
|
|
55
|
+
"""Tell ``pdf/base.html`` what to extend.
|
|
56
|
+
|
|
57
|
+
Registered on the blueprint, so the name is in scope for this app's templates
|
|
58
|
+
and for nothing else in the application.
|
|
59
|
+
"""
|
|
60
|
+
return {"pdf_layout": current_app.config["PODPACK_PDF_BASE_TEMPLATE"]}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pdf_blueprint.route("/", methods=['GET'])
|
|
64
|
+
def root_page():
|
|
65
|
+
return render_template("pdf/index.html", title="PDF Helpers")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pdf_blueprint.route("/booklet", methods=['GET', 'POST'])
|
|
69
|
+
def get_or_post_booklet():
|
|
70
|
+
"""
|
|
71
|
+
Post-design imposition of A6 booklets from A4 paper.
|
|
72
|
+
|
|
73
|
+
This page requests a file from the user, and returns a zipfile containing
|
|
74
|
+
its pages, each shrunk to 50% and imposed four-up on A4. This allows
|
|
75
|
+
creation of a signature from each eight original pages, four-up on an
|
|
76
|
+
even and an odd side.
|
|
77
|
+
|
|
78
|
+
The even and odd sides are then written into separate PDF files and
|
|
79
|
+
packed in a zipfile, which is delivered to the user as a downloaded.
|
|
80
|
+
"""
|
|
81
|
+
form = PDFBookletForm()
|
|
82
|
+
logger.info("Booklet requested")
|
|
83
|
+
if form.validate_on_submit():
|
|
84
|
+
my_file = request.files['file_details']
|
|
85
|
+
try:
|
|
86
|
+
output_pdfs = make_booklet(my_file.stream)
|
|
87
|
+
outzip = BytesIO()
|
|
88
|
+
container = ZipFile(outzip, 'w')
|
|
89
|
+
for p_typ, pdf in zip(('odd', 'even'), output_pdfs):
|
|
90
|
+
container.writestr(f"pdf/{p_typ}.pdf",
|
|
91
|
+
pdf.getvalue())
|
|
92
|
+
container.close()
|
|
93
|
+
outzip.seek(0)
|
|
94
|
+
return send_file(outzip,
|
|
95
|
+
mimetype="application/zip",
|
|
96
|
+
as_attachment=True,
|
|
97
|
+
download_name="pages.zip")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
# Plain text, no markup: a host's layout may escape flashed messages
|
|
100
|
+
# -- podpack's does -- and the message interpolates an exception that
|
|
101
|
+
# can contain the client's own filename.
|
|
102
|
+
logger.exception("booklet imposition failed")
|
|
103
|
+
flash("I'm sorry, it seems I couldn't do that. Please report the "
|
|
104
|
+
f"following message if it makes no sense to you: {e}")
|
|
105
|
+
return render_template('pdf/booklet_form.html', form=form, title="PDF Booklet Maker")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pdf_blueprint.route("/pagezip", methods=['GET', 'POST'])
|
|
109
|
+
def get_or_post_pagezip():
|
|
110
|
+
form = PDFSplitterForm()
|
|
111
|
+
if form.validate_on_submit():
|
|
112
|
+
in_storage = request.files['file_details']
|
|
113
|
+
# The uploaded name becomes a directory inside the zip and the name of
|
|
114
|
+
# the download, so it is the client's string until sanitised.
|
|
115
|
+
infile_name = os.path.splitext(
|
|
116
|
+
secure_filename(in_storage.filename))[0] or "document"
|
|
117
|
+
file_prefix = request.form['file_prefix'] or 'page'
|
|
118
|
+
try:
|
|
119
|
+
inputpdf = pdfrw.PdfReader(fname=in_storage.stream)
|
|
120
|
+
max_pages = current_app.config["PODPACK_PDF_MAX_PAGES"]
|
|
121
|
+
if len(inputpdf.pages) > max_pages:
|
|
122
|
+
# A refusal rather than an error, and outside the loop, so the
|
|
123
|
+
# bare except below cannot turn it into "not a PDF".
|
|
124
|
+
flash(f"That document has {len(inputpdf.pages)} pages and this "
|
|
125
|
+
f"tool splits at most {max_pages}.")
|
|
126
|
+
return render_template('pdf/pagesplit_form.html', form=form,
|
|
127
|
+
title="PDF Page Splitter")
|
|
128
|
+
outzip = BytesIO()
|
|
129
|
+
container = ZipFile(outzip, 'w')
|
|
130
|
+
for i, page in enumerate(inputpdf.pages):
|
|
131
|
+
output = pdfrw.PdfWriter()
|
|
132
|
+
file_name = f"{file_prefix}_{i+1:03}.pdf"
|
|
133
|
+
output.addpage(page)
|
|
134
|
+
outfile = BytesIO()
|
|
135
|
+
output.write(outfile)
|
|
136
|
+
container.writestr(f"{infile_name}/{file_name}",
|
|
137
|
+
outfile.getvalue())
|
|
138
|
+
container.close()
|
|
139
|
+
outzip.seek(0)
|
|
140
|
+
return send_file(outzip,
|
|
141
|
+
mimetype="application/octet-stream",
|
|
142
|
+
as_attachment=True,
|
|
143
|
+
download_name=f"{infile_name}.pages.zip")
|
|
144
|
+
except Exception:
|
|
145
|
+
logger.exception("could not read the upload as a PDF")
|
|
146
|
+
flash("Could not open file as a PDF - please try again")
|
|
147
|
+
return render_template('pdf/pagesplit_form.html', form=form, title="PDF Page Splitter")
|