Compare commits
2
Commits
4df9c0116e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c28c955df8
|
||
|
|
b3f9539189
|
No files matched your search
@@ -0,0 +1 @@
|
||||
*.pdf
|
||||
@@ -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()
|
||||
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user