Initial Blockminers printer
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Blockminers card rendering and print layout tools."""
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
@@ -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
@@ -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 [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"/\* *", "/* ", text)
|
||||
text = re.sub(r" *\*/", " */", 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)
|
||||
Reference in New Issue
Block a user