photo-to-scanned-pdf
- Repo stars 0
- Author updated Aug 24, 2026, 03:33 PM
- Author repo claude-code-skills
Photo → Scanned PDF
Two related pipelines, same destination look, different starting point: phone photos of paper documents, or a digital document that needs a synthetic signature before it looks signed. The pipelines that work, and the failure modes that ship wrong PDFs if skipped.
Which one do you need?
| The input is... | Use |
|---|---|
| Phone photos of an already-signed/stamped paper document | This file, main pipeline below |
| A digital document (docx/PDF) with no signature yet, and you need to make it look hand-signed | references/digital-signature-synthesis.md |
photos ──► rectify (photo_to_scan.py --raw)
──► ORDER BY CONTENT, detect colored paper ← agent eyes, not filenames
──► enhance: noteshrink (white batch with -g │ colored pages separately,
after white-balance pre-pass)
──► assemble_pdf.py → A4 PDF
──► make_contact_sheet.py → READ IT, verify EVERY page ← mandatory
Division of labor: scripts carry execution; you (the agent) carry the two judgment steps — content-based page ordering, and whole-document verification. Neither can be automated away: filenames lie about order, and per-page spot checks miss wrong-slot bugs. The digital-signature branch shares this same philosophy with its own two judgment calls — see the reference file.
Step 0 — Dependencies
which pdftoppm || brew install poppler # contact sheet + any PDF rendering
uvx noteshrink --help | head -3 # first run builds it (~30 s)
Scripts are uv run single-file scripts (PEP 723); OpenCV/PIL/img2pdf resolve
automatically on first run.
Step 1 — Rectify
uv run <skill>/scripts/photo_to_scan.py --raw --out-dir work --prefix page \
photo1.jpg photo2.jpg ...
Expected: one page_NN.jpg per photo, each tagged [quad]. A
[FULLFRAME-fallback] tag means the paper outline wasn't found (busy background,
page cut off) — view that photo and decide: retake, or accept the uncropped frame.
The script handles EXIF rotation internally (cv2.imread ignores EXIF; phone
photos come rotated — this silently produces sideways pages if you rectify with
raw OpenCV).
Step 2 — Order by content, detect colored paper (agent judgment)
Read every rectified image (batch of ~6 per message) and record two things:
- Its identity — date, title, page number, whatever distinguishes pages. Batch-exported photos (WeChat, AirDrop) get timestamps of the export moment, often all within one second — filename order is meaningless. Real case: 17 photos turned out to be in exact reverse document order; only content reading caught it.
- Its paper color — white, or colored (blue/yellow/pink stock)? Colored
pages take a different path in Step 3. If unsure, sample programmatically:
mean RGB of a blank region;
B > R + 25⇒ blue-ish paper.
Build the final page order as an explicit list before proceeding. If pages are supposed to match an external register (an invoice list, a session table), cross-check identity against it now — missing/duplicate pages found here cost seconds; found after delivery they cost a redo.
Step 3 — Enhance (noteshrink, split by paper color)
White-paper pages — one batch, global palette:
uvx noteshrink -w -g -K -q -b ns -c "true" page_03.jpg page_01.jpg page_07.jpg ...
# inputs IN FINAL PAGE ORDER → outputs ns0000.png, ns0001.png, ... in that order
-wwhite background,-gone global palette (uniform ink/stamp color across pages),-Kkeep given order,-c "true"skips its internal PDF step (we assemble ourselves).- Pass filenames explicitly. zsh does not word-split
$VAR— a file list in a variable arrives as one giant "filename", noteshrink exits without output, and-qkeeps it silent. Verify outputs exist (ls ns0*.png) rather than trusting stdout.
Colored-paper pages — separate, with white-balance pre-pass:
uv run <skill>/scripts/photo_to_scan.py --out-dir work --prefix wb colored_photo.jpg # no --raw
uvx noteshrink -w -g -K -q -b nc -c "true" work/wb_01.jpg
Two distinct failure modes force this split (both shipped as bugs before the rule existed):
- Colored pages inside the
-gbatch poison the whole document — the paper color enters the global palette and white pages come out with tinted shadows/artifacts. - noteshrink alone on colored paper whitens the background but not the
foreground cast — black ink photographed on blue stock reads blue-purple, a
red stamp reads maroon. The default (non-
--raw) mode ofphoto_to_scan.pydivides out the paper color first, so ink returns to black and stamps to red.
Step 4 — Assemble
uv run <skill>/scripts/assemble_pdf.py --out scanned.pdf \
ns0000.png ns0001.png nc0000.png ns0002.png ... # FINAL page order
Expected: OK scanned.pdf (N pages, ~0.05 MB/page). Edge crop (default
24px top / 12px sides at 200 dpi) removes the sliver of desk surface that
rectification drags in along page borders; document margins dwarf it.
Step 5 — Verify the WHOLE document (mandatory, not optional)
uv run <skill>/scripts/make_contact_sheet.py scanned.pdf --out contact.png
Read contact.png and check every page: identity sequence complete and
correct (each date/title where it should be, no duplicates, none missing), no
off-color page, stamps/signatures present. Then spot-read 1–2 pages at full
resolution for text sharpness.
Why whole-document, every time: two shipped-bug stories from the session this skill was distilled from —
- A page-replacement task wrote the new page into the wrong slot (an off-by-one in a copy command), silently overwriting a neighboring page. The per-page check of the replaced slots passed; the clobbered neighbor was only caught by the user.
- A palette-poisoning bug (Step 3 #1) tinted pages that were not being edited. Checking only the edited pages missed it.
The cost asymmetry is absolute: contact sheet = one Read; a wrong page in a delivered PDF = redo + lost trust. "I verified the pages I changed" is not verification.
Replacing pages in an existing scanned PDF
Keep the per-page enhanced PNGs (ns*/nc*) as the working set. To replace page
k: process the new photo through Steps 1–3, overwrite that page's PNG, re-run
Steps 4–5. When copying into numbered slots, mind the mapping — slot numbers
shift when photo order was reversed; derive the slot from the page's content
identity, never from its position in the photo batch. Then the Step 5 full
check is what actually protects you.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Output "doesn't look scanned" — gray haze, soft text | You hand-rolled levels/curves/divide enhancement. Don't — two attempts were rejected by a real user before switching to noteshrink (background sampling + palette quantization is what produces the flat-white scan look). |
| White pages have tinted shadows | A colored-paper page was inside the -g batch. Re-run whites-only batch (Step 3). |
| Ink looks blue/purple, stamp looks maroon on a colored page | noteshrink got the colored page raw. Insert the white-balance pre-pass (photo_to_scan.py without --raw). |
| noteshrink produced no output, no error | File list passed via an unquoted shell variable under zsh (no word splitting), or paths with spaces. Pass explicit filenames; check ls ns0*.png. |
| Page sideways / upside down | EXIF ignored somewhere upstream, or the quad landed landscape. photo_to_scan.py corrects EXIF + rotates to portrait; upside-down pages it cannot know — catch at Step 2 and rotate the source photo. |
[FULLFRAME-fallback] on a photo |
Paper outline not detected (low contrast vs table, page cut off). Retake against a dark background, or accept full frame + rely on edge crop. |
| Thin dark strip along page edge in the PDF | Desk surface dragged in by rectification. Raise --crop-top/--crop-side in assemble_pdf.py. |
| Pages in wrong order in the PDF | Filename-order assumption. Order comes from Step 2 content reading, passed explicitly to noteshrink (-K) and assemble_pdf.py. |
- Fluxly category
- Data
- Author-declared agents
- No explicit declaration found; this is not inferred or tested compatibility
- Static check
- 88 / 100 · heuristic scan, not runtime safety proof
- Author / version / license
- @daymade · no license declared
- Fluxly token estimate
- Lean
- Fluxly setup estimate
- Guided setup
- External API key
- No requirement detected
- Detected OS requirements
- Unspecified
- Runtime requirements
- Unspecified
- Detected file/system behavior
-
- Read-only
- Write / modify
- Shell exec
- Detected network behavior
- Local-only
- Install commands
- None (reference only)
Profile is derived at build time from SKILL.md and install vectors. Subject to drift from author intent.
Heads up: 未限定 allowed-tools,默认拥有全部工具权限。
The current SKILL.md does not define a fixed output example. Scripts are uv run single-file scripts (PEP 723); OpenCV/PIL/img2pdf resolve automatically on first run.
Expected: one pageNN.jpg per photo, each tagged [quad]. A [FULLFRAME-fallback] tag means the paper outline wasn't found (busy background, page cut off) — view that photo and decide: retake, or accept the uncropped frame.
Read every rectified image (batch of ~6 per message) and record two things: Its identity — date, title, page number, whatever distinguishes pages. Batch-exported photos (WeChat, AirDrop) get timestamps of the export
White-paper pages — one batch, global palette: -w white background, -g one global palette (uniform ink/stamp color across pages), -K keep given order, -c "true" skips its internal PDF step (we
Expected: OK scanned.pdf (N pages, ~0.05 MB/page). Edge crop (default 24px top / 12px sides at 200 dpi) removes the sliver of desk surface that rectification drags in along page borders; document margins dwarf it.
Read contact.png and check every page: identity sequence complete and correct (each date/title where it should be, no duplicates, none missing), no off-color page, stamps/signatures present. Then spot-read 1–2 pages at full
# Photo → Scanned PDF
Two related pipelines, same destination look, different starting point:
phone photos of paper documents, or a digital document that needs a synthetic
signature before it looks signed. The pipelines that work, and the failure
modes that ship wrong PDFs if skipped.
**Which one do you need?**
| The input is... | Use |
|---|---|
| Phone photos of an already-signed/stamped paper document | This file, main pipeline below |
| A digital document (docx/PDF) with **no signature yet**, and you need to make it look hand-signed | [references/digital-signature-synthesis.md](references/digital-signature-synthesis.md) |
```
photos ──► rectify (photo_to_scan.py --raw)
──► ORDER BY CONTENT, detect colored paper ← agent eyes, not filenames
──► enhance: noteshrink (white batch with -g │ colored pages separately,
after white-balance pre-pass)
──► assemble_pdf.py → A4 PDF
──► make_contact_sheet.py → READ IT, verify EVERY page ← mandatory
```
**Division of labor**: scripts carry execution; you (the agent) carry the two
judgment steps — content-based page ordering, and whole-document verification.
Neither can be automated away: filenames lie about order, and per-page spot
checks miss wrong-slot bugs. The digital-signature branch shares this same
philosophy with its own two judgment calls — see the reference file.
## Step 0 — Dependencies
```bash
which pdftoppm || brew install poppler # contact sheet + any PDF rendering
uvx noteshrink --help | head -3 # first run builds it (~30 s)
```
Scripts are `uv run` single-file scripts (PEP 723); OpenCV/PIL/img2pdf resolve
automatically on first run.
## Step 1 — Rectify
```bash
… Author text anchors workflow facts; Fluxly only indexes current sections, terms, files, and commands.
sections -> Step 0 — Dependencies → Step 1 — Rectify → Step 2 — Order by content, detect colored paper (agent judgment) → Step 3 — Enhance (noteshrink, split by paper color) → Step 4 — Assemble → Step 5 — Verify the WHOLE document (mandatory, not optional)
terms -> Which one do you need? · no signature yet · Division of labor · Read every rectified image · Its identity · Its paper color · White-paper pages — one batch, global palette · Pass filenames explicitly.
files/cmd -> uv run · pageNN.jpg · [quad] · [FULLFRAME-fallback] · cv2.imread · B > R + 25 · -c "true" · $VAR
body sha256 -> 991f2509b797
Decide Fit First
Design Intent
How To Use It
Boundaries And Review