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.
@@ -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
+ &mdash; 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
+ &mdash; 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")