Initial Blockminers printer

This commit is contained in:
ajp_anton
2026-05-30 16:54:49 +00:00
commit 47748f3897
261 changed files with 3992 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Blockminers card rendering and print layout tools."""
+120
View File
@@ -0,0 +1,120 @@
import json
import re
import struct
from functools import lru_cache
from io import BytesIO
from pathlib import Path
from PIL import Image, UnidentifiedImageError
from .settings import DATA_DIR
def load_json(path: Path) -> dict:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def load_project_version() -> str:
project_file = DATA_DIR / "project.json"
if not project_file.exists():
return ""
return str(load_json(project_file).get("projectVersion", ""))
def find_art_for_json(json_path: Path) -> Path | None:
base = json_path.with_suffix("")
for ext in (".png", ".jpg", ".jpeg"):
candidate = base.with_suffix(ext)
if candidate.exists():
return candidate
return None
@lru_cache(maxsize=256)
def _load_rgb_cached(path: str, mtime_ns: int, size: int) -> Image.Image:
try:
return Image.open(path).convert("RGBA")
except UnidentifiedImageError:
cleaned = strip_png_ancillary_chunks(Path(path))
if cleaned is None:
raise
return Image.open(BytesIO(cleaned)).convert("RGBA")
def load_rgb(path: Path) -> Image.Image:
stat = path.stat()
return _load_rgb_cached(str(path), stat.st_mtime_ns, stat.st_size).copy()
def save_temp_png(img: Image.Image, path: Path, dpi: int) -> None:
img.save(path, dpi=(dpi, dpi), compress_level=1, optimize=False)
def strip_png_ancillary_chunks(path: Path) -> bytes | None:
"""Return a PNG with nonessential chunks removed.
A few source PNG templates carry metadata chunks that Pillow refuses to
parse, while the pixel data itself is valid. Keeping critical chunks gives
us a deterministic source-data repair without touching the copied assets.
"""
data = path.read_bytes()
signature = b"\x89PNG\r\n\x1a\n"
if not data.startswith(signature):
return None
keep = {b"IHDR", b"PLTE", b"IDAT", b"IEND", b"tRNS"}
out = bytearray(signature)
pos = len(signature)
try:
while pos + 12 <= len(data):
size = struct.unpack(">I", data[pos : pos + 4])[0]
chunk_type = data[pos + 4 : pos + 8]
chunk_end = pos + 8 + size + 4
if chunk_end > len(data):
return None
if chunk_type in keep:
out.extend(data[pos:chunk_end])
pos = chunk_end
if chunk_type == b"IEND":
return bytes(out)
except struct.error:
return None
return None
def cover_resize(img: Image.Image, size: tuple[int, int]) -> Image.Image:
target_w, target_h = size
src = img.convert("RGBA")
ratio = max(target_w / src.width, target_h / src.height)
new_size = (round(src.width * ratio), round(src.height * ratio))
resized = src.resize(new_size, Image.Resampling.LANCZOS)
left = max(0, (resized.width - target_w) // 2)
top = max(0, (resized.height - target_h) // 2)
return resized.crop((left, top, left + target_w, top + target_h))
def render_work_scale(meta: dict, target_size: tuple[int, int]) -> float:
sx = target_size[0] / float(meta["cardWidth"])
sy = target_size[1] / float(meta["cardHeight"])
return max(2.0, sx, sy)
def meta_unit_scale(meta: dict) -> float:
return float(meta.get("cardWidth", 825)) / 825.0
def hex_color(value: str | None, fallback: str = "#ffffff") -> tuple[int, int, int]:
value = value or fallback
value = value.strip()
if value.startswith("#"):
value = value[1:]
if len(value) != 6:
return hex_color(fallback)
return tuple(int(value[i : i + 2], 16) for i in (0, 2, 4))
def safe_name(value: str) -> str:
value = re.sub(r'[\\/:*?"<>|]', "", value)
value = re.sub(r"\s+", "_", value.strip())
return value or "untitled"
+65
View File
@@ -0,0 +1,65 @@
import math
from pathlib import Path
from .assets import load_json, load_rgb
from .settings import BOARD_DECK, DECKS_DIR, BuildConfig
def board_target_mm_for_mode(
board_path: Path,
board_mode: str,
board_custom_mm: tuple[float, float] | None = None,
) -> tuple[float, float]:
if board_custom_mm is not None:
return board_custom_mm
ratio = board_aspect_ratio(board_path)
if board_mode == "a4-fit":
max_w, max_h = 210.0, 297.0
if ratio > max_w / max_h:
return max_w, max_w / ratio
return max_h * ratio, max_h
if board_mode == "a5-fit":
max_w, max_h = 148.0, 210.0
if ratio > max_w / max_h:
return max_w, max_w / ratio
return max_h * ratio, max_h
# Default: preserve aspect ratio while using approximately A5 area,
# capped to fit inside A4.
a5_area = 148.0 * 210.0
width = math.sqrt(a5_area * ratio)
height = math.sqrt(a5_area / ratio)
if width > 210.0 or height > 297.0:
scale = min(210.0 / width, 297.0 / height)
width *= scale
height *= scale
return width, height
def board_aspect_ratio(board_path: Path) -> float:
deck_json = board_path.parent / "deck.json"
if deck_json.exists():
meta = load_json(deck_json)
if meta.get("cardWidth") and meta.get("cardHeight"):
return float(meta["cardWidth"]) / float(meta["cardHeight"])
with load_rgb(board_path) as img:
return img.width / img.height
def board_target_mm(board_path: Path, config: BuildConfig) -> tuple[float, float]:
return board_target_mm_for_mode(board_path, config.board_mode, config.board_custom_mm)
def board_mode_fits_paper(board_mode: str, paper_mm: tuple[float, float], margin_mm: float) -> bool:
usable_w = paper_mm[0] - 2 * margin_mm
usable_h = paper_mm[1] - 2 * margin_mm
board_paths = sorted((DECKS_DIR / BOARD_DECK).glob("*-jumbo-front.png"))
if not board_paths:
return True
for board_path in board_paths:
board_w, board_h = board_target_mm_for_mode(board_path, board_mode)
fits_upright = board_w <= usable_w and board_h <= usable_h
fits_rotated = board_h <= usable_w and board_w <= usable_h
if not (fits_upright or fits_rotated):
return False
return True
+667
View File
@@ -0,0 +1,667 @@
import shutil
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw
from .assets import cover_resize, find_art_for_json, hex_color, load_json, load_rgb, meta_unit_scale, render_work_scale, safe_name, save_temp_png
from .boards import board_target_mm
from .settings import (
BASE_CARD_SIZE,
BASEMENT_SINGLE_STARTER_COLOR,
BOARD_DECK,
CARD_DECKS,
CHARACTER_DECK,
CHARACTER_EDGE_REPLACE_PX,
CHECK_COLORS,
DECKS_DIR,
FIXED_CROP,
OFFICE_SINGLE_STARTER_COLOR,
PLAYER_COLORS,
RENDER_DIR,
SIDE_EDGE_REPLACE_BANDS,
STARTER_DECKS,
TOP_BOTTOM_EDGE_REPLACE_PX,
BuildConfig,
PrintItem,
mm_to_px,
worker_count,
)
from .text import (
FONTS,
TEXT_INDENT_CHARS,
draw_body_and_flavour_text,
draw_text_box,
font_size_y,
format_card_text,
format_flavour_text,
standard_text_layout,
text_line_height,
th,
title_font_for_target_width,
title_width_limit,
transformed_text_area,
tw,
tx,
ty,
)
def draw_polygon(draw: ImageDraw.ImageDraw, points, fill: tuple[int, int, int]) -> None:
draw.polygon(list(points), fill=fill)
def draw_banner_shape(image: Image.Image, meta: dict, y_offset: int, fill: tuple[int, int, int]) -> None:
draw = ImageDraw.Draw(image)
scale = meta_unit_scale(meta)
x = int(meta["checkOffsetX"])
y = int(meta["checkOffsetY"]) + y_offset
w = int(meta["checkWidth"])
h = int(meta["checkHeight"])
points = [(x, y), (x * 2 + w - round(30 * scale), y), (x + w, y + h), (x, y + h)]
draw_polygon(draw, points, fill)
def draw_banner_text(
image: Image.Image,
meta: dict,
layout: dict,
text: str,
y_offset: int,
color: tuple[int, int, int],
) -> None:
scale = meta_unit_scale(meta)
x = int(meta["checkOffsetX"])
y = int(meta["checkOffsetY"]) + y_offset
w = int(meta["checkWidth"])
h = int(meta["checkHeight"])
font = FONTS.title(font_size_y(layout, int(meta.get("titleFontSize") or meta.get("fontSize") or 32)))
draw_text_box(image, text, (tx(layout, x + round(50 * scale)), ty(layout, y), tw(layout, w), th(layout, h)), font, color, 0.0, 0.5, paragraph_margin=0)
def draw_banner(
image: Image.Image,
meta: dict,
text: str,
y_offset: int,
fill: tuple[int, int, int],
text_color: tuple[int, int, int] | None = None,
draw_text: bool = True,
) -> None:
draw_banner_shape(image, meta, y_offset, fill)
if not draw_text:
return
color = text_color or hex_color(meta.get("foregroundColor"), "#ffffff")
font = FONTS.title(int(meta.get("titleFontSize") or meta.get("fontSize") or 32))
scale = meta_unit_scale(meta)
x = int(meta["checkOffsetX"])
y = int(meta["checkOffsetY"]) + y_offset
w = int(meta["checkWidth"])
h = int(meta["checkHeight"])
draw_text_box(image, text, (x + round(50 * scale), y, w, h), font, color, 0.0, 0.5, paragraph_margin=0)
def draw_starter_banner(image: Image.Image, meta: dict, y_offset: int, color: str, draw_text: bool = True) -> None:
draw = ImageDraw.Draw(image)
scale = meta_unit_scale(meta)
x = int(meta["checkOffsetX"])
y = int(meta["checkOffsetY"]) + y_offset
w = int(meta["checkWidth"])
h = int(meta["checkHeight"])
black = (0, 0, 0)
banner = hex_color(color)
draw_banner(image, meta, "STARTER", y_offset, black, text_color=(255, 255, 255), draw_text=draw_text)
draw_polygon(draw, [(x * 2 + w - round(32 * scale), y), (x * 2 + w - round(26 * scale), y), (x + w, y + h), (x + w - round(6 * scale), y + h)], banner)
draw_polygon(draw, [(x * 2 + w - round(50 * scale), y), (x * 2 + w - round(38 * scale), y), (x + w - round(12 * scale), y + h), (x + w - round(24 * scale), y + h)], banner)
draw_polygon(draw, [(x, y), (x * 2 + w - round(30 * scale), y), (x * 2 + w - round(30 * scale), y + round(4 * scale)), (x, y + round(4 * scale))], banner)
if not draw_text:
return
font = FONTS.title(int(meta.get("titleFontSize") or meta.get("fontSize") or 32))
draw_text_box(image, "STARTER", (x + round(50 * scale), y, w, h), font, (255, 255, 255), 0.0, 0.5, paragraph_margin=0)
def render_card_image(
deck_dir: Path,
meta: dict,
card: dict,
front_template: Image.Image,
starter_color: str | None,
target_size: tuple[int, int],
crop_edges_before_text: bool = False,
) -> Image.Image:
layout = standard_text_layout(meta, target_size, crop_edges_before_text)
source_canvas = Image.new("RGB", (int(meta["cardWidth"]), int(meta["cardHeight"])), (0, 0, 0)).convert("RGBA")
banner_texts: list[tuple[str, int, tuple[int, int, int]]] = []
art_path = find_art_for_json(Path(card["_json_path"]))
if art_path is not None:
art = cover_resize(load_rgb(art_path), (int(meta["artWidth"]), int(meta["artHeight"])))
source_canvas.alpha_composite(art, (int(meta["artOffsetX"]), int(meta["artOffsetY"])))
check_offset = 0
if starter_color is not None:
draw_starter_banner(source_canvas, meta, check_offset, starter_color, draw_text=False)
banner_texts.append(("STARTER", check_offset, (255, 255, 255)))
check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
if card.get("encounterThreshold") is not None:
draw_banner(source_canvas, meta, f"Threat {card['encounterThreshold']}", check_offset, hex_color("#ead500"), draw_text=False)
banner_texts.append((f"Threat {card['encounterThreshold']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff")))
check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
if card.get("checkLevel") is not None and card.get("checkType") is not None:
check_color = hex_color(CHECK_COLORS.get(str(card["checkType"]), "#ffffff"))
draw_banner(
source_canvas,
meta,
f"{card['checkType']} {card['checkLevel']}",
check_offset,
check_color,
draw_text=False,
)
banner_texts.append((f"{card['checkType']} {card['checkLevel']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff")))
check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
if card.get("type") == "VIRUS":
draw_banner(source_canvas, meta, "VIRUS", check_offset, (255, 255, 255), text_color=hex_color(meta.get("foregroundColor"), "#000000"), draw_text=False)
banner_texts.append(("VIRUS", check_offset, hex_color(meta.get("foregroundColor"), "#000000")))
source_canvas.alpha_composite(front_template.convert("RGBA"), (0, 0))
if crop_edges_before_text:
canvas = replace_standard_card_edges(fixed_crop_card(source_canvas)).convert("RGBA")
else:
canvas = source_canvas
canvas = canvas.convert("RGB").resize(target_size, Image.Resampling.LANCZOS).convert("RGBA")
for banner_text, y_offset, text_color in banner_texts:
draw_banner_text(canvas, meta, layout, banner_text, y_offset, text_color)
title = card.get("title")
if title:
max_title_width = tw(layout, title_width_limit(deck_dir, meta))
font = title_font_for_target_width(str(title), meta, layout["sy"], max_title_width)
title_color = hex_color(meta.get("titleColor"), "#000000")
title_y = ty(layout, int(meta["titleOffset"]))
title_box_h = th(layout, 16)
draw_text_box(canvas, title, (0, title_y, target_size[0], title_box_h), font, title_color, 0.5, 0.5, 0, 0)
fg = hex_color(meta.get("foregroundColor"), "#ffffff")
text_left = int(meta["textPadding"])
text_right = int(meta["cardWidth"]) - int(meta["textPadding"])
text_x = tx(layout, text_left)
text_y, text_bottom_y, flavour_bottom_gap = transformed_text_area(deck_dir, meta, layout)
text_w = max(1, tx(layout, text_right) - text_x)
text_h = max(1, text_bottom_y - text_y)
flavour_text = format_flavour_text(card.get("flavourText"))
body_text = format_card_text(card.get("text"))
if body_text or flavour_text:
body_font = FONTS.text(font_size_y(layout, int(meta.get("fontSize") or 32)))
flavour_font = FONTS.text(font_size_y(layout, int(meta.get("flavourTextFontSize") or 24)))
draw_body_and_flavour_text(
canvas,
body_text,
flavour_text,
(text_x, text_y, text_w, text_h),
body_font,
flavour_font,
fg,
th(layout, 10),
indent_chars=TEXT_INDENT_CHARS,
flavour_bottom_gap=flavour_bottom_gap,
)
old_footer_font = FONTS.text(font_size_y(layout, 16))
small = FONTS.text(font_size_y(layout, 16 * 0.9))
footer_top_compensation = round((text_line_height(old_footer_font) - text_line_height(small)) / 2)
footer_y = ty(layout, int(meta["cardHeight"]) - 95) - footer_top_compensation
if config_project_version := card.get("_project_version"):
draw_text_box(
canvas,
str(config_project_version),
(
tx(layout, int(meta["cardWidth"]) - 252),
footer_y,
tw(layout, 150),
th(layout, 30),
),
small,
(255, 255, 255),
1.0,
0.5,
0,
)
if card.get("credit"):
draw_text_box(
canvas,
str(card["credit"]),
(
tx(layout, 100),
footer_y,
tw(layout, 500),
th(layout, 30),
),
small,
(255, 255, 255),
0.0,
0.5,
0,
)
return canvas.convert("RGB")
def smart_trim(img: Image.Image) -> Image.Image:
rgb = img.convert("RGB")
diff = ImageChops.difference(rgb, Image.new("RGB", rgb.size, (0, 0, 0)))
bbox = diff.getbbox()
if bbox is None:
return rgb
rgb = rgb.crop(bbox)
def black_fraction_row(image: Image.Image, y: int, x0: int = 0, x1: int | None = None) -> float:
x1 = image.width if x1 is None else x1
if x1 <= x0:
return 0.0
pixels = image.crop((x0, y, x1, y + 1)).getdata()
return sum(1 for p in pixels if p == (0, 0, 0)) / (x1 - x0)
def black_fraction_col(image: Image.Image, x: int, y0: int = 0, y1: int | None = None) -> float:
y1 = image.height if y1 is None else y1
if y1 <= y0:
return 0.0
pixels = image.crop((x, y0, x + 1, y1)).getdata()
return sum(1 for p in pixels if p == (0, 0, 0)) / (y1 - y0)
changed = True
while changed and rgb.width > 1 and rgb.height > 1:
changed = False
if black_fraction_row(rgb, 0) >= 0.9:
rgb = rgb.crop((0, 1, rgb.width, rgb.height))
changed = True
if rgb.height > 1 and black_fraction_row(rgb, rgb.height - 1) >= 0.9:
rgb = rgb.crop((0, 0, rgb.width, rgb.height - 1))
changed = True
changed = True
while changed and rgb.width > 1 and rgb.height > 1:
changed = False
y0 = int(rgb.height * 0.05)
y1 = rgb.height - y0
if black_fraction_col(rgb, 0, y0, y1) >= 0.05:
rgb = rgb.crop((1, 0, rgb.width, rgb.height))
changed = True
if rgb.width > 1 and black_fraction_col(rgb, rgb.width - 1, y0, y1) >= 0.05:
rgb = rgb.crop((0, 0, rgb.width - 1, rgb.height))
changed = True
changed = True
while changed and rgb.width > 1 and rgb.height > 1:
changed = False
x0 = int(rgb.width * 0.05)
x1 = rgb.width - x0
if black_fraction_row(rgb, 0, x0, x1) >= 0.05:
rgb = rgb.crop((0, 1, rgb.width, rgb.height))
changed = True
if rgb.height > 1 and black_fraction_row(rgb, rgb.height - 1, x0, x1) >= 0.05:
rgb = rgb.crop((0, 0, rgb.width, rgb.height - 1))
changed = True
if rgb.height > 1:
x0 = int(rgb.width * 0.05)
x1 = max(x0 + 1, rgb.width - x0)
prev = row_brightness(rgb, 0, x0, x1)
cut = 0
for y in range(1, rgb.height):
current = row_brightness(rgb, y, x0, x1)
if current >= prev:
cut = y
prev = current
else:
break
if cut:
rgb = rgb.crop((0, cut, rgb.width, rgb.height))
return rgb
def row_brightness(img: Image.Image, y: int, x0: int, x1: int) -> float:
pixels = img.crop((x0, y, x1, y + 1)).getdata()
count = max(1, x1 - x0)
return sum(0.299 * r + 0.587 * g + 0.114 * b for r, g, b in pixels) / count
def scaled_fixed_crop_box(img: Image.Image) -> tuple[int, int, int, int]:
sx = img.width / BASE_CARD_SIZE[0]
sy = img.height / BASE_CARD_SIZE[1]
left = round(FIXED_CROP[0] * sx)
top = round(FIXED_CROP[1] * sy)
right = img.width - round(FIXED_CROP[2] * sx)
bottom = img.height - round(FIXED_CROP[3] * sy)
return (left, top, right, bottom)
def fixed_crop_card(img: Image.Image) -> Image.Image:
return img.convert("RGB").crop(scaled_fixed_crop_box(img))
def cropped_card_scale(img: Image.Image) -> float:
cropped_base_w = BASE_CARD_SIZE[0] - FIXED_CROP[0] - FIXED_CROP[2]
cropped_base_h = BASE_CARD_SIZE[1] - FIXED_CROP[1] - FIXED_CROP[3]
return min(img.width / cropped_base_w, img.height / cropped_base_h)
def full_card_scale(img: Image.Image) -> float:
return min(img.width / BASE_CARD_SIZE[0], img.height / BASE_CARD_SIZE[1])
def scaled_card_px(img: Image.Image, px: int) -> int:
return max(1, round(px * cropped_card_scale(img)))
def scaled_full_card_px(img: Image.Image, px: int) -> int:
return max(1, round(px * full_card_scale(img)))
def replace_side_edge_band(out: Image.Image, y0: int, y1: int, px: int) -> None:
if y1 <= y0 or px <= 0:
return
px = min(px, out.width // 4)
if px <= 0:
return
out.paste(out.crop((px, y0, px + 1, y1)).resize((px, y1 - y0)), (0, y0))
out.paste(out.crop((out.width - px - 1, y0, out.width - px, y1)).resize((px, y1 - y0)), (out.width - px, y0))
def replace_standard_card_edges(img: Image.Image) -> Image.Image:
out = img.copy()
for start, end, band_px in SIDE_EDGE_REPLACE_BANDS:
y0 = round(out.height * start)
y1 = round(out.height * end)
replace_side_edge_band(out, y0, y1, scaled_card_px(out, band_px))
px = min(scaled_card_px(out, TOP_BOTTOM_EDGE_REPLACE_PX), out.height // 4)
if px > 0:
out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0))
out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px))
return out
def replace_uniform_edges(img: Image.Image, px: int) -> Image.Image:
px = min(px, img.width // 4, img.height // 4)
if px <= 0:
return img
out = img.copy()
out.paste(out.crop((px, 0, px + 1, out.height)).resize((px, out.height)), (0, 0))
out.paste(out.crop((out.width - px - 1, 0, out.width - px, out.height)).resize((px, out.height)), (out.width - px, 0))
out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0))
out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px))
return out
def replace_character_edges(img: Image.Image) -> Image.Image:
px = scaled_full_card_px(img, CHARACTER_EDGE_REPLACE_PX)
return replace_uniform_edges(img, px)
def draw_corner_triangles(img: Image.Image, size: int, color: tuple[int, int, int]) -> Image.Image:
size = min(size, img.width // 4, img.height // 4)
if size <= 0:
return img
out = img.copy()
draw = ImageDraw.Draw(out)
draw.polygon([(0, 0), (size, 0), (0, size)], fill=color)
draw.polygon([(out.width - 1, 0), (out.width - 1 - size, 0), (out.width - 1, size)], fill=color)
draw.polygon([(0, out.height - 1), (size, out.height - 1), (0, out.height - 1 - size)], fill=color)
draw.polygon(
[(out.width - 1, out.height - 1), (out.width - 1 - size, out.height - 1), (out.width - 1, out.height - 1 - size)],
fill=color,
)
return out
def character_bleed_crop(img: Image.Image, target_size: tuple[int, int], bleed_px: int) -> Image.Image:
crop_box = scaled_fixed_crop_box(img)
crop_w = crop_box[2] - crop_box[0]
crop_h = crop_box[3] - crop_box[1]
extra_x = round(bleed_px * crop_w / target_size[0])
extra_y = round(bleed_px * crop_h / target_size[1])
expanded = (
max(0, crop_box[0] - extra_x),
max(0, crop_box[1] - extra_y),
min(img.width, crop_box[2] + extra_x),
min(img.height, crop_box[3] + extra_y),
)
return img.convert("RGB").crop(expanded).resize(
(target_size[0] + 2 * bleed_px, target_size[1] + 2 * bleed_px),
Image.Resampling.LANCZOS,
)
def apply_print_adjustments(
img: Image.Image,
config: BuildConfig,
target_size: tuple[int, int],
crop_mode: str = "board",
) -> Image.Image:
out = img.convert("RGB")
if crop_mode == "character_card":
if config.crop_black_borders:
out = fixed_crop_card(out)
else:
out = replace_character_edges(out)
elif crop_mode == "rendered_standard_card":
pass
elif config.crop_black_borders:
if crop_mode == "pre_cropped_standard_card":
pass
elif crop_mode == "standard_card":
out = fixed_crop_card(out)
out = replace_standard_card_edges(out)
else:
out = smart_trim(out)
out = out.resize(target_size, Image.Resampling.LANCZOS)
if crop_mode in {"rendered_standard_card", "standard_card", "pre_cropped_standard_card", "character_card"}:
triangle_color = (0, 0, 0) if config.crop_black_borders else (255, 255, 255)
out = draw_corner_triangles(out, mm_to_px(config.bleed_mm, config.dpi), triangle_color)
if crop_mode == "board" and config.clean_edge_mm > 0:
out = clean_edges(out, mm_to_px(config.clean_edge_mm, config.dpi))
if config.gamma_enabled:
out = apply_gamma(out, config.gamma, config.black_point)
return out
def apply_gamma(img: Image.Image, gamma: float, black_point: float) -> Image.Image:
scale = max(1e-9, 1.0 - black_point)
lut = []
for value in range(256):
normalized = max(0.0, ((value / 255.0) - black_point) / scale)
lut.append(round((normalized ** gamma) * 255))
return img.point(lut * 3)
def clean_edges(img: Image.Image, px: int) -> Image.Image:
if px <= 0:
return img
px = min(px, img.width // 4, img.height // 4)
if px <= 0:
return img
out = img.copy()
out.paste(out.crop((px, 0, px + 1, out.height)).resize((px, out.height)), (0, 0))
out.paste(out.crop((out.width - px - 1, 0, out.width - px, out.height)).resize((px, out.height)), (out.width - px, 0))
out.paste(out.crop((0, px, out.width, px + 1)).resize((out.width, px)), (0, 0))
out.paste(out.crop((0, out.height - px - 1, out.width, out.height - px)).resize((out.width, px)), (0, out.height - px))
return out
def make_bleed(img: Image.Image, bleed_px: int) -> Image.Image:
if bleed_px <= 0:
return img
w, h = img.size
out = Image.new("RGB", (w + 2 * bleed_px, h + 2 * bleed_px))
out.paste(img, (bleed_px, bleed_px))
out.paste(img.crop((0, 0, 1, h)).resize((bleed_px, h)), (0, bleed_px))
out.paste(img.crop((w - 1, 0, w, h)).resize((bleed_px, h)), (bleed_px + w, bleed_px))
out.paste(img.crop((0, 0, w, 1)).resize((w, bleed_px)), (bleed_px, 0))
out.paste(img.crop((0, h - 1, w, h)).resize((w, bleed_px)), (bleed_px, bleed_px + h))
out.paste(img.crop((0, 0, 1, 1)).resize((bleed_px, bleed_px)), (0, 0))
out.paste(img.crop((w - 1, 0, w, 1)).resize((bleed_px, bleed_px)), (bleed_px + w, 0))
out.paste(img.crop((0, h - 1, 1, h)).resize((bleed_px, bleed_px)), (0, bleed_px + h))
out.paste(img.crop((w - 1, h - 1, w, h)).resize((bleed_px, bleed_px)), (bleed_px + w, bleed_px + h))
return out
def render_standard_front_job(job: dict) -> Path:
deck_dir = job["deck_dir"]
meta = job["meta"]
card = dict(job["card"])
config = job["config"]
card_px = job["card_px"]
starter_color = job["starter_color"]
out = job["out"]
front_template = load_rgb(deck_dir / "front.png")
image = render_card_image(
deck_dir,
meta,
card,
front_template,
starter_color,
card_px,
crop_edges_before_text=config.crop_black_borders,
)
adjusted = apply_print_adjustments(image, config, card_px, "rendered_standard_card")
out.parent.mkdir(parents=True, exist_ok=True)
save_temp_png(adjusted, out, config.dpi)
return out
def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem]]:
if RENDER_DIR.exists():
shutil.rmtree(RENDER_DIR)
RENDER_DIR.mkdir(parents=True, exist_ok=True)
card_px = (mm_to_px(config.card_mm[0], config.dpi), mm_to_px(config.card_mm[1], config.dpi))
card_items: list[PrintItem] = []
board_items: list[PrintItem] = []
for group, deck_names in CARD_DECKS.items():
for deck_name in deck_names:
deck_dir = DECKS_DIR / deck_name
meta = load_json(deck_dir / "deck.json")
back_template = load_rgb(deck_dir / "back.png").convert("RGB")
work_scale = render_work_scale(meta, card_px)
deck_cards = sorted(p for p in deck_dir.glob("*.json") if p.name != "deck.json")
rendered_fronts: list[Path] = []
front_jobs: list[dict] = []
for card_json in deck_cards:
card = load_json(card_json)
card["_json_path"] = str(card_json)
card["_project_version"] = config.project_version
copies = int(card.get("copies", 1))
if deck_name == "encounterCards" and card_json.stem == "rentRaised":
copies = config.rent_raised_count
is_starter = bool(card.get("isStarter"))
colors = starter_colors_for_deck(group, deck_name, meta, config) if is_starter else [None]
rendered_by_color: dict[str | None, Path] = {}
for color_index, starter_color in enumerate(colors):
out = rendered_by_color.get(starter_color)
if out is None:
out = RENDER_DIR / "cards" / group / "fronts" / f"{deck_name}_{safe_name(card.get('title', card_json.stem))}_{color_index}.png"
rendered_by_color[starter_color] = out
front_jobs.append(
{
"deck_dir": deck_dir,
"meta": meta,
"card": card,
"config": config,
"card_px": card_px,
"starter_color": starter_color,
"out": out,
}
)
for _ in range(copies):
rendered_fronts.append(out)
label = card.get("title", card_json.stem)
card_items.append(PrintItem(out, group, label, "front", is_starter, label.lower()))
with ProcessPoolExecutor(max_workers=worker_count(len(front_jobs))) as executor:
list(executor.map(render_standard_front_job, front_jobs))
back_work = back_template.resize(
(round(back_template.width * work_scale), round(back_template.height * work_scale)),
Image.Resampling.LANCZOS,
)
adjusted_back = apply_print_adjustments(back_work, config, card_px, "standard_card")
back_out = RENDER_DIR / "cards" / group / "backs" / f"{deck_name}_back.png"
back_out.parent.mkdir(parents=True, exist_ok=True)
save_temp_png(adjusted_back, back_out, config.dpi)
for i in range(len(rendered_fronts)):
card_items.append(PrintItem(back_out, group, f"{deck_name} back {i + 1}", "back", False, deck_name.lower()))
char_dir = DECKS_DIR / CHARACTER_DECK
char_meta = load_json(char_dir / "deck.json")
char_layout = standard_text_layout(char_meta, card_px, config.crop_black_borders)
for card_json in sorted(p for p in char_dir.glob("*.json") if p.name != "deck.json"):
art = find_art_for_json(card_json)
if art is None:
continue
canvas = Image.new("RGB", (int(char_meta["cardWidth"]), int(char_meta["cardHeight"])), (0, 0, 0)).convert("RGBA")
face = cover_resize(load_rgb(art), (int(char_meta["artWidth"]), int(char_meta["artHeight"])))
canvas.alpha_composite(face, (int(char_meta["artOffsetX"]), int(char_meta["artOffsetY"])))
character_source = canvas.convert("RGB")
adjusted = apply_print_adjustments(character_source, config, card_px, "character_card")
if config.project_version:
small = FONTS.text(font_size_y(char_layout, 16))
draw_text_box(
adjusted,
config.project_version,
(
tx(char_layout, int(char_meta["cardWidth"]) - 252),
ty(char_layout, int(char_meta["cardHeight"]) - 99),
tw(char_layout, 150),
th(char_layout, 30),
),
small,
(255, 255, 255),
1.0,
0.5,
0,
)
out = RENDER_DIR / "cards" / "characters" / f"{safe_name(card_json.stem)}.png"
out.parent.mkdir(parents=True, exist_ok=True)
save_temp_png(adjusted, out, config.dpi)
bleed_out = None
if config.crop_black_borders and config.bleed_mm > 0:
bleed_px = mm_to_px(config.bleed_mm, config.dpi)
bleed_img = character_bleed_crop(character_source, card_px, bleed_px)
if config.gamma_enabled:
bleed_img = apply_gamma(bleed_img, config.gamma, config.black_point)
bleed_out = RENDER_DIR / "cards" / "characters" / "bleed" / f"{safe_name(card_json.stem)}.png"
bleed_out.parent.mkdir(parents=True, exist_ok=True)
save_temp_png(bleed_img, bleed_out, config.dpi)
# Character edges are unique and not cut-neighbor compatible, so each
# duplicate is treated as its own print group.
card_items.append(PrintItem(out, f"character:{card_json.stem}:1", card_json.stem, "front", False, card_json.stem.lower(), bleed_out))
card_items.append(PrintItem(out, f"character:{card_json.stem}:2", card_json.stem, "back", False, card_json.stem.lower(), bleed_out))
board_dir = DECKS_DIR / BOARD_DECK
for board_path in sorted(board_dir.glob("*-jumbo-front.png")):
target_mm = board_target_mm(board_path, config)
target_px = (mm_to_px(target_mm[0], config.dpi), mm_to_px(target_mm[1], config.dpi))
image = load_rgb(board_path).convert("RGB")
adjusted = apply_print_adjustments(image, config, target_px, "board")
out = RENDER_DIR / "boards" / board_path.name
out.parent.mkdir(parents=True, exist_ok=True)
save_temp_png(adjusted, out, config.dpi)
board_items.append(PrintItem(out, "board", board_path.stem, "board"))
return card_items, board_items
def starter_colors_for_deck(group: str, deck_name: str, meta: dict, config: BuildConfig) -> list[str | None]:
colors = list(meta.get("starterBannerColors") or PLAYER_COLORS)
if deck_name not in STARTER_DECKS:
return [None]
if config.starter_unique_colors:
return colors
single = BASEMENT_SINGLE_STARTER_COLOR if group == "basement" else OFFICE_SINGLE_STARTER_COLOR
return [single for _ in colors]
+310
View File
@@ -0,0 +1,310 @@
import argparse
import re
import shutil
import textwrap
from pathlib import Path
from .assets import load_project_version
from .boards import board_mode_fits_paper
from .cards import render_sources
from .layout import grid_capacity, render_board_pages, render_card_pages
from .pdf import write_pdf
from .settings import (
CARD_SIZE_OPTIONS_MM,
PAGES_DIR,
PAPER_SIZES_MM,
RENDER_DIR,
ROOT,
TEMP_DIR,
BuildConfig,
mm_to_px,
)
def should_delete_temp(config: BuildConfig) -> bool:
if config.render_only:
return False
if config.delete_temp is not None:
return config.delete_temp
return prompt_bool("Delete temporary rendered files", True)
def build_printable(config: BuildConfig) -> Path | None:
print("Rendering source cards...")
card_items, board_items = render_sources(config)
print(f"Rendered {len(card_items)} card-side images and {len(board_items)} boards.")
if config.render_only:
print(f"Render-only mode: wrote source images under {RENDER_DIR.relative_to(ROOT)} and skipped page/PDF creation.")
return None
if PAGES_DIR.exists():
shutil.rmtree(PAGES_DIR)
PAGES_DIR.mkdir(parents=True, exist_ok=True)
cols, rows = grid_capacity(config)
print(f"Card grid: {cols} columns x {rows} rows on {config.paper_name.upper()} ({cols * rows} images/page)")
next_page = render_card_pages(card_items, config, 1)
next_page = render_board_pages(board_items, config, next_page)
pdf = write_pdf(config)
if pdf:
print(f"Wrote {pdf.relative_to(ROOT)}")
if should_delete_temp(config):
shutil.rmtree(TEMP_DIR, ignore_errors=True)
print(f"Deleted {TEMP_DIR.relative_to(ROOT)}")
return pdf
def prompt_choice(label: str, options, default: str) -> str:
option_text = ", ".join(options)
value = input(f"{label} [{default}] ({option_text}): ").strip().lower()
if not value:
return default
if value in options:
return value
print(f"Unknown option '{value}', using {default}.")
return default
def prompt_int(label: str, default: int, allowed=None) -> int:
suffix = f" [{default}]"
if allowed:
suffix += " (" + ", ".join(str(x) for x in allowed) + ")"
value = input(f"{label}{suffix}: ").strip()
if not value:
return default
try:
parsed = int(value)
except ValueError:
print(f"Invalid number '{value}', using {default}.")
return default
if allowed and parsed not in allowed:
print(f"Unsupported value '{parsed}', using {default}.")
return default
return parsed
def prompt_positive_int(label: str, default: int) -> int:
value = input(f"{label} [{default}]: ").strip()
if not value:
return default
try:
parsed = int(value)
except ValueError:
print(f"Invalid number '{value}', using {default}.")
return default
if parsed <= 0:
print(f"Unsupported value '{parsed}', using {default}.")
return default
return parsed
def prompt_bool(label: str, default: bool) -> bool:
suffix = "Y/n" if default else "y/N"
value = input(f"{label} [{suffix}]: ").strip().lower()
if not value:
return default
return value.startswith("y")
def prompt_card_size(default: tuple[float, float]) -> tuple[float, float]:
options = list(CARD_SIZE_OPTIONS_MM)
choice = prompt_choice("Card size", options + ["custom"], "poker")
if choice == "custom":
raw = input("Custom card size in mm, width x height: ").strip().lower().replace(" ", "")
match = re.fullmatch(r"([0-9.]+)x([0-9.]+)", raw)
if match:
return float(match.group(1)), float(match.group(2))
print(f"Invalid custom size, using {default[0]} x {default[1]} mm.")
return default
return CARD_SIZE_OPTIONS_MM[choice]
def prompt_board_mode(paper_mm: tuple[float, float], margin_mm: float) -> tuple[str, tuple[float, float] | None]:
choices = ["a5-area", "a5-fit"]
if board_mode_fits_paper("a4-fit", paper_mm, margin_mm):
choices.append("a4-fit")
choices.append("custom")
choice = prompt_choice("Board size", choices, "a5-area")
if choice != "custom":
return choice, None
raw = input("Custom board size in mm, width x height: ").strip().lower().replace(" ", "")
match = re.fullmatch(r"([0-9.]+)x([0-9.]+)", raw)
if match:
return "custom", (float(match.group(1)), float(match.group(2)))
print("Invalid custom board size, using a5-area.")
return "a5-area", None
def prompt_gamma() -> tuple[bool, float, float]:
choice = prompt_choice("Gamma correction", ["default", "none", "custom"], "default")
if choice == "none":
return False, 1.0, 0.0
if choice == "custom":
raw = input("Gamma exponent [0.7]: ").strip()
try:
gamma = float(raw) if raw else 0.7
except ValueError:
gamma = 0.7
return True, gamma, 0.02
return True, 0.7, 0.02
def prompt_output_pdf_name() -> str:
return input("Output PDF name [blockminers_printable.pdf]: ").strip()
def parse_card_size_arg(value: str) -> tuple[float, float]:
normalized = value.strip().lower().replace(" ", "")
if normalized in CARD_SIZE_OPTIONS_MM:
return CARD_SIZE_OPTIONS_MM[normalized]
match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)x([0-9]+(?:\.[0-9]+)?)", normalized)
if not match:
choices = ", ".join(sorted(CARD_SIZE_OPTIONS_MM))
raise argparse.ArgumentTypeError(f"expected one of {choices}, or WIDTHxHEIGHT in mm")
width = float(match.group(1))
height = float(match.group(2))
if width <= 0 or height <= 0:
raise argparse.ArgumentTypeError("custom card size must be positive")
return width, height
def default_config() -> BuildConfig:
return BuildConfig(
paper_name="a3",
paper_mm=PAPER_SIZES_MM["a3"],
dpi=300,
crop_black_borders=False,
card_mm=CARD_SIZE_OPTIONS_MM["poker"],
board_mode="a5-area",
board_custom_mm=None,
gamma_enabled=True,
gamma=0.7,
black_point=0.02,
project_version=load_project_version(),
starter_unique_colors=True,
rent_raised_count=3,
margin_mm=12.0,
bleed_mm=1.0,
clean_edge_mm=0.35,
output_pdf_name="",
delete_temp=True,
render_only=False,
)
def interactive_config() -> BuildConfig:
default = default_config()
paper = prompt_choice("Paper size", list(PAPER_SIZES_MM), default.paper_name)
dpi = prompt_int("DPI", default.dpi, [300, 600])
crop = prompt_bool("Crop black borders", default.crop_black_borders)
card_mm = prompt_card_size(default.card_mm)
board_mode, custom_board = prompt_board_mode(PAPER_SIZES_MM[paper], default.margin_mm)
unify_starter_colors = prompt_bool("Unify starter badge colors", not default.starter_unique_colors)
rent_raised_count = prompt_positive_int("Number of Rent raised", default.rent_raised_count)
gamma_enabled, gamma, black_point = prompt_gamma()
output_pdf_name = prompt_output_pdf_name()
return BuildConfig(
paper_name=paper,
paper_mm=PAPER_SIZES_MM[paper],
dpi=dpi,
crop_black_borders=crop,
card_mm=card_mm,
board_mode=board_mode,
board_custom_mm=custom_board,
gamma_enabled=gamma_enabled,
gamma=gamma,
black_point=black_point,
project_version=default.project_version,
starter_unique_colors=not unify_starter_colors,
rent_raised_count=rent_raised_count,
margin_mm=default.margin_mm,
bleed_mm=default.bleed_mm,
clean_edge_mm=default.clean_edge_mm,
output_pdf_name=output_pdf_name,
delete_temp=None,
render_only=default.render_only,
)
def parse_args(argv=None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Render Blockminers sources and create printable cut sheets.")
parser.add_argument("--yes", action="store_true", help="Use defaults without prompting.")
parser.add_argument("--dpi", type=int, choices=[300, 600], help="Override DPI.")
parser.add_argument("--paper", choices=sorted(PAPER_SIZES_MM), help="Override paper size.")
parser.add_argument("--card-size", metavar="SIZE", type=parse_card_size_arg, help="Override card size by name or WIDTHxHEIGHT in mm.")
parser.add_argument("--crop", action="store_true", help="Crop black borders.")
parser.add_argument("--no-gamma", action="store_true", help="Disable print brightening gamma.")
parser.add_argument("--render-only", action="store_true", help="Render source card/board PNGs and skip page/PDF creation.")
parser.add_argument("--output", help="Output PDF filename under print/. Adds .pdf if missing.")
parser.add_argument("--rent-raised", type=int, help="Number of Rent raised encounter cards to print.")
parser.add_argument("--keep-temp", action="store_true", help="Keep temporary rendered PNGs under print/temp/.")
parser.add_argument(
"--starter-colors",
choices=["separate", "same"],
help="Use five player colors for starter badges, or one category color repeated.",
)
return parser.parse_args(argv)
def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfig:
paper_name = args.paper or config.paper_name
dpi = args.dpi or config.dpi
delete_temp = False if args.keep_temp else config.delete_temp
rent_raised_count = config.rent_raised_count
if args.rent_raised is not None:
if args.rent_raised <= 0:
raise SystemExit("--rent-raised must be a positive non-zero integer.")
rent_raised_count = args.rent_raised
return BuildConfig(
paper_name=paper_name,
paper_mm=PAPER_SIZES_MM[paper_name],
dpi=dpi,
crop_black_borders=True if args.crop else config.crop_black_borders,
card_mm=args.card_size if args.card_size else config.card_mm,
board_mode=config.board_mode,
board_custom_mm=config.board_custom_mm,
gamma_enabled=False if args.no_gamma else config.gamma_enabled,
gamma=config.gamma,
black_point=config.black_point,
project_version=config.project_version,
starter_unique_colors=(args.starter_colors == "separate") if args.starter_colors else config.starter_unique_colors,
rent_raised_count=rent_raised_count,
margin_mm=config.margin_mm,
bleed_mm=config.bleed_mm,
clean_edge_mm=config.clean_edge_mm,
output_pdf_name=args.output if args.output is not None else config.output_pdf_name,
delete_temp=delete_temp,
render_only=args.render_only or config.render_only,
)
def main(argv=None) -> None:
args = parse_args(argv)
config = default_config() if args.yes else interactive_config()
config = apply_overrides(config, args)
print(
textwrap.dedent(
f"""
Build settings:
paper: {config.paper_name.upper()} {config.paper_mm[0]} x {config.paper_mm[1]} mm
dpi: {config.dpi}
card: {config.card_mm[0]} x {config.card_mm[1]} mm
board mode: {config.board_mode}
card version: {config.project_version or '<none>'}
starter badge colors: {'different player colors' if config.starter_unique_colors else 'same color per starter deck'}
Rent raised cards: {config.rent_raised_count}
bleed: {config.bleed_mm} mm ({mm_to_px(config.bleed_mm, config.dpi)} px at {config.dpi} DPI)
crop black borders: {'yes' if config.crop_black_borders else 'no'}
gamma: {'off' if not config.gamma_enabled else f'{config.gamma} with black point {config.black_point}'}
output: {'rendered PNGs only' if config.render_only else 'rendered PNGs, page PNGs, and PDF'}
pdf name: {(config.output_pdf_name.strip() or 'blockminers_printable.pdf') if not config.render_only else '<not written>'}
temp files: {'kept for render-only output' if config.render_only else 'ask after build' if config.delete_temp is None else 'deleted after build' if config.delete_temp else 'kept under print/temp'}
"""
).strip()
)
build_printable(config)
if __name__ == "__main__":
main()
+861
View File
@@ -0,0 +1,861 @@
import math
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, replace
from pathlib import Path
from PIL import Image, ImageDraw
from .assets import save_temp_png
from .cards import make_bleed
from .settings import BuildConfig, PAGES_DIR, PrintItem, ROOT, mm_to_px, worker_count
@dataclass
class CardPage:
items: list[PrintItem | None]
spaced: bool = False
extras: list[tuple[PrintItem, tuple[int, int, int, int], int]] | None = None
def grid_capacity(config: BuildConfig) -> tuple[int, int]:
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
card_w = mm_to_px(config.card_mm[0], config.dpi)
card_h = mm_to_px(config.card_mm[1], config.dpi)
oriented_w = card_h
oriented_h = card_w
cols = max(1, (page_w - 2 * margin) // oriented_w)
rows = max(1, (page_h - 2 * margin) // oriented_h)
return int(cols), int(rows)
def columns_to_pages(column_items: list[list[PrintItem]], cols_per_page: int, rows: int) -> list[list[PrintItem | None]]:
pages: list[list[PrintItem | None]] = []
for i in range(0, len(column_items), cols_per_page):
page_columns = column_items[i : i + cols_per_page]
page: list[PrintItem | None] = [None] * (cols_per_page * rows)
column_groups: dict[str, list[tuple[int, list[PrintItem]]]] = {}
group_order: list[str] = []
for col_index, column in enumerate(page_columns):
if not column:
continue
key = column[0].image_type
if key not in column_groups:
column_groups[key] = []
group_order.append(key)
column_groups[key].append((col_index, column))
for key in group_order:
columns = column_groups[key]
sorted_items = [item for _, column in columns for item in column]
row_major_slots = [
row_index * cols_per_page + col_index
for row_index in range(rows)
for col_index, column in columns
if row_index < len(column)
]
for slot, item in zip(row_major_slots, sorted_items):
page[slot] = item
pages.append(page)
return pages
def interleave_columns(*column_groups: list[list[PrintItem]]) -> list[list[PrintItem]]:
columns: list[list[PrintItem]] = []
max_len = max((len(group) for group in column_groups), default=0)
for index in range(max_len):
for group in column_groups:
if index < len(group):
columns.append(group[index])
return columns
def balanced_columns(items: list[PrintItem], column_count: int) -> list[list[PrintItem]]:
if column_count <= 0 or not items:
return []
columns: list[list[PrintItem]] = [[] for _ in range(column_count)]
for index, item in enumerate(items):
columns[index % len(columns)].append(item)
return columns
def column_height(column: list[PrintItem | None]) -> int:
for index in range(len(column) - 1, -1, -1):
if column[index] is not None:
return index + 1
return 0
def column_type(column: list[PrintItem | None]) -> str | None:
for item in column:
if item is not None:
return item.image_type
return None
def optimize_mixed_columns(columns: list[list[PrintItem | None]], rows: int) -> list[list[PrintItem | None]]:
columns = [list(column) for column in columns]
while True:
heights = [column_height(column) for column in columns]
if not heights:
return columns
current_max = max(heights)
best_move = None
best_height = current_max
for donor_index, donor in enumerate(columns):
donor_type = column_type(donor)
donor_height = heights[donor_index]
if donor_type is None or donor_height <= 1 or donor_height < current_max:
continue
moved_item = donor[donor_height - 1]
if moved_item is None:
continue
for receiver_index, receiver in enumerate(columns):
if receiver_index == donor_index:
continue
receiver_type = column_type(receiver)
receiver_height = heights[receiver_index]
if receiver_type is None or receiver_type == donor_type:
continue
insert_index = receiver_height + 1
if insert_index >= rows:
continue
candidate_heights = list(heights)
candidate_heights[donor_index] = donor_height - 1
candidate_heights[receiver_index] = insert_index + 1
candidate_max = max(candidate_heights)
if candidate_max < best_height:
best_height = candidate_max
best_move = (donor_index, receiver_index, donor_height - 1, insert_index, moved_item)
if best_move is None:
return columns
donor_index, receiver_index, donor_slot, receiver_slot, moved_item = best_move
columns[donor_index][donor_slot] = None
while len(columns[receiver_index]) <= receiver_slot:
columns[receiver_index].append(None)
columns[receiver_index][receiver_slot] = moved_item
def page_from_columns(columns: list[list[PrintItem | None]], cols_per_page: int, rows: int) -> list[PrintItem | None]:
page: list[PrintItem | None] = [None] * (cols_per_page * rows)
for col_index, column in enumerate(columns[:cols_per_page]):
for row_index, item in enumerate(column[:rows]):
if item is not None:
page[row_index * cols_per_page + col_index] = item
return page
def full_pages_then_remainder(items: list[PrintItem], cols: int, rows: int) -> tuple[list[CardPage], list[PrintItem]]:
per_page = cols * rows
pages: list[CardPage] = []
full_count = len(items) // per_page
for page_index in range(full_count):
start = page_index * per_page
pages.append(CardPage(list(items[start : start + per_page])))
return pages, items[full_count * per_page :]
def best_mixed_column_counts(front_count: int, back_count: int, cols: int, rows: int) -> tuple[int, int]:
best_score: tuple[int, int, int] | None = None
best_counts = (0, 0)
for front_cols in range(cols + 1):
back_cols = cols - front_cols
if front_count and front_cols == 0:
continue
if back_count and back_cols == 0:
continue
if not front_count and front_cols:
continue
if not back_count and back_cols:
continue
front_placed = min(front_count, front_cols * rows) if front_cols else 0
back_placed = min(back_count, back_cols * rows) if back_cols else 0
if front_placed + back_placed <= 0:
continue
front_h = math.ceil(front_placed / front_cols) if front_cols else 0
back_h = math.ceil(back_placed / back_cols) if back_cols else 0
used_cols = (front_cols if front_placed else 0) + (back_cols if back_placed else 0)
score = (front_placed + back_placed, -max(front_h, back_h), -used_cols)
if best_score is None or score > best_score:
best_score = score
best_counts = (front_cols if front_placed else 0, back_cols if back_placed else 0)
return best_counts
def pack_remainder_pages(front_items: list[PrintItem], back_items: list[PrintItem], cols: int, rows: int) -> list[CardPage]:
pages: list[CardPage] = []
fronts = list(front_items)
backs = list(back_items)
while fronts or backs:
front_cols, back_cols = best_mixed_column_counts(len(fronts), len(backs), cols, rows)
if front_cols + back_cols <= 0:
break
front_take = min(len(fronts), front_cols * rows)
back_take = min(len(backs), back_cols * rows)
page_columns = balanced_columns(fronts[:front_take], front_cols) + balanced_columns(backs[:back_take], back_cols)
page_columns = optimize_mixed_columns(page_columns, rows)
pages.append(CardPage(page_from_columns(page_columns, cols, rows)))
fronts = fronts[front_take:]
backs = backs[back_take:]
return pages
def pack_cropped_standard_group(group_items: list[PrintItem], cols: int, rows: int) -> list[CardPage]:
fronts = sorted((item for item in group_items if item.image_type != "back"), key=item_order_key)
backs = sorted((item for item in group_items if item.image_type == "back"), key=item_order_key)
front_pages, front_remainder = full_pages_then_remainder(fronts, cols, rows)
back_pages, back_remainder = full_pages_then_remainder(backs, cols, rows)
return front_pages + back_pages + pack_remainder_pages(front_remainder, back_remainder, cols, rows)
def is_character_item(item: PrintItem) -> bool:
return item.group.startswith("character:")
def character_edge_compatible(rotation: int, side: str) -> bool:
if rotation == 90:
edge_by_side = {"left": "top", "right": "bottom", "top": "right", "bottom": "left"}
else:
edge_by_side = {"left": "bottom", "right": "top", "top": "left", "bottom": "right"}
return edge_by_side[side] in {"top", "right"}
def item_edge_compatible(item: PrintItem, side: str, cols: int, index: int) -> bool:
if not is_character_item(item):
return True
rotation = item.layout_rotation if item.layout_rotation is not None else (-90 if index % cols else 90)
return character_edge_compatible(rotation, side)
def character_duplicate_key(item: PrintItem) -> str:
return item.sort_label or item.label.lower()
def character_bottom_left_corner(index: int, rotation: int, cols: int) -> tuple[int, int]:
row, col = divmod(index, cols)
if rotation == 90:
return row + 1, col + 1
return row, col
def character_item_bottom_left_corner(item: PrintItem, index: int, cols: int) -> tuple[int, int]:
rotation = item.layout_rotation if item.layout_rotation is not None else (-90 if index % cols else 90)
return character_bottom_left_corner(index, rotation, cols)
def neighboring_slots(index: int, cols: int, total: int) -> list[tuple[int, str, str]]:
neighbors: list[tuple[int, str, str]] = []
if index % cols:
neighbors.append((index - 1, "left", "right"))
if index % cols != cols - 1 and index + 1 < total:
neighbors.append((index + 1, "right", "left"))
if index >= cols:
neighbors.append((index - cols, "top", "bottom"))
if index + cols < total:
neighbors.append((index + cols, "bottom", "top"))
return neighbors
def can_place_item(page: list[PrintItem | None], index: int, item: PrintItem, cols: int) -> bool:
if is_character_item(item):
item_corner = character_item_bottom_left_corner(item, index, cols)
item_key = character_duplicate_key(item)
for placed_index, placed in enumerate(page):
if placed is None or not is_character_item(placed):
continue
if character_item_bottom_left_corner(placed, placed_index, cols) != item_corner:
continue
if character_duplicate_key(placed) != item_key:
return False
for neighbor_index, item_side, neighbor_side in neighboring_slots(index, cols, len(page)):
neighbor = page[neighbor_index]
if neighbor is None:
continue
if not item_edge_compatible(item, item_side, cols, index):
return False
if not item_edge_compatible(neighbor, neighbor_side, cols, neighbor_index):
return False
return True
def character_pairs(items: list[PrintItem]) -> tuple[list[tuple[PrintItem, PrintItem]], list[PrintItem]]:
by_key: dict[str, list[PrintItem]] = {}
for item in items:
by_key.setdefault(character_duplicate_key(item), []).append(item)
pairs: list[tuple[PrintItem, PrintItem]] = []
leftovers: list[PrintItem] = []
for key in sorted(by_key):
group = by_key[key]
for i in range(0, len(group) - 1, 2):
pairs.append((group[i], group[i + 1]))
if len(group) % 2:
leftovers.append(group[-1])
return pairs, leftovers
def shared_character_corner_slots(page: list[PrintItem | None], cols: int) -> list[tuple[int, int]]:
slots: list[tuple[int, int]] = []
for nw_index, item in enumerate(page):
if item is not None or nw_index % cols == cols - 1:
continue
se_index = nw_index + cols + 1
if se_index >= len(page) or page[se_index] is not None:
continue
slots.append((nw_index, se_index))
return slots
def fill_single_characters(
page: list[PrintItem | None],
character_items: list[PrintItem],
cols: int,
) -> tuple[list[PrintItem | None], list[PrintItem]]:
empty_indices = [index for index, item in enumerate(page) if item is None]
best_page = list(page)
best_count = 0
def dfs(slot_pos: int, char_pos: int) -> None:
nonlocal best_count, best_page
if char_pos > best_count:
best_count = char_pos
best_page = list(page)
if char_pos == len(character_items) or slot_pos == len(empty_indices):
return
if char_pos + (len(empty_indices) - slot_pos) <= best_count:
return
index = empty_indices[slot_pos]
for rotation in (90, -90):
item = replace(character_items[char_pos], layout_rotation=rotation)
if can_place_item(page, index, item, cols):
page[index] = item
dfs(slot_pos + 1, char_pos + 1)
page[index] = None
dfs(slot_pos + 1, char_pos)
dfs(0, 0)
return best_page, character_items[best_count:]
def fill_page_with_characters(
page: list[PrintItem | None],
character_items: list[PrintItem],
cols: int,
) -> tuple[list[PrintItem | None], list[PrintItem]]:
pairs, leftovers = character_pairs(character_items)
if not pairs:
return fill_single_characters(page, character_items, cols)
best_page = list(page)
best_remaining = list(character_items)
best_score = (-1, -1)
def dfs(pair_pos: int, placed_pairs: int, unplaced: list[PrintItem]) -> None:
nonlocal best_page, best_remaining, best_score
remaining_pairs = len(pairs) - pair_pos
max_possible_total = len(character_items)
if max_possible_total < best_score[0]:
return
if max_possible_total == best_score[0] and placed_pairs + remaining_pairs <= best_score[1]:
return
if pair_pos == len(pairs):
remaining_for_single_fill = sorted(leftovers + unplaced, key=item_order_key)
candidate_page, candidate_remaining = fill_single_characters(list(page), remaining_for_single_fill, cols)
total_placed = len(character_items) - len(candidate_remaining)
score = (total_placed, placed_pairs)
if score > best_score:
best_score = score
best_page = candidate_page
best_remaining = candidate_remaining
return
first, second = pairs[pair_pos]
for nw_index, se_index in shared_character_corner_slots(page, cols):
nw_item = replace(first, layout_rotation=90)
se_item = replace(second, layout_rotation=-90)
if not can_place_item(page, nw_index, nw_item, cols):
continue
page[nw_index] = nw_item
if can_place_item(page, se_index, se_item, cols):
page[se_index] = se_item
dfs(pair_pos + 1, placed_pairs + 1, unplaced)
page[se_index] = None
page[nw_index] = None
dfs(pair_pos + 1, placed_pairs, unplaced + [first, second])
dfs(0, 0, [])
return best_page, best_remaining
def fill_bottom_rows_with_spaced_characters(
pages: list[CardPage],
character_items: list[PrintItem],
config: BuildConfig,
) -> list[PrintItem]:
if not character_items:
return character_items
cols, rows = grid_capacity(config)
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
gap = mm_to_px(5.0, config.dpi)
card_w = mm_to_px(config.card_mm[0], config.dpi)
card_h = mm_to_px(config.card_mm[1], config.dpi)
oriented_w, oriented_h = card_h, card_w
grid_w = cols * oriented_w
grid_h = rows * oriented_h
x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
y0 = margin + ((page_h - 2 * margin) - grid_h) // 2
remaining = list(character_items)
for page in pages:
if not remaining or page.spaced:
continue
first_empty_row = rows
for row in range(rows - 1, -1, -1):
row_items = page.items[row * cols : (row + 1) * cols]
if any(item is not None for item in row_items):
break
first_empty_row = row
empty_rows = rows - first_empty_row
if empty_rows <= 0:
continue
area_left = margin
area_top = y0 + first_empty_row * oriented_h
area_w = page_w - 2 * margin
area_h = page_h - min(margin, gap) - area_top
if first_empty_row > 0:
area_top += gap
area_h -= gap
layout_options = []
for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)):
fit_cols = max(1, (area_w + gap) // (item_w + gap))
fit_rows = max(0, (area_h + gap) // (item_h + gap))
layout_options.append((fit_cols * fit_rows, fit_cols, fit_rows, item_w, item_h, rotation))
capacity_slots, fit_cols, fit_rows, item_w, item_h, rotation = max(layout_options)
capacity = min(len(remaining), capacity_slots)
if capacity <= 0:
continue
used_cols = min(fit_cols, capacity)
used_rows = math.ceil(capacity / fit_cols)
placement_w = used_cols * item_w + max(0, used_cols - 1) * gap
placement_h = used_rows * item_h + max(0, used_rows - 1) * gap
place_x0 = area_left + (area_w - placement_w) // 2
place_y0 = area_top
extras = list(page.extras or [])
for index in range(capacity):
row, col = divmod(index, fit_cols)
left = place_x0 + col * (item_w + gap)
top = place_y0 + row * (item_h + gap)
extras.append((remaining[index], (left, top, left + item_w, top + item_h), rotation))
page.extras = extras
remaining = remaining[capacity:]
return fill_free_space_with_spaced_characters(pages, remaining, config)
def rects_conflict(rect: tuple[int, int, int, int], occupied: list[tuple[int, int, int, int]], gap: int) -> bool:
left, top, right, bottom = rect
for other_left, other_top, other_right, other_bottom in occupied:
if left < other_right + gap and right > other_left - gap and top < other_bottom + gap and bottom > other_top - gap:
return True
return False
def shift_rect(rect: tuple[int, int, int, int], dx: int, dy: int) -> tuple[int, int, int, int]:
return rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy
def centered_shift(
rects: list[tuple[int, int, int, int]],
page_w: int,
page_h: int,
edge_margin: int,
) -> tuple[int, int]:
if not rects:
return 0, 0
left = min(rect[0] for rect in rects)
top = min(rect[1] for rect in rects)
right = max(rect[2] for rect in rects)
bottom = max(rect[3] for rect in rects)
bbox_w = right - left
bbox_h = bottom - top
target_left = (page_w - bbox_w) // 2
target_top = (page_h - bbox_h) // 2
min_left = edge_margin
max_left = page_w - edge_margin - bbox_w
min_top = edge_margin
max_top = page_h - edge_margin - bbox_h
if max_left >= min_left:
target_left = min(max(target_left, min_left), max_left)
if max_top >= min_top:
target_top = min(max(target_top, min_top), max_top)
return target_left - left, target_top - top
def fill_free_space_with_spaced_characters(
pages: list[CardPage],
character_items: list[PrintItem],
config: BuildConfig,
) -> list[PrintItem]:
if not character_items:
return character_items
cols, rows = grid_capacity(config)
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
edge_margin = min(margin, mm_to_px(5.0, config.dpi))
gap = mm_to_px(5.0, config.dpi)
card_w = mm_to_px(config.card_mm[0], config.dpi)
card_h = mm_to_px(config.card_mm[1], config.dpi)
oriented_w, oriented_h = card_h, card_w
grid_w = cols * oriented_w
grid_h = rows * oriented_h
x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
y0 = margin + ((page_h - 2 * margin) - grid_h) // 2
remaining = list(character_items)
for page in pages:
if not remaining or page.spaced:
continue
occupied: list[tuple[int, int, int, int]] = []
for index, item in enumerate(page.items):
if item is None:
continue
row, col = divmod(index, cols)
left = x0 + col * oriented_w
top = y0 + row * oriented_h
occupied.append((left, top, left + oriented_w, top + oriented_h))
occupied.extend(rect for _, rect, _ in (page.extras or []))
extras = list(page.extras or [])
placed_count = 0
while placed_count < len(remaining):
placed = False
for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)):
xs = {edge_margin, page_w - edge_margin - item_w}
ys = {edge_margin, page_h - edge_margin - item_h}
for left, top, right, bottom in occupied:
xs.add(right + gap)
xs.add(left - gap - item_w)
ys.add(bottom + gap)
ys.add(top - gap - item_h)
candidates = []
for y in sorted(ys):
if y < edge_margin or y + item_h > page_h - edge_margin:
continue
for x in sorted(xs):
if x < edge_margin or x + item_w > page_w - edge_margin:
continue
candidates.append((x, y, x + item_w, y + item_h))
for rect in sorted(candidates, key=lambda value: (value[1], value[0])):
if rects_conflict(rect, occupied, gap):
continue
extras.append((remaining[placed_count], rect, rotation))
occupied.append(rect)
placed_count += 1
placed = True
break
if placed:
break
if not placed:
break
if placed_count:
page.extras = extras
remaining = remaining[placed_count:]
return remaining
def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[CardPage]:
cols, rows = grid_capacity(config)
per_page = cols * rows
pages: list[CardPage] = []
standard_items = [item for item in items if not item.group.startswith("character:")]
character_items = sorted((item for item in items if item.group.startswith("character:")), key=item_order_key)
if config.crop_black_borders:
groups = sorted({item.group for item in standard_items}, key=group_order_key)
for group in groups:
group_items = [item for item in standard_items if item.group == group]
pages.extend(pack_cropped_standard_group(group_items, cols, rows))
character_items = fill_bottom_rows_with_spaced_characters(pages, character_items, config)
else:
group_items = sorted(standard_items, key=item_order_key)
for i in range(0, len(group_items), per_page):
page = group_items[i : i + per_page]
if i + per_page >= len(group_items):
page = page + [None] * (per_page - len(page))
pages.append(CardPage(page))
if character_items and pages:
last_page = pages[-1]
if not last_page.spaced:
filled_page, character_items = fill_page_with_characters(list(last_page.items), character_items, cols)
pages[-1] = CardPage(filled_page)
if character_items:
for chunk in chunk_spaced_cards(character_items, config):
pages.append(CardPage(chunk, True))
return pages
def chunk_spaced_cards(items: list[PrintItem], config: BuildConfig) -> list[list[PrintItem]]:
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
gap = mm_to_px(5.0, config.dpi)
card_w = mm_to_px(config.card_mm[0], config.dpi)
card_h = mm_to_px(config.card_mm[1], config.dpi)
oriented_w, oriented_h = card_h, card_w
usable_w = page_w - 2 * margin
usable_h = page_h - 2 * margin
cols = max(1, (usable_w + gap) // (oriented_w + gap))
rows = max(1, (usable_h + gap) // (oriented_h + gap))
per_page = max(1, cols * rows)
return [items[i : i + per_page] for i in range(0, len(items), per_page)]
def group_order_key(group: str) -> tuple[int, str]:
base_order = {"encounter": 0, "office": 1, "basement": 2}
if group in base_order:
return base_order[group], group
if group.startswith("character:"):
return 3, group
return 99, group
def item_order_key(item: PrintItem) -> tuple[int, str, str, str]:
if item.image_type == "back":
phase = 2
elif item.is_starter:
phase = 0
else:
phase = 1
return phase, item.sort_label or item.label.lower(), item.image_type, str(item.path)
def draw_crop_lines(page: Image.Image, rect: tuple[int, int, int, int]) -> None:
draw = ImageDraw.Draw(page)
left, top, right, bottom = rect
draw.line((left, 0, left, page.height), fill=(0, 0, 0), width=1)
draw.line((right, 0, right, page.height), fill=(0, 0, 0), width=1)
draw.line((0, top, page.width, top), fill=(0, 0, 0), width=1)
draw.line((0, bottom, page.width, bottom), fill=(0, 0, 0), width=1)
def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> int:
pages = grouped_card_pages(items, config)
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
bleed_px = mm_to_px(config.bleed_mm, config.dpi)
card_w = mm_to_px(config.card_mm[0], config.dpi)
card_h = mm_to_px(config.card_mm[1], config.dpi)
oriented_w, oriented_h = card_h, card_w
cols, rows = grid_capacity(config)
def render_one_card_page(page_offset_and_plan: tuple[int, CardPage]) -> tuple[Path, int]:
page_offset, page_plan = page_offset_and_plan
page_items = page_plan.items
spaced = page_plan.spaced
extras = page_plan.extras or []
page = Image.new("RGB", (page_w, page_h), "white")
image_cache: dict[Path, Image.Image] = {}
def load_page_image(path: Path) -> Image.Image:
if path not in image_cache:
image_cache[path] = Image.open(path).convert("RGB")
return image_cache[path]
def make_item_bleed(item: PrintItem, face: Image.Image) -> Image.Image:
if item.bleed_path is not None and bleed_px > 0:
return load_page_image(item.bleed_path)
return make_bleed(face, bleed_px)
gap = mm_to_px(5.0, config.dpi) if spaced else 0
if spaced:
page_cols = max(1, (page_w - 2 * margin + gap) // (oriented_w + gap))
page_rows = math.ceil(len(page_items) / page_cols)
else:
page_cols = cols
page_rows = rows
grid_w = page_cols * oriented_w + max(0, page_cols - 1) * gap
grid_h = page_rows * oriented_h + max(0, page_rows - 1) * gap
x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
y0 = margin + ((page_h - 2 * margin) - grid_h) // 2
placements: list[tuple[PrintItem | None, tuple[int, int, int, int], int]] = []
for idx, item in enumerate(page_items):
row = idx // page_cols
col = idx % page_cols
left = x0 + col * (oriented_w + gap)
top = y0 + row * (oriented_h + gap)
rotate = item.layout_rotation if item is not None and item.layout_rotation is not None else (-90 if col % 2 else 90)
placements.append((item, (left, top, left + oriented_w, top + oriented_h), rotate))
occupied_rects = [rect for item, rect, _ in placements if item is not None]
occupied_rects.extend(rect for _, rect, _ in extras)
dx, dy = centered_shift(occupied_rects, page_w, page_h, min(margin, gap or margin))
if dx or dy:
placements = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in placements]
extras = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in extras]
for item, rect, _ in placements:
if item is None:
continue
draw_crop_lines(page, rect)
for _, rect, _ in extras:
draw_crop_lines(page, rect)
for item, rect, rotate in placements:
if item is None:
continue
face = load_page_image(item.path)
bleed = make_item_bleed(item, face).rotate(rotate, expand=True)
cx = (rect[0] + rect[2]) // 2
cy = (rect[1] + rect[3]) // 2
page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2))
for item, rect, rotate in extras:
face = load_page_image(item.path)
bleed = make_item_bleed(item, face).rotate(rotate, expand=True)
cx = (rect[0] + rect[2]) // 2
cy = (rect[1] + rect[3]) // 2
page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2))
for item, rect, rotate in placements:
if item is None:
continue
face = load_page_image(item.path).rotate(rotate, expand=True)
face = face.resize((oriented_w, oriented_h), Image.Resampling.LANCZOS)
page.paste(face, (rect[0], rect[1]))
for item, rect, rotate in extras:
face = load_page_image(item.path).rotate(rotate, expand=True)
face = face.resize((rect[2] - rect[0], rect[3] - rect[1]), Image.Resampling.LANCZOS)
page.paste(face, (rect[0], rect[1]))
out = PAGES_DIR / f"page_{start_index + page_offset:03d}.png"
out.parent.mkdir(parents=True, exist_ok=True)
save_temp_png(page, out, config.dpi)
return out, sum(1 for item in page_items if item is not None) + len(extras)
with ThreadPoolExecutor(max_workers=worker_count(len(pages))) as executor:
for out, count in executor.map(render_one_card_page, enumerate(pages)):
print(f"Wrote {out.relative_to(ROOT)} ({count} card images)")
return start_index + len(pages)
def plan_board_rows(items: list[PrintItem], config: BuildConfig) -> list[list[tuple[PrintItem, bool]]]:
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
gap = mm_to_px(5.0, config.dpi)
usable_w = page_w - 2 * margin
usable_h = page_h - 2 * margin
pages: list[list[tuple[PrintItem, bool]]] = []
current: list[tuple[PrintItem, bool]] = []
row_w = row_h = used_h = 0
for item in items:
with Image.open(item.path) as img:
options = [(False, img.width, img.height), (True, img.height, img.width)]
rotate, bw, bh = min((o for o in options if o[1] <= usable_w), key=lambda o: o[2], default=options[0])
needed_w = bw if row_w == 0 else row_w + gap + bw
if row_w and needed_w > usable_w:
used_h += row_h + (gap if used_h else 0)
row_w = row_h = 0
if used_h and used_h + bh > usable_h:
pages.append(current)
current = []
used_h = row_w = row_h = 0
current.append((item, rotate))
row_w = bw if row_w == 0 else row_w + gap + bw
row_h = max(row_h, bh)
if current:
pages.append(current)
return pages
def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> int:
if not items:
return start_index
pages = plan_board_rows(items, config)
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
gap = mm_to_px(5.0, config.dpi)
bleed_px = mm_to_px(config.bleed_mm, config.dpi)
usable_w = page_w - 2 * margin
def render_one_board_page(page_offset_and_items: tuple[int, list[tuple[PrintItem, bool]]]) -> tuple[Path, int]:
page_offset, page_items = page_offset_and_items
page = Image.new("RGB", (page_w, page_h), "white")
image_cache: dict[Path, Image.Image] = {}
def load_page_image(path: Path) -> Image.Image:
if path not in image_cache:
image_cache[path] = Image.open(path).convert("RGB")
return image_cache[path]
rows: list[list[tuple[PrintItem, bool, int, int]]] = []
row: list[tuple[PrintItem, bool, int, int]] = []
row_w = row_h = 0
for item, rotate in page_items:
with Image.open(item.path) as img:
bw, bh = (img.height, img.width) if rotate else (img.width, img.height)
if row and row_w + gap + bw > usable_w:
rows.append(row)
row = []
row_w = row_h = 0
row.append((item, rotate, bw, bh))
row_w = bw if row_w == 0 else row_w + gap + bw
row_h = max(row_h, bh)
if row:
rows.append(row)
total_h = sum(max(bh for _, _, _, bh in r) for r in rows) + gap * max(0, len(rows) - 1)
y = margin + ((page_h - 2 * margin) - total_h) // 2
placements: list[tuple[PrintItem, bool, tuple[int, int, int, int]]] = []
for row in rows:
row_w = sum(bw for _, _, bw, _ in row) + gap * max(0, len(row) - 1)
row_h = max(bh for _, _, _, bh in row)
x = margin + (usable_w - row_w) // 2
for item, rotate, bw, bh in row:
top = y + (row_h - bh) // 2
rect = (x, top, x + bw, top + bh)
placements.append((item, rotate, rect))
x += bw + gap
y += row_h + gap
for _, _, rect in placements:
draw_crop_lines(page, rect)
for item, rotate, rect in placements:
face = load_page_image(item.path)
bleed = make_bleed(face, bleed_px)
if rotate:
bleed = bleed.rotate(90, expand=True)
cx = (rect[0] + rect[2]) // 2
cy = (rect[1] + rect[3]) // 2
page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2))
for item, rotate, rect in placements:
face = load_page_image(item.path)
if rotate:
face = face.rotate(90, expand=True)
face = face.resize((rect[2] - rect[0], rect[3] - rect[1]), Image.Resampling.LANCZOS)
page.paste(face, (rect[0], rect[1]))
out = PAGES_DIR / f"page_{start_index + page_offset:03d}.png"
out.parent.mkdir(parents=True, exist_ok=True)
save_temp_png(page, out, config.dpi)
return out, len(page_items)
with ThreadPoolExecutor(max_workers=worker_count(len(pages))) as executor:
for out, count in executor.map(render_one_board_page, enumerate(pages)):
print(f"Wrote {out.relative_to(ROOT)} ({count} boards)")
return start_index + len(pages)
+92
View File
@@ -0,0 +1,92 @@
import zlib
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from PIL import Image
from .settings import BuildConfig, PAGES_DIR, PDF_COMPRESSION_LEVEL, PRINT_DIR, worker_count
def write_pdf(config: BuildConfig) -> Path | None:
pages = sorted(PAGES_DIR.glob("page_*.png"))
if not pages:
return None
out_name = config.output_pdf_name.strip() or "blockminers_printable.pdf"
if not out_name.lower().endswith(".pdf"):
out_name += ".pdf"
PRINT_DIR.mkdir(parents=True, exist_ok=True)
out = PRINT_DIR / Path(out_name).name
write_lossless_image_pdf(pages, out, config.dpi)
return out
def write_lossless_image_pdf(page_paths: list[Path], out_path: Path, dpi: int) -> None:
max_obj = 2 + len(page_paths) * 3
offsets = [0] * (max_obj + 1)
page_objects: list[int] = []
def pdf_num(value: float) -> str:
return f"{value:.4f}".rstrip("0").rstrip(".")
def write_obj(f, obj_id: int, body: bytes) -> None:
offsets[obj_id] = f.tell()
f.write(f"{obj_id} 0 obj\n".encode("ascii"))
f.write(body)
f.write(b"\nendobj\n")
def write_stream_obj(f, obj_id: int, dictionary: str, stream: bytes) -> None:
header = f"<< {dictionary} /Length {len(stream)} >>\nstream\n".encode("ascii")
write_obj(f, obj_id, header + stream + b"\nendstream")
def compress_page(path: Path) -> tuple[int, int, float, float, bytes]:
with Image.open(path).convert("RGB") as img:
width_px, height_px = img.size
width_pt = width_px / dpi * 72.0
height_pt = height_px / dpi * 72.0
compressed = zlib.compress(img.tobytes(), level=PDF_COMPRESSION_LEVEL)
return width_px, height_px, width_pt, height_pt, compressed
with ThreadPoolExecutor(max_workers=worker_count(len(page_paths))) as executor:
compressed_pages = list(executor.map(compress_page, page_paths))
with out_path.open("wb") as f:
f.write(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
for index, (width_px, height_px, width_pt, height_pt, compressed) in enumerate(compressed_pages):
page_obj = 3 + index * 3
image_obj = page_obj + 1
content_obj = page_obj + 2
page_objects.append(page_obj)
image_dict = (
f"/Type /XObject /Subtype /Image /Width {width_px} /Height {height_px} "
"/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode"
)
write_stream_obj(f, image_obj, image_dict, compressed)
content = (
f"q\n{pdf_num(width_pt)} 0 0 {pdf_num(height_pt)} 0 0 cm\n/Im0 Do\nQ\n"
).encode("ascii")
write_stream_obj(f, content_obj, "", content)
page_body = (
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {pdf_num(width_pt)} {pdf_num(height_pt)}] "
f"/Resources << /XObject << /Im0 {image_obj} 0 R >> >> "
f"/Contents {content_obj} 0 R >>"
).encode("ascii")
write_obj(f, page_obj, page_body)
kids = " ".join(f"{obj_id} 0 R" for obj_id in page_objects)
write_obj(f, 2, f"<< /Type /Pages /Kids [{kids}] /Count {len(page_objects)} >>".encode("ascii"))
write_obj(f, 1, b"<< /Type /Catalog /Pages 2 0 R >>")
xref_offset = f.tell()
f.write(f"xref\n0 {max_obj + 1}\n".encode("ascii"))
f.write(b"0000000000 65535 f \n")
for obj_id in range(1, max_obj + 1):
f.write(f"{offsets[obj_id]:010d} 00000 n \n".encode("ascii"))
f.write(
f"trailer\n<< /Size {max_obj + 1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode(
"ascii"
)
)
+119
View File
@@ -0,0 +1,119 @@
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = ROOT / "data"
DECKS_DIR = DATA_DIR / "decks"
FONTS_DIR = DATA_DIR / "fonts"
PRINT_DIR = ROOT / "print"
TEMP_DIR = PRINT_DIR / "temp"
RENDER_DIR = TEMP_DIR / "rendered"
PAGES_DIR = TEMP_DIR / "pages"
PLAYER_COLORS = ["#00ea0e", "#ead500", "#0abdc6", "#ea2b00", "#ea00d9"]
OFFICE_SINGLE_STARTER_COLOR = "#0abdc6"
BASEMENT_SINGLE_STARTER_COLOR = "#ea00d9"
CARD_DECKS = {
"encounter": ["encounterCards"],
"office": ["darkWebCompanyDeck", "startingCompanyDeck"],
"basement": ["darkWebBasementDeck", "startingBasementDeck"],
}
DECK_GROUPS = {deck_name: group for group, deck_names in CARD_DECKS.items() for deck_name in deck_names}
TITLE_WIDTH_LIMITS = {
"encounter": 0.94,
"office": 0.81,
"basement": 0.98,
}
TEXT_AREA_TOP_CROPPED_RATIOS = {
"encounter": 0.552,
"office": 0.557,
"basement": 0.542,
}
TEXT_AREA_BOTTOM_CROPPED_RATIO = 0.965
FLAVOUR_BLOCK_BOTTOM_SHIFT_CROPPED_RATIO = 0.0061
STARTER_DECKS = {"startingCompanyDeck", "startingBasementDeck"}
CHARACTER_DECK = "characters"
BOARD_DECK = "gameBoard"
CHECK_COLORS = {
"VOICE": "#0abdc6",
"MIND": "#ea00d9",
"WILL": "#ead500",
}
PAPER_SIZES_MM = {
"a3": (297.0, 420.0),
"a4": (210.0, 297.0),
"letter": (215.9, 279.4),
"legal": (215.9, 355.6),
"tabloid": (279.4, 431.8),
}
CARD_SIZE_OPTIONS_MM = {
"poker": (63.5, 88.9),
"bridge": (56.0, 87.0),
"mini-euro": (44.0, 68.0),
"tarot": (70.0, 120.0),
}
TEXT_INDENT_CHARS = 2
ITALIC_SHEAR = 0.18
BOLD_OFFSETS = ((1, 0), (0, 1), (1, 1))
BASE_CARD_SIZE = (825, 1125)
FIXED_CROP = (70, 73, 70, 73)
SIDE_EDGE_REPLACE_BANDS = (
(0.0, 0.2, 10),
(0.2, 0.4, 3),
(0.4, 0.6, 1),
(0.6, 0.8, 3),
(0.8, 1.0, 10),
)
TOP_BOTTOM_EDGE_REPLACE_PX = 10
CHARACTER_EDGE_REPLACE_PX = 3
MAX_WORKERS = 8
PDF_COMPRESSION_LEVEL = 9
@dataclass(frozen=True)
class BuildConfig:
paper_name: str
paper_mm: tuple[float, float]
dpi: int
crop_black_borders: bool
card_mm: tuple[float, float]
board_mode: str
board_custom_mm: tuple[float, float] | None
gamma_enabled: bool
gamma: float
black_point: float
project_version: str
starter_unique_colors: bool
rent_raised_count: int
margin_mm: float
bleed_mm: float
clean_edge_mm: float
output_pdf_name: str
delete_temp: bool | None
render_only: bool = False
@dataclass(frozen=True)
class PrintItem:
path: Path
group: str
label: str
image_type: str
is_starter: bool = False
sort_label: str = ""
bleed_path: Path | None = None
layout_rotation: int | None = None
def mm_to_px(mm: float, dpi: int) -> int:
return round(mm / 25.4 * dpi)
def worker_count(task_count: int) -> int:
return max(1, min(MAX_WORKERS, task_count))
+773
View File
@@ -0,0 +1,773 @@
import html
import math
import re
import threading
import unicodedata
from pathlib import Path
from PIL import Image, ImageChops, ImageDraw, ImageFont
from .assets import meta_unit_scale
from .settings import (
BASE_CARD_SIZE,
BOLD_OFFSETS,
DECK_GROUPS,
FIXED_CROP,
FLAVOUR_BLOCK_BOTTOM_SHIFT_CROPPED_RATIO,
FONTS_DIR,
ITALIC_SHEAR,
TEXT_AREA_BOTTOM_CROPPED_RATIO,
TEXT_AREA_TOP_CROPPED_RATIOS,
TEXT_INDENT_CHARS,
TITLE_WIDTH_LIMITS,
)
class Fonts:
def __init__(self) -> None:
self._xirod = FONTS_DIR / "xirod.ttf"
self._consolas = FONTS_DIR / "consolas.ttf"
self._unicode = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf")
self._cache: dict[tuple[str, int], ImageFont.FreeTypeFont | ImageFont.ImageFont] = {}
self._lock = threading.Lock()
def title(self, java_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
size = max(8, int(java_size / 3) * 2)
return self._font("xirod", self._xirod, size)
def text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
return self._font("consolas", self._consolas, max(8, size))
def unicode_text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
return self._font("unicode", self._unicode, max(8, size))
def _font(self, name: str, path: Path, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
key = (name, size)
with self._lock:
if key not in self._cache:
if path.exists():
self._cache[key] = ImageFont.truetype(str(path), size=size, layout_engine=ImageFont.Layout.RAQM)
else:
self._cache[key] = ImageFont.load_default()
return self._cache[key]
FONTS = Fonts()
KEYWORDS = [
"Voice",
"Mind",
"Will",
r"\+[0-9] Cards?",
r"\+[0-9] Encounters?",
r"\+[0-9] Blocks?",
r"\+[0-9] Money",
r"Effort [0-9]",
"Blocks?",
"Money",
r"\+X",
r"\-X",
r"\+[0-9]",
"-[0-9]",
r"Reboot [0-9]?",
r"Reveal [0-9]?",
"Revealed",
r"Distract [0-9]?",
"Distracted",
"Deleted",
"Delete",
"Draw [0-9] office cards",
"Draw [0-9] office card",
"Draw [0-9] basement cards",
"Draw [0-9] basement card",
"Draw [0-9] encounter cards",
"Draw [0-9] encounter card",
r"Escape [0-9]?",
r"Escape&nbsp;[0-9]?",
r"Extract [0-9]?",
r"Browse [0-9]",
"Infiltrating",
"Infiltrates",
"Infiltrated?",
"Hacked",
"Hacking",
"Hack any company",
"Hacks?",
"Manipulates?",
"Demoted",
"Demote",
"Promoted",
"Promote",
"Transferring",
"Transferred",
"Transfer",
]
KEYWORD_RE = re.compile(r"(?i)(?<!<b>)(?<![\w+.-])(" + "|".join(KEYWORDS) + r")(?![\w.-])(?!</b>)")
def format_card_text(text: str | None) -> str:
if text is None:
return ""
lines: list[str] = []
for original in text.split("\n"):
leading = re.match(r"^\s*", original).group(0)
line = original.strip()
if line.startswith("//"):
lines.append(leading + re.sub(r"//( ?)(?!<i>)(.*)(?!</i>)", r"<i>//\1\2</i>", line))
continue
parts = line.split("//", 1)
action = parts[0]
if not action.strip().endswith(":"):
action = re.sub(r" *$", ";", action)
action = action.replace(";;", "; ").replace(":;", ":")
action = KEYWORD_RE.sub(r"<b>\1</b>", action)
result = leading + action
if len(parts) > 1:
comment = "//" + parts[1]
comment = re.sub(r"//( ?)(?!<i>)(.*)(?!</i>)", r"<i>//\1\2</i>", comment)
result += " " + comment
lines.append(result)
return "\n".join(lines)
def format_flavour_text(text: str | None) -> str:
if not text:
return ""
zalgo = re.fullmatch(r"<zalgo>(.*)</zalgo>", text, flags=re.DOTALL)
if zalgo:
return "<i>" + zalgo.group(1) + "</i>\n<i></i>"
if text.startswith("<i>"):
return text
text = re.sub(r"/\* *", "/*&nbsp;", text)
text = re.sub(r" *\*/", "&nbsp;*/", text)
return "<i>" + text.replace("\n", "</i>\n<i>") + "</i>"
def strip_font_tags(text: str) -> str:
text = re.sub(r"</?(font|p)[^>]*>", "", text)
text = re.sub(r"<hr\s*/?>", "\n<hr>\n", text)
text = text.replace("<br>", "\n").replace("<br/>", "\n")
return html.unescape(text)
def parse_runs(text: str) -> list[tuple[str, set[str]]]:
text = strip_font_tags(text)
token_re = re.compile(r"(<\/?b>|<\/?i>|<hr>)", re.I)
parts = token_re.split(text)
styles: set[str] = set()
runs: list[tuple[str, set[str]]] = []
for part in parts:
low = part.lower()
if low == "<b>":
styles.add("b")
elif low == "</b>":
styles.discard("b")
elif low == "<i>":
styles.add("i")
elif low == "</i>":
styles.discard("i")
elif low == "<hr>":
runs.append(("\n---\n", set(styles)))
elif part:
runs.append((part, set(styles)))
return runs
def split_words(runs: list[tuple[str, set[str]]]) -> list[tuple[str, set[str]]]:
out: list[tuple[str, set[str]]] = []
for text, styles in runs:
for token in re.findall(r"\n|[ \t\r\f\v]+|[^ \t\r\f\v\n]+", text):
out.append((token, styles))
return out
def font_px(font: ImageFont.ImageFont) -> int:
return int(getattr(font, "size", 16))
def cluster_has_unicode(cluster: str) -> bool:
return any(ord(char) > 127 or unicodedata.combining(char) for char in cluster)
def text_clusters(text: str) -> list[str]:
clusters: list[str] = []
for char in text:
if clusters and unicodedata.combining(char):
clusters[-1] += char
else:
clusters.append(char)
return clusters
def text_segments(text: str, font: ImageFont.ImageFont) -> list[tuple[str, ImageFont.ImageFont]]:
segments: list[tuple[str, ImageFont.ImageFont]] = []
current = ""
current_font = font
unicode_font = FONTS.unicode_text(font_px(font))
for cluster in text_clusters(text):
segment_font = unicode_font if cluster_has_unicode(cluster) else font
if current and segment_font is current_font:
current += cluster
else:
if current:
segments.append((current, current_font))
current = cluster
current_font = segment_font
if current:
segments.append((current, current_font))
return segments
def cluster_layout_text(cluster: str) -> str:
if any(unicodedata.combining(char) for char in cluster):
return "".join(char for char in cluster if not unicodedata.combining(char))
return cluster
def is_zalgo_cluster(cluster: str) -> bool:
return sum(1 for char in cluster if unicodedata.combining(char)) >= 4
def zalgo_extra_advance(font: ImageFont.ImageFont, mark_count: int) -> int:
if mark_count <= 0:
return 0
return max(2, round(math.sqrt(mark_count) * font_px(font) * 0.065))
def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, styles: set[str] | None = None) -> int:
if not text:
return 0
styles = styles or set()
bold_offset = max(dx for dx, _ in BOLD_OFFSETS) if "b" in styles else 0
width = 0.0
for segment, segment_font in text_segments(text, font):
for cluster in text_clusters(segment):
layout_text = cluster_layout_text(cluster)
if layout_text:
width += draw.textlength(layout_text, font=segment_font)
if is_zalgo_cluster(cluster):
mark_count = sum(1 for char in cluster if unicodedata.combining(char))
width += zalgo_extra_advance(segment_font, mark_count)
if bold_offset:
width += bold_offset
return round(width)
def text_line_height(font: ImageFont.ImageFont) -> int:
if hasattr(font, "getmetrics"):
ascent, descent = font.getmetrics()
return max(1, ascent + descent + 2)
return max(1, font_px(font) + 2)
TRIMMED_CARD_WIDTHS: dict[Path, int] = {}
def trimmed_card_width(deck_dir: Path) -> int:
deck_dir = deck_dir.resolve()
if deck_dir not in TRIMMED_CARD_WIDTHS:
TRIMMED_CARD_WIDTHS[deck_dir] = BASE_CARD_SIZE[0] - FIXED_CROP[0] - FIXED_CROP[2]
return TRIMMED_CARD_WIDTHS[deck_dir]
def title_width_limit(deck_dir: Path, meta: dict) -> int:
group = DECK_GROUPS.get(deck_dir.name, "")
ratio = TITLE_WIDTH_LIMITS.get(group, 0.94)
crop_scale = meta_unit_scale(meta)
return math.floor(trimmed_card_width(deck_dir) * crop_scale * ratio)
def title_font_for_target_width(title: str, meta: dict, font_scale: float, max_width: int) -> ImageFont.ImageFont:
source_base_size = int(meta.get("titleFontSize") or meta.get("fontSize") or 32)
base_size = max(8, round(source_base_size * font_scale))
min_size = max(8, round(base_size * 0.55))
draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
for size in range(base_size, min_size - 1, -1):
font = FONTS.title(size)
if draw.textlength(title, font=font) <= max_width:
return font
return FONTS.title(min_size)
def standard_text_layout(meta: dict, target_size: tuple[int, int], crop_edges: bool) -> dict:
card_w = int(meta["cardWidth"])
card_h = int(meta["cardHeight"])
unit = meta_unit_scale(meta)
if crop_edges:
left = round(FIXED_CROP[0] * unit)
top = round(FIXED_CROP[1] * unit)
right = round(FIXED_CROP[2] * unit)
bottom = round(FIXED_CROP[3] * unit)
else:
left = top = right = bottom = 0
visible_w = max(1, card_w - left - right)
visible_h = max(1, card_h - top - bottom)
return {
"left": left,
"top": top,
"sx": target_size[0] / visible_w,
"sy": target_size[1] / visible_h,
"target_w": target_size[0],
"target_h": target_size[1],
}
def tx(layout: dict, x: float) -> int:
return round((x - layout["left"]) * layout["sx"])
def ty(layout: dict, y: float) -> int:
return round((y - layout["top"]) * layout["sy"])
def tw(layout: dict, width: float) -> int:
return max(1, round(width * layout["sx"]))
def th(layout: dict, height: float) -> int:
return max(1, round(height * layout["sy"]))
def font_size_y(layout: dict, source_size: float) -> int:
return max(8, round(source_size * layout["sy"]))
def source_y_from_cropped_ratio(meta: dict, ratio: float) -> float:
unit = meta_unit_scale(meta)
crop_top = FIXED_CROP[1] * unit
crop_bottom = FIXED_CROP[3] * unit
cropped_h = float(meta["cardHeight"]) - crop_top - crop_bottom
return crop_top + cropped_h * ratio
def transformed_text_area(deck_dir: Path, meta: dict, layout: dict) -> tuple[int, int, int]:
group = DECK_GROUPS.get(deck_dir.name, "")
top_ratio = TEXT_AREA_TOP_CROPPED_RATIOS.get(group, TEXT_AREA_TOP_CROPPED_RATIOS["encounter"])
source_top = source_y_from_cropped_ratio(meta, top_ratio)
source_bottom = source_y_from_cropped_ratio(meta, TEXT_AREA_BOTTOM_CROPPED_RATIO)
previous_bottom = int(meta["textOffset"]) + int(meta["textHeight"]) - int(meta["textPadding"])
flavour_content_bottom = previous_bottom + (
source_y_from_cropped_ratio(meta, FLAVOUR_BLOCK_BOTTOM_SHIFT_CROPPED_RATIO)
- source_y_from_cropped_ratio(meta, 0.0)
)
source_bottom_gap = max(0.0, source_bottom - flavour_content_bottom)
return ty(layout, source_top), ty(layout, source_bottom), th(layout, source_bottom_gap)
def draw_styled_text(
image: Image.Image,
xy: tuple[int, int],
text: str,
font: ImageFont.ImageFont,
color: tuple[int, int, int],
styles: set[str],
line_h: int,
) -> None:
if not text:
return
ascent = font.getmetrics()[0] if hasattr(font, "getmetrics") else font_px(font)
bold_offsets = BOLD_OFFSETS if "b" in styles else ()
def draw_zalgo_cluster(
target: Image.Image,
target_draw: ImageDraw.ImageDraw,
pos: tuple[int, int],
cluster: str,
segment_font: ImageFont.ImageFont,
fill: tuple[int, int, int],
) -> int:
base = cluster_layout_text(cluster) or " "
marks = [char for char in cluster if unicodedata.combining(char)]
base_font = font if base.isascii() else segment_font
base_w = max(1, round(target_draw.textlength(base, font=base_font)))
advance_w = base_w + zalgo_extra_advance(base_font, len(marks))
def draw_base(base_pos: tuple[int, int]) -> None:
if "i" not in styles:
target_draw.text(base_pos, base, fill=fill, font=base_font, anchor="ls")
return
base_ascent = base_font.getmetrics()[0] if hasattr(base_font, "getmetrics") else font_px(base_font)
pad = max(4, round(font_px(base_font) * 0.35))
src_w = base_w + pad * 2
src_h = font_px(base_font) * 3
src = Image.new("RGBA", (src_w, src_h), (0, 0, 0, 0))
src_draw = ImageDraw.Draw(src)
src_draw.text((pad, pad + base_ascent), base, fill=(*fill, 255), font=base_font, anchor="ls")
skew_w = src.width + round(src.height * ITALIC_SHEAR)
skewed = src.transform((skew_w, src.height), Image.Transform.AFFINE, (1, ITALIC_SHEAR, -round(src.height * ITALIC_SHEAR), 0, 1, 0), Image.Resampling.BICUBIC)
bbox = skewed.getbbox()
if not bbox:
return
skewed = skewed.crop(bbox)
paste_at = (base_pos[0], base_pos[1] - pad - base_ascent + bbox[1])
if target.mode == "RGBA":
target.alpha_composite(skewed, paste_at)
else:
target.paste(skewed.convert("RGB"), paste_at, skewed.getchannel("A"))
draw_base(pos)
mark_font = FONTS.unicode_text(font_px(segment_font))
mark_ascent = mark_font.getmetrics()[0] if hasattr(mark_font, "getmetrics") else font_px(mark_font)
above = 0
below = 0
step = max(2, round(font_px(segment_font) * 0.12))
center_x = pos[0] + base_w // 2
for mark in marks:
combining = unicodedata.combining(mark)
if combining in {0, 1, 202, 220, 240}:
below += 1
y = pos[1] + below * step
else:
above += 1
y = pos[1] - above * step
# Rendering a combining mark alone produces a dotted-circle placeholder
# in many fonts. Render it attached to its base, subtract the base glyph,
# then place the extracted mark so it can spill outside the text line.
pad = max(8, font_px(segment_font) * 2)
patch_size = max(32, font_px(segment_font) * 5)
full = Image.new("L", (patch_size, patch_size), 0)
base_mask = Image.new("L", (patch_size, patch_size), 0)
full_draw = ImageDraw.Draw(full)
base_draw = ImageDraw.Draw(base_mask)
base_pos = (pad, pad + mark_ascent)
full_draw.text(base_pos, base + mark, fill=255, font=mark_font, anchor="ls")
base_draw.text(base_pos, base, fill=255, font=mark_font, anchor="ls")
mark_mask = ImageChops.subtract(full, base_mask)
bbox = mark_mask.getbbox()
if bbox:
mark_crop = mark_mask.crop(bbox)
mark_img = Image.new("RGBA", mark_crop.size, (*fill, 0))
mark_img.putalpha(mark_crop)
paste_at = (round(center_x - mark_crop.width / 2), round(y - mark_crop.height / 2))
if target.mode == "RGBA":
target.alpha_composite(mark_img, paste_at)
else:
target.paste(mark_img.convert("RGB"), paste_at, mark_img.getchannel("A"))
return advance_w
def draw_run(target: Image.Image, pos: tuple[int, int]) -> None:
target_draw = ImageDraw.Draw(target)
cursor_x = pos[0]
for segment, segment_font in text_segments(text, font):
for cluster in text_clusters(segment):
if is_zalgo_cluster(cluster):
advance = draw_zalgo_cluster(target, target_draw, (cursor_x, pos[1]), cluster, segment_font, color)
for dx, dy in bold_offsets:
draw_zalgo_cluster(target, target_draw, (cursor_x + dx, pos[1] + dy), cluster, segment_font, color)
cursor_x += advance
else:
target_draw.text((cursor_x, pos[1]), cluster, fill=color, font=segment_font, anchor="ls")
for dx, dy in bold_offsets:
target_draw.text((cursor_x + dx, pos[1] + dy), cluster, fill=color, font=segment_font, anchor="ls")
cursor_x += text_width(target_draw, cluster, font, set())
has_zalgo = any(is_zalgo_cluster(cluster) for cluster in text_clusters(text))
if "i" not in styles or has_zalgo:
draw_run(image, (xy[0], xy[1] + ascent))
return
width = max(1, text_width(ImageDraw.Draw(image), text, font, styles))
pad = max(4, round(font_px(font) * 2.4))
src = Image.new("RGBA", (width + pad * 2, line_h + pad * 2), (0, 0, 0, 0))
draw_run(src, (pad, pad + ascent))
skew_w = src.width + round(src.height * ITALIC_SHEAR)
skewed = src.transform((skew_w, src.height), Image.Transform.AFFINE, (1, ITALIC_SHEAR, -round(src.height * ITALIC_SHEAR), 0, 1, 0), Image.Resampling.BICUBIC)
bbox = skewed.getbbox()
if not bbox:
return
skewed = skewed.crop(bbox)
paste_at = (xy[0], xy[1] - pad + bbox[1])
if image.mode == "RGBA":
image.alpha_composite(skewed, paste_at)
else:
image.paste(skewed.convert("RGB"), paste_at, skewed.getchannel("A"))
def wrap_runs(
draw: ImageDraw.ImageDraw,
runs: list[tuple[str, set[str]]],
font: ImageFont.ImageFont,
max_width: int,
) -> list[list[tuple[str, set[str]]]]:
lines: list[list[tuple[str, set[str]]]] = []
current: list[tuple[str, set[str]]] = []
current_w = 0
for token, styles in split_words(runs):
if token == "\n":
lines.append(current)
current = []
current_w = 0
continue
if token.isspace():
if current:
current.append((token, styles))
current_w += text_width(draw, token, font, styles)
continue
token_w = text_width(draw, token, font, styles)
should_wrap = current_w + token_w > max_width
if current and should_wrap:
while current and current[-1][0].isspace():
current.pop()
lines.append(current)
current = []
current_w = 0
current.append((token, styles))
current_w += token_w
if current or not lines:
while current and current[-1][0].isspace():
current.pop()
lines.append(current)
return lines
def draw_text_box(
image: Image.Image,
text: str,
box: tuple[int, int, int, int],
font: ImageFont.ImageFont,
color: tuple[int, int, int],
align_x: float,
align_y: float,
paragraph_margin: int = 10,
indent_chars: int = TEXT_INDENT_CHARS,
spacing_scale: float = 1.0,
) -> int:
draw = ImageDraw.Draw(image)
x, y, width, height = box
paragraphs = text.split("\n")
paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = []
total_h = 0
line_h = max(1, round(text_line_height(font) * spacing_scale))
paragraph_margin = max(0, round(paragraph_margin * spacing_scale))
indent_px = text_width(draw, " " * indent_chars, font, set())
for paragraph in paragraphs:
indent = indent_px if paragraph.startswith("\t") else 0
paragraph = paragraph.lstrip("\t")
if paragraph == "---":
lines = [[("---", set())]]
else:
lines = wrap_runs(draw, parse_runs(paragraph), font, max(1, width - indent))
paragraph_lines.append((lines, indent))
total_h += len(lines) * line_h + paragraph_margin
if paragraph_lines:
total_h -= paragraph_margin
cursor_y = y + int(height * align_y - total_h * align_y)
for lines, indent in paragraph_lines:
for line in lines:
if len(line) == 1 and line[0][0] == "---":
draw.line((x, cursor_y + line_h // 2, x + width, cursor_y + line_h // 2), fill=color, width=2)
cursor_y += line_h
continue
line_text_w = sum(text_width(draw, run_text, font, styles) for run_text, styles in line)
line_width = max(1, width - indent)
cursor_x = x + indent + int(line_width * align_x - line_text_w * align_x)
for run_text, styles in line:
if not run_text:
continue
draw_styled_text(image, (cursor_x, cursor_y), run_text, font, color, styles, max(line_h, text_line_height(font)))
cursor_x += text_width(draw, run_text, font, styles)
cursor_y += line_h
cursor_y += paragraph_margin
return total_h
def measure_wrapped_text_box(
image: Image.Image,
text: str,
font: ImageFont.ImageFont,
width: int,
paragraph_margin: int = 10,
indent_chars: int = TEXT_INDENT_CHARS,
spacing_scale: float = 1.0,
) -> tuple[list[tuple[list[list[tuple[str, set[str]]]], int]], int, int]:
draw = ImageDraw.Draw(image)
paragraphs = text.split("\n")
paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = []
total_h = 0
line_h = max(1, round(text_line_height(font) * spacing_scale))
paragraph_margin = max(0, round(paragraph_margin * spacing_scale))
indent_px = text_width(draw, " " * indent_chars, font, set())
for paragraph in paragraphs:
indent = indent_px if paragraph.startswith("\t") else 0
paragraph = paragraph.lstrip("\t")
lines = wrap_runs(draw, parse_runs(paragraph), font, max(1, width - indent))
paragraph_lines.append((lines, indent))
total_h += len(lines) * line_h + paragraph_margin
if paragraph_lines:
total_h -= paragraph_margin
return paragraph_lines, total_h, line_h
def draw_lines(
image: Image.Image,
paragraph_lines: list[tuple[list[list[tuple[str, set[str]]]], int]],
x: int,
y: int,
width: int,
font: ImageFont.ImageFont,
color: tuple[int, int, int],
line_h: int,
paragraph_margin: int = 10,
) -> None:
draw = ImageDraw.Draw(image)
cursor_y = y
for lines, indent in paragraph_lines:
for line in lines:
cursor_x = x + indent
for run_text, styles in line:
if not run_text:
continue
if len(line) == 1 and run_text == "---":
draw.line((x, cursor_y + line_h // 2, x + width, cursor_y + line_h // 2), fill=color, width=2)
continue
draw_styled_text(image, (cursor_x, cursor_y), run_text, font, color, styles, max(line_h, text_line_height(font)))
cursor_x += text_width(draw, run_text, font, styles)
cursor_y += line_h
cursor_y += paragraph_margin
def flavour_rule_metrics(font: ImageFont.ImageFont, spacing_scale: float = 1.0) -> tuple[int, int, int]:
scale = max(1.0, font.size / 24.0) if hasattr(font, "size") else 1.0
rule_gap = max(0, round(8 * scale * spacing_scale))
rule_h = max(1, round(1 * scale))
hook_h = max(1, round(4 * scale * spacing_scale))
return rule_gap, rule_h, hook_h
def measure_flavour_block(
image: Image.Image,
flavour_text: str,
font: ImageFont.ImageFont,
width: int,
paragraph_margin: int = 10,
indent_chars: int = TEXT_INDENT_CHARS,
spacing_scale: float = 1.0,
bottom_gap: int = 0,
) -> dict:
paragraph_lines, text_total_h, line_h = measure_wrapped_text_box(
image,
flavour_text,
font,
width,
paragraph_margin,
indent_chars,
spacing_scale,
)
scaled_paragraph_margin = max(0, round(paragraph_margin * spacing_scale))
scaled_bottom_gap = max(0, round(bottom_gap * spacing_scale))
rule_gap, rule_h, hook_h = flavour_rule_metrics(font, spacing_scale)
total_h = max(rule_h, hook_h) + rule_gap + text_total_h + scaled_bottom_gap
return {
"paragraph_lines": paragraph_lines,
"text_total_h": text_total_h,
"line_h": line_h,
"paragraph_margin": scaled_paragraph_margin,
"bottom_gap": scaled_bottom_gap,
"rule_gap": rule_gap,
"rule_h": rule_h,
"hook_h": hook_h,
"total_h": total_h,
}
def draw_flavour_block(
image: Image.Image,
block: dict,
x: int,
top: int,
width: int,
font: ImageFont.ImageFont,
color: tuple[int, int, int],
) -> None:
draw = ImageDraw.Draw(image)
rule_y = top
rule_h = block["rule_h"]
hook_h = block["hook_h"]
draw.line((x, rule_y, x + width, rule_y), fill=color, width=rule_h)
draw.line((x, rule_y, x, rule_y + hook_h), fill=color, width=rule_h)
draw_lines(
image,
block["paragraph_lines"],
x,
rule_y + max(rule_h, hook_h) + block["rule_gap"],
width,
font,
color,
block["line_h"],
block["paragraph_margin"],
)
def draw_body_and_flavour_text(
image: Image.Image,
body_text: str,
flavour_text: str,
box: tuple[int, int, int, int],
body_font: ImageFont.ImageFont,
flavour_font: ImageFont.ImageFont,
color: tuple[int, int, int],
paragraph_margin: int,
indent_chars: int = TEXT_INDENT_CHARS,
flavour_bottom_gap: int = 0,
) -> None:
x, y, width, height = box
def measure(spacing_scale: float):
body_lines: list[tuple[list[list[tuple[str, set[str]]]], int]] = []
body_h = 0
body_line_h = text_line_height(body_font)
body_margin = max(0, round(paragraph_margin * spacing_scale))
if body_text:
body_lines, body_h, body_line_h = measure_wrapped_text_box(
image,
body_text,
body_font,
width,
paragraph_margin,
indent_chars,
spacing_scale,
)
flavour_block = None
flavour_h = 0
if flavour_text:
flavour_block = measure_flavour_block(
image,
flavour_text,
flavour_font,
width,
paragraph_margin,
indent_chars,
spacing_scale,
flavour_bottom_gap,
)
flavour_h = flavour_block["total_h"]
return body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h
body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(1.0)
required_h = body_h + flavour_h
spacing_scale = 1.0 if required_h <= 0 or required_h <= height else max(0.05, height / required_h)
if spacing_scale < 1.0:
body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(spacing_scale)
for _ in range(3):
scaled_required_h = body_h + flavour_h
if scaled_required_h <= height or spacing_scale <= 0.05:
break
spacing_scale = max(0.05, spacing_scale * height / scaled_required_h * 0.99)
body_lines, body_h, body_line_h, body_margin, flavour_block, flavour_h = measure(spacing_scale)
if flavour_block is not None:
flavour_top = y + height - flavour_h
draw_flavour_block(image, flavour_block, x, flavour_top, width, flavour_font, color)
body_area_h = max(1, flavour_top - y)
else:
body_area_h = height
if body_text:
body_y = y + max(0, round((body_area_h - body_h) / 2))
draw_lines(image, body_lines, x, body_y, width, body_font, color, body_line_h, body_margin)