352 lines
12 KiB
Python
352 lines
12 KiB
Python
#!/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()
|