248 lines
9.8 KiB
Python
248 lines
9.8 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.
|
|
"""
|
|
|
|
import subprocess
|
|
from datetime import datetime
|
|
|
|
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))
|
|
|
|
sheet.save(out_path, quality=95)
|
|
return out_path
|
|
|
|
|
|
def print_sheet(path, printer=""):
|
|
"""Send the sheet to the printer via lp. Returns (ok, message)."""
|
|
cmd = ["lp", "-o", "media=letter", "-o", "fit-to-page"]
|
|
if printer:
|
|
cmd += ["-d", printer]
|
|
cmd.append(str(path))
|
|
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() |