This commit is contained in:
2026-07-22 15:28:35 -04:00
parent 755fa0632c
commit dd42e8fc58
5 changed files with 91 additions and 18 deletions

View File

@ -99,10 +99,11 @@ To use a different camera:
automatically after a successful print. (If the program is restarted
mid-cycle, photos here are counted so no progress is lost.)
- `contact_sheets/` — every printed sheet, kept as a backup in case of
printer trouble. Sheets are full US-letter pages at 300 DPI, so they
also print at the right size from any other app. Delete them manually
now and then, or set `delete_contact_sheets = true` in the config to
remove each one right after it prints.
printer trouble. Each sheet has two files: a `.jpg` (handy for viewing)
and a `.pdf` twin — the PDF is a full US-letter page at exactly 300 DPI
and is what actually gets sent to the printer. Delete them manually now
and then, or set `delete_contact_sheets = true` in the config to remove
each one right after it prints.
## If printing fails
@ -111,9 +112,12 @@ The booth doesn't lose anything: the contact sheet is saved in
sheet by hand with:
```bash
lp -o media=letter -o fit-to-page contact_sheets/sheet_XXXX.jpg
lp -o media=letter -o fit-to-page contact_sheets/sheet_XXXX.pdf
```
(Print the `.pdf`, not the `.jpg` — the PDF's page size is exact, so the
printer can't stretch the photos.)
Common causes: printer off or unplugged (`lpstat -p` says "disabled" —
re-enable with `cupsenable PRINTER_NAME`), or no default printer set.

View File

@ -63,6 +63,6 @@ printer = "HP-LaserJet-P2055d"
# Set to false while testing: contact sheets are still composed and saved,
# but nothing is sent to the printer (and photos are still cleaned up).
enabled = true
# true = delete each contact sheet immediately after it prints.
# true = delete each contact sheet (JPEG + PDF) immediately after it prints.
# false = keep them in contact_sheets/ in case something goes wrong.
delete_contact_sheets = false

View File

@ -17,13 +17,17 @@ that maximizes each printed photo's area on a fixed 8.5x11 letter sheet
a strip has few photos and a wide page fills better
(e.g. 1 photo per shoot, or one big photo).
The date is printed only inside the footer. Sheets are saved with 300-DPI
metadata so their physical print size is well-defined (a full letter page)
for CUPS or any other program they are printed from.
The date is printed only inside the footer.
Each sheet is saved twice: a JPEG (300-DPI metadata, handy for viewing or
reprinting from other apps) and a PDF twin whose page geometry is exactly a
300-DPI letter page. `print_sheet` sends the PDF, so the print pipeline
rasterizes exactly what was composed - no image-DPI guessing, no warping.
"""
import subprocess
from datetime import datetime
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
@ -234,24 +238,34 @@ def build_sheet(photo_paths, out_path, footer_cfg=None,
# Stamp 300 DPI into the file: without it the sheet has no defined
# physical size and the print pipeline has to guess (and may stretch).
sheet.save(out_path, quality=95, dpi=(300, 300))
# PDF twin: page size is exactly a 300-DPI letter page, so printing it
# reproduces the composed geometry 1:1 (PIL maps pixels at `resolution`
# DPI onto the PDF page).
sheet.save(Path(out_path).with_suffix(".pdf"), "PDF", resolution=300.0)
return out_path
def print_sheet(path, printer=""):
"""Send the sheet to the printer via lp. Returns (ok, message).
fit-to-page scales uniformly, so the sheet's aspect ratio is preserved.
Passing the sheet's orientation explicitly keeps a landscape sheet from
being rotated or squeezed by driver defaults.
Prefers the sheet's PDF twin (exact page geometry) when it exists, and
falls back to the image itself. fit-to-page scales uniformly, so the
sheet's aspect ratio is preserved. For plain images we also pass the
orientation explicitly so a landscape sheet is not rotated or squeezed
by driver defaults; a PDF carries its orientation in its page size.
"""
with Image.open(path) as im:
sheet_w, sheet_h = im.size
orientation = "landscape" if sheet_w > sheet_h else "portrait"
cmd = ["lp", "-o", "media=letter", "-o", "fit-to-page",
"-o", orientation]
path = Path(path)
job = path.with_suffix(".pdf")
job = job if job.exists() else path
cmd = ["lp", "-o", "media=letter", "-o", "fit-to-page"]
if job.suffix.lower() != ".pdf":
with Image.open(job) as im:
sheet_w, sheet_h = im.size
cmd += ["-o", "landscape" if sheet_w > sheet_h else "portrait"]
if printer:
cmd += ["-d", printer]
cmd.append(str(path))
cmd.append(str(job))
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except (OSError, subprocess.TimeoutExpired) as e:

View File

@ -246,6 +246,7 @@ class Booth:
self.falling.reload()
if printing["delete_contact_sheets"]:
sheet_path.unlink()
sheet_path.with_suffix(".pdf").unlink(missing_ok=True)
self.shoots_done = 0
self.set_notice("Sent to printer!")
else:

View File

@ -12,6 +12,7 @@ quadrant that lands in the cell's top-left corner tells us whether the
applied transpose is CCW (correct) or CW (the bug we fixed).
"""
import re
import sys
import tempfile
from pathlib import Path
@ -129,6 +130,18 @@ def check_layout_combos():
sheet = opened.convert("RGB")
assert sheet.size == (page_w, page_h)
# PDF twin: page must be exactly the sheet's pixel size at
# 300 DPI, in points (1/72in) - that is what the printer sees.
pdf = out.with_suffix(".pdf")
assert pdf.exists(), "PDF twin not written"
m = re.search(rb"/MediaBox\s*\[\s*0[ ,]+0[ ,]+([\d.]+)[ ,]+([\d.]+)\s*\]",
pdf.read_bytes())
assert m, "PDF MediaBox missing"
pw, ph = float(m.group(1)), float(m.group(2))
want_w, want_h = page_w * 72 / 300, page_h * 72 / 300
assert abs(pw - want_w) < 1 and abs(ph - want_h) < 1, (
f"PDF page {pw}x{ph}pt != ~{want_w}x{want_h}pt")
for i, r in enumerate(layout["photo_rects"]):
got = sheet.getpixel(rect_center(r))
exp = colors[i]
@ -231,12 +244,53 @@ def check_falling_photos_rotation_halved():
print(f" ROT=±{fp.ROT_MAX} SPIN=±{fp.SPIN_MAX} OK")
def check_print_sheet_prefers_pdf():
print("== print_sheet job selection ==")
calls = []
class FakeResult:
returncode = 0
stdout = "ok"
stderr = ""
def fake_run(*a, **k):
calls.append(list(a[0]))
return FakeResult()
orig = cs.subprocess.run
cs.subprocess.run = fake_run
try:
with tempfile.TemporaryDirectory() as d:
jpg = Path(d) / "s.jpg"
Image.new("RGB", (3300, 2550), (10, 20, 30)).save(jpg, dpi=(300, 300))
# No PDF yet: prints the JPEG, with explicit orientation.
ok, _ = cs.print_sheet(jpg, "SomePrinter")
assert ok
argv = calls.pop()
assert argv[-1].endswith("s.jpg"), argv
assert "landscape" in argv, argv
assert "-d" in argv and "SomePrinter" in argv, argv
# PDF twin exists: it wins, and no orientation flag is needed.
jpg.with_suffix(".pdf").write_bytes(b"%PDF-1.4 fake")
ok, _ = cs.print_sheet(jpg, "SomePrinter")
assert ok
argv = calls.pop()
assert argv[-1].endswith("s.pdf"), argv
assert "landscape" not in argv and "portrait" not in argv, argv
finally:
cs.subprocess.run = orig
print(" JPEG fallback gets orientation; PDF twin preferred OK")
def main():
check_layout_combos()
check_rotation_mode_expectations()
check_rotation_direction()
check_default_config_shape()
check_falling_photos_rotation_halved()
check_print_sheet_prefers_pdf()
print("\nAll tests passed.")