diff --git a/README.md b/README.md index 27765d8..c865986 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config.toml b/config.toml index 0896ef8..1147056 100644 --- a/config.toml +++ b/config.toml @@ -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 diff --git a/contact_sheet.py b/contact_sheet.py index 0189493..5cb6a35 100644 --- a/contact_sheet.py +++ b/contact_sheet.py @@ -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: diff --git a/photobooth.py b/photobooth.py index f2ca28b..97b563b 100644 --- a/photobooth.py +++ b/photobooth.py @@ -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: diff --git a/test_contact_sheet.py b/test_contact_sheet.py index 23899e0..3443be2 100644 --- a/test_contact_sheet.py +++ b/test_contact_sheet.py @@ -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.")