PDF Assembly Pipeline
Dozens of PDFs from many people become one ordered, bookmarked document.
Problem
Many workflows need one PDF built from many source files, in a set order, with a bookmark on each section. The files come from many people with inconsistent names. The storage system could merge files, but not in a template's order.
Approach
Node's PDF libraries either merge or bookmark but none do both, so I built a Redis + BullMQ queue to a Python worker that handles the document assembly. Files are assigned to sections by a case-insensitive pattern match. Templates are selected by exact name, because a wrong template builds the wrong document.
Solution
A web request only adds a job to the queue. A Node worker downloads the files, matches them to sections, and runs the Python script as a subprocess with one JSON argument. Exit code 0 marks the job done.
flowchart LR
U[user browser] -->|build| API[Express API, Node]
API --> GATE{template bound?}
GATE -->|no| NT[no template, build disabled]
GATE -->|yes| Q[(Redis and BullMQ)]
Q --> WN[Node worker: download, match, manifest]
V[document store API, signed requests] --> WN
WN -->|spawn, template as JSON| WP[Python worker: pypdf, reportlab]
WP -->|stdout and stderr| LOG[job log]
WP -->|exit 0 and the PDF| ST[storage]
ST --> U// Node side: hand the whole job to Python, treat the exit code as the verdict
const py = spawn(PYTHON, ['assemble.py', JSON.stringify(template), '-o', out])
py.stdout.on('data', (d) => job.log(String(d)))
py.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`exit ${code}`))))A bookmark's page number is only known after every earlier file is merged. The script therefore merges in a first pass, recording each file's start page, and writes the bookmarks in a second pass.
sequenceDiagram
participant T as Template
participant M as Merge pass
participant O as Outline pass
participant W as Writer
T->>M: sections in order, files in order
loop each file
M->>W: add its pages
M->>M: note its start page
end
M->>O: every start page is known
loop each section
O->>W: bookmark the section
O->>W: bookmark files only if 2 or more
end
O->>W: open with the outline showing# Python side: pages first, outline second
offset = 0
for section in ordered(template):
for f in section.files:
pages = PdfReader(f.path).pages
f.start = offset
for p in pages: writer.add_page(p)
offset += len(pages)
for section in ordered(template):
ref = writer.add_outline_item(section.title, first_page(section), parent_of(section))
if len(section.files) > 1:
for f in section.files: writer.add_outline_item(f.name, f.start, ref)flowchart TB
P[packet template name] -->|exact match| B{template with that name?}
B -->|no| X[flagged, not buildable]
B -->|yes| S[sections]
F[file section, title, type, filename] -->|loose match| S
S -->|matched| PL[placed]
S -->|unmatched| SIL[bookmark on page 1]Another team had a Python desktop app that handled HMAC authentication, so I ported the auth flow to Node.js to fit the existing application stack.
Outcome
| Measure | Value |
|---|---|
| Templates | 8, from 10 to 52 sections each |
| End-to-end test packet | 41 pages, 51 of 51 rows, 0 unplaced |
| Real packets, files placed | 144 of 219, 66% |
| Live packets a new template touched at deploy | 1 of 7 |
| Time to add a template | 15 days, one case, mostly waiting on the order |
| Node worker, Python worker | 374 lines, 741 lines |
A file that matches no section is still included, bookmarked on page 1, without an error. Build time and time saved were not measured.
Applicability
The pipeline fits any document set with a fixed structure, such as grant applications, court bundles and board packets. A subprocess with JSON input and an exit code connects Node to a Python library.