Compare commits

..
2 Commits
Author SHA1 Message Date
bmondream c28c955df8 feat: add bookmark-sheets 2026-09-13 10:27:44 +02:00
bmondream b3f9539189 feat: support multiple color correction inputs 2026-09-13 10:22:33 +02:00
3 changed files with 376 additions and 14 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.pdf
+351
View File
@@ -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()
+24 -14
View File
@@ -10,7 +10,7 @@
Apply a warm print color correction (less blue, slightly yellower).
Usage with uv (no venv, no uv pip):
uv run warm_print.py /path/to/image.jpg
uv run warm_print.py image.jpg other.jpg /path/to/more.jpg
uv reads the dependencies in the header above and installs them
into an isolated cache on first run.
@@ -83,17 +83,8 @@ def warm_correct(im: Image.Image) -> Image.Image:
return out_im
def main() -> int:
if len(sys.argv) < 2:
print("Usage: python warm_print.py /path/to/image.jpg", file=sys.stderr)
return 2
src = Path(sys.argv[1]).expanduser().resolve()
if not src.is_file():
print(f"Not a file: {src}", file=sys.stderr)
return 1
# Same folder, same name, short suffix before the original extension.
def process_one(src: Path) -> Path:
"""Color-correct one image and write sibling *_warmprint file."""
dest = src.with_name(f"{src.stem}_warmprint{src.suffix}")
im = Image.open(src)
@@ -112,8 +103,27 @@ def main() -> int:
out_im.save(dest, "JPEG", **save_kwargs)
else:
out_im.save(dest, "PNG", **save_kwargs)
print(dest)
return 0
return dest
def main() -> int:
if len(sys.argv) < 2:
print(
"Usage: python warm_print.py image.jpg [more.jpg ...]",
file=sys.stderr,
)
return 2
failed = 0
for raw in sys.argv[1:]:
src = Path(raw).expanduser().resolve()
if not src.is_file():
print(f"Not a file: {src}", file=sys.stderr)
failed += 1
continue
dest = process_one(src)
print(dest)
return 1 if failed else 0
if __name__ == "__main__":