From c28c955df81b96d2afe7da0361a4b4191e1affd7 Mon Sep 17 00:00:00 2001 From: bmondream Date: Sun, 13 Sep 2026 10:27:44 +0200 Subject: [PATCH] feat: add bookmark-sheets --- bookmark-sheets/.gitignore | 1 + bookmark-sheets/make_bookmark_sheets.py | 351 ++++++++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 bookmark-sheets/.gitignore create mode 100644 bookmark-sheets/make_bookmark_sheets.py diff --git a/bookmark-sheets/.gitignore b/bookmark-sheets/.gitignore new file mode 100644 index 0000000..a136337 --- /dev/null +++ b/bookmark-sheets/.gitignore @@ -0,0 +1 @@ +*.pdf diff --git a/bookmark-sheets/make_bookmark_sheets.py b/bookmark-sheets/make_bookmark_sheets.py new file mode 100644 index 0000000..f54a67a --- /dev/null +++ b/bookmark-sheets/make_bookmark_sheets.py @@ -0,0 +1,351 @@ +#!/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()