test
This commit is contained in:
14
README.md
14
README.md
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
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
|
sheet_w, sheet_h = im.size
|
||||||
orientation = "landscape" if sheet_w > sheet_h else "portrait"
|
cmd += ["-o", "landscape" if sheet_w > sheet_h else "portrait"]
|
||||||
cmd = ["lp", "-o", "media=letter", "-o", "fit-to-page",
|
|
||||||
"-o", orientation]
|
|
||||||
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:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user