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).
|
Apply a warm print color correction (less blue, slightly yellower).
|
||||||
|
|
||||||
Usage with uv (no venv, no uv pip):
|
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
|
uv reads the dependencies in the header above and installs them
|
||||||
into an isolated cache on first run.
|
into an isolated cache on first run.
|
||||||
@@ -83,17 +83,8 @@ def warm_correct(im: Image.Image) -> Image.Image:
|
|||||||
return out_im
|
return out_im
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def process_one(src: Path) -> Path:
|
||||||
if len(sys.argv) < 2:
|
"""Color-correct one image and write sibling *_warmprint file."""
|
||||||
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.
|
|
||||||
dest = src.with_name(f"{src.stem}_warmprint{src.suffix}")
|
dest = src.with_name(f"{src.stem}_warmprint{src.suffix}")
|
||||||
|
|
||||||
im = Image.open(src)
|
im = Image.open(src)
|
||||||
@@ -112,8 +103,27 @@ def main() -> int:
|
|||||||
out_im.save(dest, "JPEG", **save_kwargs)
|
out_im.save(dest, "JPEG", **save_kwargs)
|
||||||
else:
|
else:
|
||||||
out_im.save(dest, "PNG", **save_kwargs)
|
out_im.save(dest, "PNG", **save_kwargs)
|
||||||
|
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)
|
print(dest)
|
||||||
return 0
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in new issue
Block a user