@@ -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 ( )