"""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()