Files
nissaphotobooth/contact_sheet.py
2026-07-22 15:28:35 -04:00

275 lines
11 KiB
Python

"""Contact sheet composition and printing.
Source photos are always 4:3 landscape (e.g. 800x600). A "strip" is one
shoot's `photos_per_shoot` photos arranged as a VERTICAL COLUMN with a
footer block beneath the column (footer is half a photo tall). The sheet
holds `shoots_per_print` strips side by side.
`compute_layout` picks, per (photos_per_shoot, shoots_per_print), the
combination of *rotation mode*, *page orientation*, and *strip columns*
that maximizes each printed photo's area on a fixed 8.5x11 letter sheet
(2550x3300 at 300 DPI). The two rotation modes are:
- ``portrait_ccw`` : each photo rotated 90 deg counter-clockwise so the
cell is portrait 3:4. Best when strips are tall
and narrow (e.g. 3 photos per shoot, 3 shoots).
- ``landscape_none``: photos kept landscape 4:3 (no rotation). Best when
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.
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
# US letter at 300 DPI, both orientations.
PAGE_W_PORTRAIT, PAGE_H_PORTRAIT = 2550, 3300
PAGE_W_LANDSCAPE, PAGE_H_LANDSCAPE = 3300, 2550
FOOTER_H_RATIO = 0.5 # footer height = half one photo's height
# Rotation modes: (name, cell aspect W:H, PIL transpose used in build_sheet).
# ROTATE_90 = 90 deg CCW ; ROTATE_270 = 90 deg CW ; None = no rotation.
ROTATION_MODES = [
("portrait_ccw", 3, 4, Image.Transpose.ROTATE_90),
("landscape_none", 4, 3, None),
]
def _divisors(n):
out = []
for d in range(1, n + 1):
if n % d == 0:
out.append(d)
return out
def _hex_color(s, default=(0, 0, 0)):
s = (s or "").strip().lstrip("#")
if len(s) == 6:
try:
return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
except ValueError:
pass
return default
def _load_font(size):
try:
return ImageFont.truetype("DejaVuSans-Bold.ttf", size)
except OSError:
try:
return ImageFont.truetype("DejaVuSans.ttf", size)
except OSError:
return ImageFont.load_default(size)
def compute_layout(photos_per_shoot, shoots_per_print, margin,
photo_gutter, strip_gutter, auto_orient=True):
"""Return the optimal sheet layout as a dict.
A strip is always a vertical column of `photos_per_shoot` photos with
a footer beneath it. We try every rotation mode, every column count
that divides `shoots_per_print` (so the grid of strips has no empty
cells), and (when auto_orient) both page orientations, then choose the
layout with the largest printed photo cell area.
"""
pps = max(1, int(photos_per_shoot))
spp = max(1, int(shoots_per_print))
orientations = [(PAGE_W_PORTRAIT, PAGE_H_PORTRAIT, "portrait"),
(PAGE_W_LANDSCAPE, PAGE_H_LANDSCAPE, "landscape")]
if not auto_orient:
orientations = orientations[:1]
best = None # (photo_area, layout_dict)
for mode_name, aw, ah, transpose in ROTATION_MODES:
for cols in _divisors(spp):
rows = spp // cols
for page_w, page_h, orient_name in orientations:
avail_w = page_w - 2 * margin
avail_h = page_h - 2 * margin
if avail_w <= 0 or avail_h <= 0:
continue
# Width budget: cols*cell_w + (cols-1)*strip_gutter <= avail_w
cell_w_from_w = (avail_w - (cols - 1) * strip_gutter) // cols
# Height budget. One strip of `pps` stacked photos + a
# footer half a photo tall:
# strip_h = pps*cell_h + (pps-1)*photo_gutter + photo_gutter + footer_h
# = cell_h*(pps + FOOTER_H_RATIO) + pps*photo_gutter
# Grid height: rows*strip_h + (rows-1)*strip_gutter <= avail_h
# => cell_h <= (avail_h - (rows-1)*strip_gutter - pps*photo_gutter)
# / (rows*(pps + FOOTER_H_RATIO))
strip_gap_total_h = (rows - 1) * strip_gutter
photo_gaps_total_h = pps * photo_gutter
avail_for_cells_h = avail_h - strip_gap_total_h - photo_gaps_total_h
denom_h = rows * (pps + FOOTER_H_RATIO)
cell_h_from_h = int(avail_for_cells_h / denom_h) if denom_h > 0 else 0
# Convert between cell_w and cell_h using this mode's aspect.
cell_w_from_h = cell_h_from_h * aw // ah
cell_h_from_w = cell_w_from_w * ah // aw
cell_h = min(cell_h_from_h, cell_h_from_w)
if cell_h <= 0:
continue
cell_w = cell_h * aw // ah
if cell_w <= 0:
continue
cell_w = min(cell_w, cell_w_from_w)
cell_h = cell_w * ah // aw # keep aspect exact after clamp
if cell_h <= 0:
continue
footer_h = int(cell_h * FOOTER_H_RATIO)
strip_h = (pps * cell_h + (pps - 1) * photo_gutter
+ photo_gutter + footer_h)
grid_w = cols * cell_w + (cols - 1) * strip_gutter
grid_h = rows * strip_h + (rows - 1) * strip_gutter
if grid_w > avail_w or grid_h > avail_h:
continue
x0 = margin + (avail_w - grid_w) // 2
y0 = margin + (avail_h - grid_h) // 2
photo_rects = []
footer_rects = []
for strip_i in range(spp):
scol = strip_i // rows
srow = strip_i % rows
sx = x0 + scol * (cell_w + strip_gutter)
sy = y0 + srow * (strip_h + strip_gutter)
for pi in range(pps):
py = sy + pi * (cell_h + photo_gutter)
photo_rects.append((sx, py, cell_w, cell_h))
fy = sy + pps * cell_h + (pps - 1) * photo_gutter + photo_gutter
footer_rects.append((sx, fy, cell_w, footer_h))
photo_area = cell_w * cell_h
cand = {
"page_w": page_w, "page_h": page_h,
"orientation": orient_name,
"mode": mode_name,
"transpose": transpose,
"cols": cols, "rows": rows,
"cell_w": cell_w, "cell_h": cell_h,
"footer_h": footer_h,
"strip_h": strip_h, "grid_w": grid_w, "grid_h": grid_h,
"x0": x0, "y0": y0,
"photo_rects": photo_rects,
"footer_rects": footer_rects,
"photo_area": photo_area,
}
if best is None or photo_area > best[0]:
best = (photo_area, cand)
if best is None:
raise ValueError(
f"No contact sheet layout fits photos_per_shoot={pps}, "
f"shoots_per_print={spp} on a letter page.")
return best[1]
def _render_footer(width, height, footer_cfg):
"""Build the footer image: bg fill, line1, line2, then date at bottom."""
line1 = (footer_cfg or {}).get("line1", "")
line2 = (footer_cfg or {}).get("line2", "")
bg = _hex_color((footer_cfg or {}).get("background_color"), (0, 0, 0))
fg = _hex_color((footer_cfg or {}).get("text_color"), (255, 255, 255))
img = Image.new("RGB", (width, height), bg)
draw = ImageDraw.Draw(img)
pad = max(6, height // 12)
title_font = _load_font(max(24, height // 5))
date_font = _load_font(max(18, height // 7))
y = pad
for line in (line1, line2):
if not line:
continue
tw = draw.textlength(line, font=title_font)
draw.text(((width - tw) // 2, y), line, fill=fg, font=title_font)
y += title_font.getbbox(line)[3] + pad // 2
stamp = datetime.now().strftime("%B %d, %Y")
dw = draw.textlength(stamp, font=date_font)
db = date_font.getbbox(stamp)
draw.text(((width - dw) // 2, height - db[3] - pad // 2),
stamp, fill=fg, font=date_font)
return img
def build_sheet(photo_paths, out_path, footer_cfg=None,
photos_per_shoot=3, shoots_per_print=3,
margin=120, photo_gutter=30, strip_gutter=30,
auto_orient=True):
"""Compose photos onto a letter page and save it to out_path."""
layout = compute_layout(photos_per_shoot, shoots_per_print,
margin, photo_gutter, strip_gutter, auto_orient)
sheet = Image.new("RGB", (layout["page_w"], layout["page_h"]), "white")
photos = list(photo_paths)[:len(layout["photo_rects"])]
footer_img = _render_footer(layout["cell_w"], layout["footer_h"], footer_cfg)
transpose = layout["transpose"]
for i, path in enumerate(photos):
x, y, w, h = layout["photo_rects"][i]
photo = Image.open(path)
if transpose is not None:
photo = photo.transpose(transpose)
photo = photo.resize((w, h), Image.Resampling.LANCZOS)
sheet.paste(photo, (x, y))
for (x, y, w, h) in layout["footer_rects"]:
sheet.paste(footer_img, (x, y))
# 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).
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.
"""
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(job))
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except (OSError, subprocess.TimeoutExpired) as e:
return False, f"lp failed to run: {e}"
if result.returncode != 0:
return False, result.stderr.strip() or "lp returned an error"
return True, result.stdout.strip()