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 automatically after a successful print. (If the program is restarted
mid-cycle, photos here are counted so no progress is lost.) mid-cycle, photos here are counted so no progress is lost.)
- `contact_sheets/` — every printed sheet, kept as a backup in case of - `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 printer trouble. Each sheet has two files: a `.jpg` (handy for viewing)
also print at the right size from any other app. Delete them manually and a `.pdf` twin — the PDF is a full US-letter page at exactly 300 DPI
now and then, or set `delete_contact_sheets = true` in the config to and is what actually gets sent to the printer. Delete them manually now
remove each one right after it prints. and then, or set `delete_contact_sheets = true` in the config to remove
each one right after it prints.
## If printing fails ## If printing fails
@ -111,9 +112,12 @@ The booth doesn't lose anything: the contact sheet is saved in
sheet by hand with: sheet by hand with:
```bash ```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" — Common causes: printer off or unplugged (`lpstat -p` says "disabled" —
re-enable with `cupsenable PRINTER_NAME`), or no default printer set. 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, # 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). # but nothing is sent to the printer (and photos are still cleaned up).
enabled = true 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. # false = keep them in contact_sheets/ in case something goes wrong.
delete_contact_sheets = false 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 a strip has few photos and a wide page fills better
(e.g. 1 photo per shoot, or one big photo). (e.g. 1 photo per shoot, or one big photo).
The date is printed only inside the footer. Sheets are saved with 300-DPI The date is printed only inside the footer.
metadata so their physical print size is well-defined (a full letter page)
for CUPS or any other program they are printed from. 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 import subprocess
from datetime import datetime from datetime import datetime
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont 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 # 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). # physical size and the print pipeline has to guess (and may stretch).
sheet.save(out_path, quality=95, dpi=(300, 300)) 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 return out_path
def print_sheet(path, printer=""): def print_sheet(path, printer=""):
"""Send the sheet to the printer via lp. Returns (ok, message). """Send the sheet to the printer via lp. Returns (ok, message).
fit-to-page scales uniformly, so the sheet's aspect ratio is preserved. Prefers the sheet's PDF twin (exact page geometry) when it exists, and
Passing the sheet's orientation explicitly keeps a landscape sheet from falls back to the image itself. fit-to-page scales uniformly, so the
being rotated or squeezed by driver defaults. 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: path = Path(path)
sheet_w, sheet_h = im.size job = path.with_suffix(".pdf")
orientation = "landscape" if sheet_w > sheet_h else "portrait" job = job if job.exists() else path
cmd = ["lp", "-o", "media=letter", "-o", "fit-to-page", cmd = ["lp", "-o", "media=letter", "-o", "fit-to-page"]
"-o", orientation] 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: if printer:
cmd += ["-d", printer] cmd += ["-d", printer]
cmd.append(str(path)) cmd.append(str(job))
try: try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except (OSError, subprocess.TimeoutExpired) as e: except (OSError, subprocess.TimeoutExpired) as e:

View File

@ -246,6 +246,7 @@ class Booth:
self.falling.reload() self.falling.reload()
if printing["delete_contact_sheets"]: if printing["delete_contact_sheets"]:
sheet_path.unlink() sheet_path.unlink()
sheet_path.with_suffix(".pdf").unlink(missing_ok=True)
self.shoots_done = 0 self.shoots_done = 0
self.set_notice("Sent to printer!") self.set_notice("Sent to printer!")
else: 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). applied transpose is CCW (correct) or CW (the bug we fixed).
""" """
import re
import sys import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@ -129,6 +130,18 @@ def check_layout_combos():
sheet = opened.convert("RGB") sheet = opened.convert("RGB")
assert sheet.size == (page_w, page_h) 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"]): for i, r in enumerate(layout["photo_rects"]):
got = sheet.getpixel(rect_center(r)) got = sheet.getpixel(rect_center(r))
exp = colors[i] exp = colors[i]
@ -231,12 +244,53 @@ def check_falling_photos_rotation_halved():
print(f" ROT=±{fp.ROT_MAX} SPIN=±{fp.SPIN_MAX} OK") 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(): def main():
check_layout_combos() check_layout_combos()
check_rotation_mode_expectations() check_rotation_mode_expectations()
check_rotation_direction() check_rotation_direction()
check_default_config_shape() check_default_config_shape()
check_falling_photos_rotation_halved() check_falling_photos_rotation_halved()
check_print_sheet_prefers_pdf()
print("\nAll tests passed.") print("\nAll tests passed.")