#!/usr/bin/env python3 # /// script # requires-python = ">=3.12" # dependencies = [ # "pillow==12.3.0", # "reportlab==5.0.1", # ] # /// """ Compose A4 print sheets for 1008×1792 themed images as cut-out bookmarks. Design goals ------------ - Never resample the source pixels (quality identical to the generated file). - Physical size is therefore determined only by the chosen print DPI. - Thin crop marks + optional rounded-corner cut guides are drawn in vector so they stay sharp at any printer resolution. - Two ready layouts: * 2-up (~300 dpi) → 85.3 × 151.7 mm bookmarks, two per A4 * 4-up (~356 dpi) → 72.0 × 128.0 mm bookmarks, four per A4 Usage ----- python make_bookmark_sheets.py # write empty templates python make_bookmark_sheets.py img1.png img2.png ... python make_bookmark_sheets.py --layout 4up *.png Requires: pillow, reportlab """ from __future__ import annotations import argparse import os from pathlib import Path from PIL import Image from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen import canvas # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- SRC_W, SRC_H = 1008, 1792 A4_W, A4_H = A4 # points # Layout presets: name → (physical width mm, physical height mm, cols, rows) # Height is derived from source aspect so there is no distortion. ASPECT = SRC_H / SRC_W LAYOUTS = { "2up": { "label": "2-up · ~300 dpi · larger bookmarks", "width_mm": SRC_W / 300 * 25.4, # 85.344 "cols": 2, "rows": 1, "gap_mm": 10.0, "margin_mm": 12.0, "corner_r_mm": 4.0, "dpi": 300.0, }, "4up": { "label": "4-up · ~365 dpi · four per sheet", "width_mm": 70.0, "cols": 2, "rows": 2, "gap_mm": 7.0, "margin_mm": 10.0, "corner_r_mm": 3.5, "dpi": SRC_W / (70.0 / 25.4), }, } MARK_COLOR = (0.15, 0.15, 0.15) GUIDE_COLOR = (0.55, 0.15, 0.15) NOTE_COLOR = (0.25, 0.25, 0.25) # CachyOS is Arch-based: packages drop TTF/OTF under /usr/share/fonts/{TTF,OTF,…} # rather than Debian's /usr/share/fonts/truetype/dejavu/. FONT_ROOTS = [ Path("/usr/share/fonts"), Path("/usr/local/share/fonts"), Path("/usr/share/fonts/TTF"), Path("/usr/share/fonts/OTF"), Path("/usr/share/fonts/truetype"), Path("/usr/share/fonts/opentype"), Path("/usr/share/fonts/noto"), Path("/usr/share/fonts/liberation"), Path("/usr/share/fonts/gnu-free"), Path("/usr/share/fonts/adobe-source-sans-pro"), Path("/usr/share/fonts/adobe-source-sans"), Path("/usr/share/fonts/inter"), Path("/usr/share/fonts/cantarell"), Path(os.path.expanduser("~/.local/share/fonts")), Path(os.path.expanduser("~/.fonts")), ] # (regular filename fragments, bold filename fragments) — first hit wins. # Prefer clean sans families that CachyOS actually ships by default. FONT_CANDIDATES = [ ("DejaVuSans.ttf", "DejaVuSans-Bold.ttf"), ("DejaVuSans.otf", "DejaVuSans-Bold.otf"), ("NotoSans-Regular.ttf", "NotoSans-Bold.ttf"), ("NotoSans[wdth,wght].ttf", None), # variable; regular axis only ("LiberationSans-Regular.ttf", "LiberationSans-Bold.ttf"), ("LiberationSans-Regular.otf", "LiberationSans-Bold.otf"), ("SourceSans3-Regular.otf", "SourceSans3-Bold.otf"), ("SourceSans3-Regular.ttf", "SourceSans3-Bold.ttf"), ("SourceSansPro-Regular.otf", "SourceSansPro-Bold.otf"), ("Inter-Regular.ttf", "Inter-Bold.ttf"), ("Inter-Regular.otf", "Inter-Bold.otf"), ("FreeSans.otf", "FreeSansBold.otf"), ("FreeSans.ttf", "FreeSansBold.ttf"), ("Cantarell-Regular.otf", "Cantarell-Bold.otf"), ("Cantarell-VF.otf", None), ("NotoSans-Regular.otf", "NotoSans-Bold.otf"), ] def _index_font_files() -> dict[str, Path]: """Map lowercased basename → first matching path under FONT_ROOTS.""" found: dict[str, Path] = {} seen_dirs: set[Path] = set() for root in FONT_ROOTS: if not root.is_dir(): continue resolved = root.resolve() if resolved in seen_dirs: continue seen_dirs.add(resolved) for p in root.rglob("*"): if p.suffix.lower() not in {".ttf", ".otf", ".ttc"}: continue if not p.is_file(): continue key = p.name.lower() found.setdefault(key, p) return found def register_fonts() -> str: """Register a UI sans for labels. Never crash if CachyOS lacks DejaVu.""" index = _index_font_files() regular_path = None bold_path = None for regular_name, bold_name in FONT_CANDIDATES: hit = index.get(regular_name.lower()) if hit is None: continue regular_path = hit if bold_name: bold_path = index.get(bold_name.lower()) break if regular_path is None: print("No system TTF/OTF sans found; using built-in Helvetica") return "Helvetica" pdfmetrics.registerFont(TTFont("SheetSans", str(regular_path))) if bold_path is not None: pdfmetrics.registerFont(TTFont("SheetSansBold", str(bold_path))) print(f"Using font: {regular_path}") if bold_path is not None: print(f"Using bold: {bold_path}") return "SheetSans" def draw_crop_marks(c: canvas.Canvas, x: float, y: float, w: float, h: float, length: float = 5 * mm, offset: float = 2 * mm) -> None: """Professional corner crop marks just outside the trim box.""" c.saveState() c.setStrokeColorRGB(*MARK_COLOR) c.setLineWidth(0.25) # bottom-left c.line(x - offset - length, y, x - offset, y) c.line(x, y - offset - length, x, y - offset) # bottom-right c.line(x + w + offset, y, x + w + offset + length, y) c.line(x + w, y - offset - length, x + w, y - offset) # top-left c.line(x - offset - length, y + h, x - offset, y + h) c.line(x, y + h + offset, x, y + h + offset + length) # top-right c.line(x + w + offset, y + h, x + w + offset + length, y + h) c.line(x + w, y + h + offset, x + w, y + h + offset + length) c.restoreState() def draw_rounded_guide(c: canvas.Canvas, x: float, y: float, w: float, h: float, r: float) -> None: """Dashed rounded-rect sitting on the intended cut line.""" c.saveState() c.setStrokeColorRGB(*GUIDE_COLOR) c.setDash(1.2, 1.6) c.setLineWidth(0.3) c.roundRect(x, y, w, h, r, stroke=1, fill=0) c.restoreState() def layout_positions(cfg: dict) -> list[tuple[float, float, float, float]]: """Return list of (x, y, w, h) in points, origin bottom-left, centered on page.""" w = cfg["width_mm"] * mm h = cfg["width_mm"] * ASPECT * mm gap = cfg["gap_mm"] * mm cols, rows = cfg["cols"], cfg["rows"] block_w = cols * w + (cols - 1) * gap block_h = rows * h + (rows - 1) * gap # Center horizontally; keep a dedicated strip at the bottom for the legend. margin = cfg["margin_mm"] * mm legend_h = 16 * mm origin_x = max(margin, (A4_W - block_w) / 2) available_h = A4_H - legend_h origin_y = legend_h + max(margin * 0.4, (available_h - block_h) / 2) boxes = [] for r in range(rows): for col in range(cols): x = origin_x + col * (w + gap) # rows from top visually → higher y is top y = origin_y + (rows - 1 - r) * (h + gap) boxes.append((x, y, w, h)) return boxes def draw_legend(c: canvas.Canvas, cfg: dict, font: str, n_images: int) -> None: c.saveState() c.setFillColorRGB(*NOTE_COLOR) c.setFont(font, 7) y = 8 * mm lines = [ f"A4 print sheet · {cfg['label']}", f"Source {SRC_W}×{SRC_H} px placed 1:1 (no resampling) · " f"print at 100% / Actual Size · do not 'fit to page'", f"Grey marks = trim corners · dashed red = optional rounded cut " f"(r = {cfg['corner_r_mm']:.1f} mm) · paper: 200–300 gsm card or photo stock", ] for line in lines: c.drawString(12 * mm, y, line) y += 3.4 * mm c.restoreState() def draw_placeholder(c: canvas.Canvas, x, y, w, h, font: str, idx: int) -> None: c.saveState() c.setFillColorRGB(0.93, 0.93, 0.94) c.rect(x, y, w, h, stroke=0, fill=1) c.setStrokeColorRGB(0.7, 0.7, 0.72) c.setLineWidth(0.4) c.rect(x, y, w, h, stroke=1, fill=0) # cross c.setStrokeColorRGB(0.8, 0.8, 0.82) c.line(x, y, x + w, y + h) c.line(x, y + h, x + w, y) c.setFillColorRGB(0.35, 0.35, 0.38) c.setFont(font, 9) c.drawCentredString(x + w / 2, y + h / 2 + 6, f"slot {idx + 1}") c.setFont(font, 7) c.drawCentredString(x + w / 2, y + h / 2 - 8, f"{SRC_W} × {SRC_H} px") c.restoreState() def compose_page( c: canvas.Canvas, cfg: dict, images: list[Path | None], font: str, ) -> None: boxes = layout_positions(cfg) # pad / cycle images to fill slots slots = [] if images: for i in range(len(boxes)): slots.append(images[i % len(images)]) else: slots = [None] * len(boxes) for i, ((x, y, w, h), img_path) in enumerate(zip(boxes, slots)): if img_path and img_path.exists(): # Draw the raster at the exact box size. ReportLab will embed the # original pixels; because box mm == pixels / chosen-dpi, there is # no interpolation of the source. c.drawImage( str(img_path), x, y, width=w, height=h, preserveAspectRatio=True, anchor="c", mask="auto", ) else: draw_placeholder(c, x, y, w, h, font, i) draw_crop_marks(c, x, y, w, h) draw_rounded_guide(c, x, y, w, h, cfg["corner_r_mm"] * mm) draw_legend(c, cfg, font, len([p for p in images if p])) def build_pdf(out_path: Path, layout: str, image_paths: list[Path]) -> None: font = register_fonts() cfg = LAYOUTS[layout] c = canvas.Canvas(str(out_path), pagesize=A4) c.setTitle(f"Bookmark sheet ({layout})") c.setAuthor("bookmark kit") if not image_paths: compose_page(c, cfg, [], font) c.showPage() else: per_page = cfg["cols"] * cfg["rows"] for start in range(0, len(image_paths), per_page): chunk = image_paths[start : start + per_page] compose_page(c, cfg, chunk, font) c.showPage() c.save() print(f"Wrote {out_path}") def main() -> None: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("images", nargs="*", type=Path, help="PNG/JPG sources (1008×1792 ideal)") p.add_argument("--layout", choices=LAYOUTS, default="2up") p.add_argument("--both", action="store_true", help="also write the other layout") p.add_argument("-o", "--out", type=Path, default=None) args = p.parse_args() images = [ip for ip in args.images if ip.exists()] missing = [ip for ip in args.images if not ip.exists()] if missing: print("Skipping missing files:", ", ".join(str(m) for m in missing)) layouts = [args.layout] if args.both: layouts = list(LAYOUTS) for lay in layouts: suffix = "template" if not images else "sheet" out = args.out if out is None or len(layouts) > 1: out = Path(__file__).resolve().parent / f"bookmark_{lay}_{suffix}.pdf" build_pdf(out, lay, images) if __name__ == "__main__": main()