Refine rendering and packing logic

This commit is contained in:
ajp_anton
2026-06-03 00:10:39 +00:00
parent 774de1c4f5
commit 1696476025
8 changed files with 1073 additions and 660 deletions
+1
View File
@@ -44,6 +44,7 @@ The final PDF is written to `print/`. Temporary rendered images are written to `
- `--render-only`: render card and board PNGs, but skip page PNGs and PDF creation. Default: off. - `--render-only`: render card and board PNGs, but skip page PNGs and PDF creation. Default: off.
- `--output NAME`: write the PDF as `print/NAME`; `.pdf` is added if missing. Default filename: `blockminers_printable.pdf`. - `--output NAME`: write the PDF as `print/NAME`; `.pdf` is added if missing. Default filename: `blockminers_printable.pdf`.
- `--rent-raised N`: print `N` copies of `Rent raised` and matching backs. Default: `3`. - `--rent-raised N`: print `N` copies of `Rent raised` and matching backs. Default: `3`.
- `--text-supersampling N`: render text at `N` times final resolution before downscaling. Default: `3`.
- `--keep-temp`: keep `print/temp/` after PDF creation. Default: delete temp files after PDF creation. - `--keep-temp`: keep `print/temp/` after PDF creation. Default: delete temp files after PDF creation.
- `--starter-colors {separate,same}`: use separate starter badge colors or one repeated color per starter deck. Default: `separate`. - `--starter-colors {separate,same}`: use separate starter badge colors or one repeated color per starter deck. Default: `separate`.
-6
View File
@@ -94,12 +94,6 @@ def cover_resize(img: Image.Image, size: tuple[int, int]) -> Image.Image:
return resized.crop((left, top, left + target_w, top + target_h)) 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: def meta_unit_scale(meta: dict) -> float:
return float(meta.get("cardWidth", 825)) / 825.0 return float(meta.get("cardWidth", 825)) / 825.0
-24
View File
@@ -1,6 +1,5 @@
from pathlib import Path from pathlib import Path
from .assets import load_json, load_rgb
from .settings import BOARD_DECK, DECKS_DIR, BuildConfig from .settings import BOARD_DECK, DECKS_DIR, BuildConfig
ORIGINAL_BOARD_MM = (88.9, 139.7) ORIGINAL_BOARD_MM = (88.9, 139.7)
@@ -45,17 +44,6 @@ def board_target_mm_for_mode(
return BOARD_SIZE_OPTIONS_MM.get(board_mode, ORIGINAL_BOARD_MM) return BOARD_SIZE_OPTIONS_MM.get(board_mode, ORIGINAL_BOARD_MM)
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]: 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) return board_target_mm_for_mode(board_path, config.board_mode, config.board_custom_mm)
@@ -78,15 +66,3 @@ def board_size_fits_paper(
if not (fits_upright or fits_rotated): if not (fits_upright or fits_rotated):
return False return False
return True return True
def board_mode_fits_paper(board_mode: str, paper_mm: tuple[float, float], margin_mm: float) -> bool:
return board_size_fits_paper(board_mode, None, paper_mm, margin_mm)
def crop_board_bleed(img):
target_w = round(img.width * (3.5 / 3.75))
target_h = round(img.height * (5.5 / 5.75))
left = max(0, (img.width - target_w) // 2)
top = max(0, (img.height - target_h) // 2)
return img.crop((left, top, left + target_w, top + target_h))
+268 -343
View File
@@ -1,26 +1,27 @@
import math
import shutil import shutil
from concurrent.futures import ProcessPoolExecutor from concurrent.futures import ProcessPoolExecutor
from pathlib import Path from pathlib import Path
from PIL import Image, ImageChops, ImageDraw from PIL import Image, 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 .assets import cover_resize, find_art_for_json, hex_color, load_json, load_rgb, meta_unit_scale, safe_name, save_temp_png
from .boards import board_target_mm, crop_board_bleed from .boards import board_target_mm
from .settings import ( from .settings import (
BASE_CARD_SIZE,
BASEMENT_SINGLE_STARTER_COLOR, BASEMENT_SINGLE_STARTER_COLOR,
BOARD_DECK, BOARD_DECK,
CARD_DECKS, CARD_DECKS,
CHARACTER_DECK, CHARACTER_DECK,
CHARACTER_EDGE_REPLACE_PX,
CHECK_COLORS, CHECK_COLORS,
DEFAULT_SOURCE_CROP_PX,
DECK_GROUPS,
DECKS_DIR, DECKS_DIR,
FIXED_CROP, FIXED_CROP,
OFFICE_SINGLE_STARTER_COLOR, OFFICE_SINGLE_STARTER_COLOR,
PLAYER_COLORS, PLAYER_COLORS,
RENDER_DIR, RENDER_DIR,
SIDE_EDGE_REPLACE_BANDS,
STARTER_DECKS, STARTER_DECKS,
TITLE_WIDTH_LIMITS,
TOP_BOTTOM_EDGE_REPLACE_PX, TOP_BOTTOM_EDGE_REPLACE_PX,
BuildConfig, BuildConfig,
PrintItem, PrintItem,
@@ -39,7 +40,6 @@ from .text import (
text_line_height, text_line_height,
th, th,
title_font_for_target_width, title_font_for_target_width,
title_width_limit,
transformed_text_area, transformed_text_area,
tw, tw,
tx, tx,
@@ -82,26 +82,13 @@ def draw_banner_text(
def draw_banner( def draw_banner(
image: Image.Image, image: Image.Image,
meta: dict, meta: dict,
text: str,
y_offset: int, y_offset: int,
fill: tuple[int, int, int], fill: tuple[int, int, int],
text_color: tuple[int, int, int] | None = None,
draw_text: bool = True,
) -> None: ) -> None:
draw_banner_shape(image, meta, y_offset, fill) 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: def draw_starter_banner(image: Image.Image, meta: dict, y_offset: int, color: str) -> None:
draw = ImageDraw.Draw(image) draw = ImageDraw.Draw(image)
scale = meta_unit_scale(meta) scale = meta_unit_scale(meta)
x = int(meta["checkOffsetX"]) x = int(meta["checkOffsetX"])
@@ -110,14 +97,193 @@ def draw_starter_banner(image: Image.Image, meta: dict, y_offset: int, color: st
h = int(meta["checkHeight"]) h = int(meta["checkHeight"])
black = (0, 0, 0) black = (0, 0, 0)
banner = hex_color(color) banner = hex_color(color)
draw_banner(image, meta, "STARTER", y_offset, black, text_color=(255, 255, 255), draw_text=draw_text) draw_banner(image, meta, y_offset, black)
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(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 * 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) 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)) def card_source_crop(config: BuildConfig) -> tuple[float, float, float, float]:
draw_text_box(image, "STARTER", (x + round(50 * scale), y, w, h), font, (255, 255, 255), 0.0, 0.5, paragraph_margin=0) if config.crop_black_borders:
return tuple(float(value) for value in FIXED_CROP)
crop = float(DEFAULT_SOURCE_CROP_PX)
return crop, crop, crop, crop
def board_source_crop() -> tuple[float, float, float, float]:
crop = float(DEFAULT_SOURCE_CROP_PX)
return crop, crop, crop, crop
def bleed_px(config: BuildConfig) -> int:
return mm_to_px(config.bleed_mm, config.dpi)
def replace_source_dirty_pixels(img: Image.Image, crop: tuple[float, float, float, float]) -> Image.Image:
out = img.copy()
width, height = out.size
left_crop, top_crop, right_crop, bottom_crop = crop
crop_height = height - top_crop - bottom_crop
top_ref = min(height - 1, math.ceil(top_crop) + TOP_BOTTOM_EDGE_REPLACE_PX)
if top_ref > 0:
row = out.crop((0, top_ref, width, top_ref + 1))
for y in range(0, top_ref):
out.paste(row, (0, y))
bottom_ref = max(0, height - math.ceil(bottom_crop) - TOP_BOTTOM_EDGE_REPLACE_PX - 1)
if bottom_ref < height - 1:
row = out.crop((0, bottom_ref, width, bottom_ref + 1))
for y in range(bottom_ref + 1, height):
out.paste(row, (0, y))
dirty_pixels_by_segment = [10, 3, 1, 3, 10]
transitions = [0]
for index in range(1, len(dirty_pixels_by_segment)):
transitions.append(round(top_crop + crop_height * index / len(dirty_pixels_by_segment)))
transitions.append(height)
for index, dirty_pixels in enumerate(dirty_pixels_by_segment):
segment_top = max(0, transitions[index])
segment_bottom = min(height, transitions[index + 1])
if segment_bottom <= segment_top:
continue
ref_left = min(width - 1, math.ceil(left_crop) + dirty_pixels)
ref_right = max(0, width - math.ceil(right_crop) - dirty_pixels - 1)
left_strip = out.crop((ref_left, segment_top, ref_left + 1, segment_bottom)).resize((ref_left, segment_bottom - segment_top))
right_width = width - ref_right - 1
if ref_left > 0:
out.paste(left_strip, (0, segment_top))
if right_width > 0:
right_strip = out.crop((ref_right, segment_top, ref_right + 1, segment_bottom)).resize((right_width, segment_bottom - segment_top))
out.paste(right_strip, (ref_right + 1, segment_top))
return out
def transformed_print_image(
source: Image.Image,
target_size: tuple[int, int],
config: BuildConfig,
crop: tuple[float, float, float, float],
) -> tuple[Image.Image, int]:
target_w, target_h = target_size
left_crop, top_crop, right_crop, bottom_crop = crop
crop_w = float(source.width) - left_crop - right_crop
crop_h = float(source.height) - top_crop - bottom_crop
sx = float(target_w) / crop_w
sy = float(target_h) / crop_h
bleed = bleed_px(config)
source_bleed_x = float(bleed) / sx
source_bleed_y = float(bleed) / sy
bleed_left = left_crop - source_bleed_x
bleed_top = top_crop - source_bleed_y
bleed_right = left_crop + crop_w + source_bleed_x
bleed_bottom = top_crop + crop_h + source_bleed_y
full_size = (target_w + 2 * bleed, target_h + 2 * bleed)
transformed = source.convert("RGB").transform(
full_size,
Image.Transform.EXTENT,
(bleed_left, bleed_top, bleed_right, bleed_bottom),
Image.Resampling.BICUBIC,
)
return transformed, bleed
def text_layout_for_full_bleed(meta: dict, target_size: tuple[int, int], crop: tuple[float, float, float, float], config: BuildConfig) -> tuple[dict, int]:
target_w, target_h = target_size
left_crop, top_crop, right_crop, bottom_crop = crop
crop_w = float(meta["cardWidth"]) - left_crop - right_crop
crop_h = float(meta["cardHeight"]) - top_crop - bottom_crop
sx = float(target_w) / crop_w
sy = float(target_h) / crop_h
bleed = bleed_px(config)
bleed_left = left_crop - float(bleed) / sx
bleed_top = top_crop - float(bleed) / sy
ss = config.text_supersampling
layout = standard_text_layout(
meta,
(target_w + 2 * bleed, target_h + 2 * bleed),
False,
crop_box=(bleed_left, bleed_top, 0.0, 0.0),
scale=(sx * ss, sy * ss),
)
layout["target_w"] = (target_w + 2 * bleed) * ss
layout["target_h"] = (target_h + 2 * bleed) * ss
layout["crop_w"] = crop_w
layout["crop_h"] = crop_h
return layout, bleed
def draw_corner_bleed_marks(img: Image.Image, config: BuildConfig, group: str, is_character: bool) -> Image.Image:
bleed = bleed_px(config)
if bleed <= 0:
return img
out = img.copy()
draw = ImageDraw.Draw(out)
size = 2 * bleed
if out.width < size or out.height < size:
return out
def color_for(corner: str) -> tuple[int, int, int]:
if is_character or not config.crop_black_borders:
return (255, 255, 255)
return (0, 0, 0) if corner.startswith("top") else (255, 255, 255)
w, h = out.size
b = bleed
def fill_top_left(color: tuple[int, int, int]) -> None:
draw.rectangle((0, 0, 2 * b - 1, b - 1), fill=color)
draw.rectangle((0, b, b - 1, 2 * b - 1), fill=color)
for y in range(b):
draw.line((b, b + y, 2 * b - y - 1, b + y), fill=color)
def fill_top_right(color: tuple[int, int, int]) -> None:
draw.rectangle((w - 2 * b, 0, w - 1, b - 1), fill=color)
draw.rectangle((w - b, b, w - 1, 2 * b - 1), fill=color)
for y in range(b):
draw.line((w - 2 * b + y, b + y, w - b - 1, b + y), fill=color)
def fill_bottom_left(color: tuple[int, int, int]) -> None:
draw.rectangle((0, h - b, 2 * b - 1, h - 1), fill=color)
draw.rectangle((0, h - 2 * b, b - 1, h - b - 1), fill=color)
for y in range(b):
draw.line((b, h - 2 * b + y, b + y, h - 2 * b + y), fill=color)
def fill_bottom_right(color: tuple[int, int, int]) -> None:
draw.rectangle((w - 2 * b, h - b, w - 1, h - 1), fill=color)
draw.rectangle((w - b, h - 2 * b, w - 1, h - b - 1), fill=color)
for y in range(b):
draw.line((w - b - 1 - y, h - 2 * b + y, w - b - 1, h - 2 * b + y), fill=color)
for corner, fill in (
("top_left", fill_top_left),
("top_right", fill_top_right),
("bottom_left", fill_bottom_left),
("bottom_right", fill_bottom_right),
):
fill(color_for(corner))
return out
def split_face_from_bleed(full: Image.Image, target_size: tuple[int, int], bleed: int) -> Image.Image:
return full.crop((bleed, bleed, bleed + target_size[0], bleed + target_size[1]))
def save_print_images(
full: Image.Image,
face_out: Path,
bleed_out: Path,
target_size: tuple[int, int],
bleed: int,
config: BuildConfig,
) -> None:
face = split_face_from_bleed(full, target_size, bleed)
face_out.parent.mkdir(parents=True, exist_ok=True)
bleed_out.parent.mkdir(parents=True, exist_ok=True)
save_temp_png(face, face_out, config.dpi)
save_temp_png(full, bleed_out, config.dpi)
def render_card_image( def render_card_image(
@@ -127,9 +293,10 @@ def render_card_image(
front_template: Image.Image, front_template: Image.Image,
starter_color: str | None, starter_color: str | None,
target_size: tuple[int, int], target_size: tuple[int, int],
crop_edges_before_text: bool = False, config: BuildConfig,
) -> Image.Image: ) -> Image.Image:
layout = standard_text_layout(meta, target_size, crop_edges_before_text) crop = card_source_crop(config)
layout, _ = text_layout_for_full_bleed(meta, target_size, crop, config)
source_canvas = Image.new("RGB", (int(meta["cardWidth"]), int(meta["cardHeight"])), (0, 0, 0)).convert("RGBA") 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]]] = [] banner_texts: list[tuple[str, int, tuple[int, int, int]]] = []
@@ -140,11 +307,11 @@ def render_card_image(
check_offset = 0 check_offset = 0
if starter_color is not None: if starter_color is not None:
draw_starter_banner(source_canvas, meta, check_offset, starter_color, draw_text=False) draw_starter_banner(source_canvas, meta, check_offset, starter_color)
banner_texts.append(("STARTER", check_offset, (255, 255, 255))) banner_texts.append(("STARTER", check_offset, (255, 255, 255)))
check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"]) check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
if card.get("encounterThreshold") is not None: if card.get("encounterThreshold") is not None:
draw_banner(source_canvas, meta, f"Threat {card['encounterThreshold']}", check_offset, hex_color("#ead500"), draw_text=False) draw_banner(source_canvas, meta, check_offset, hex_color("#ead500"))
banner_texts.append((f"Threat {card['encounterThreshold']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff"))) 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"]) 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: if card.get("checkLevel") is not None and card.get("checkType") is not None:
@@ -152,36 +319,40 @@ def render_card_image(
draw_banner( draw_banner(
source_canvas, source_canvas,
meta, meta,
f"{card['checkType']} {card['checkLevel']}",
check_offset, check_offset,
check_color, check_color,
draw_text=False,
) )
banner_texts.append((f"{card['checkType']} {card['checkLevel']}", check_offset, hex_color(meta.get("foregroundColor"), "#ffffff"))) 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"]) check_offset += round(10 * meta_unit_scale(meta)) + int(meta["checkHeight"])
if card.get("type") == "VIRUS": 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) draw_banner(source_canvas, meta, check_offset, (255, 255, 255))
banner_texts.append(("VIRUS", check_offset, hex_color(meta.get("foregroundColor"), "#000000"))) banner_texts.append(("VIRUS", check_offset, hex_color(meta.get("foregroundColor"), "#000000")))
source_canvas.alpha_composite(front_template.convert("RGBA"), (0, 0)) source_canvas.alpha_composite(front_template.convert("RGBA"), (0, 0))
if crop_edges_before_text: source_without_text = source_canvas.convert("RGB")
canvas = replace_standard_card_edges(fixed_crop_card(source_canvas)).convert("RGBA") if config.crop_black_borders:
else: source_without_text = replace_source_dirty_pixels(source_without_text, crop)
canvas = source_canvas if config.gamma_enabled:
canvas = canvas.convert("RGB").resize(target_size, Image.Resampling.LANCZOS).convert("RGBA") source_without_text = apply_gamma(source_without_text, config.gamma, config.black_point)
canvas, _ = transformed_print_image(source_without_text, target_size, config, crop)
canvas = canvas.convert("RGBA")
ss = config.text_supersampling
text_layer = Image.new("RGBA", (canvas.width * ss, canvas.height * ss), (0, 0, 0, 0))
for banner_text, y_offset, text_color in banner_texts: for banner_text, y_offset, text_color in banner_texts:
draw_banner_text(canvas, meta, layout, banner_text, y_offset, text_color) draw_banner_text(text_layer, meta, layout, banner_text, y_offset, text_color)
title = card.get("title") title = card.get("title")
if title: if title:
max_title_width = tw(layout, title_width_limit(deck_dir, meta)) group = DECK_GROUPS.get(deck_dir.name, "")
ratio = TITLE_WIDTH_LIMITS.get(group, 0.94)
max_title_width = math.floor(float(layout["crop_w"]) * layout["sx"] * ratio)
font = title_font_for_target_width(str(title), meta, layout["sy"], max_title_width) font = title_font_for_target_width(str(title), meta, layout["sy"], max_title_width)
title_color = hex_color(meta.get("titleColor"), "#000000") title_color = hex_color(meta.get("titleColor"), "#000000")
title_y = ty(layout, int(meta["titleOffset"])) title_y = ty(layout, int(meta["titleOffset"]))
title_box_h = th(layout, 16) 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) draw_text_box(text_layer, title, (0, title_y, text_layer.width, title_box_h), font, title_color, 0.5, 0.5, 0, 0)
fg = hex_color(meta.get("foregroundColor"), "#ffffff") fg = hex_color(meta.get("foregroundColor"), "#ffffff")
text_left = int(meta["textPadding"]) text_left = int(meta["textPadding"])
@@ -194,10 +365,12 @@ def render_card_image(
flavour_text = format_flavour_text(card.get("flavourText")) flavour_text = format_flavour_text(card.get("flavourText"))
body_text = format_card_text(card.get("text")) body_text = format_card_text(card.get("text"))
if body_text or flavour_text: if body_text or flavour_text:
body_font = FONTS.text(font_size_y(layout, int(meta.get("fontSize") or 32))) body_source_size = int(meta.get("fontSize") or 32)
flavour_font = FONTS.text(font_size_y(layout, int(meta.get("flavourTextFontSize") or 24))) flavour_source_size = int(meta.get("flavourTextFontSize") or 24)
body_font = FONTS.text(font_size_y(layout, body_source_size), body_source_size)
flavour_font = FONTS.text(font_size_y(layout, flavour_source_size), flavour_source_size)
draw_body_and_flavour_text( draw_body_and_flavour_text(
canvas, text_layer,
body_text, body_text,
flavour_text, flavour_text,
(text_x, text_y, text_w, text_h), (text_x, text_y, text_w, text_h),
@@ -209,13 +382,13 @@ def render_card_image(
flavour_bottom_gap=flavour_bottom_gap, flavour_bottom_gap=flavour_bottom_gap,
) )
old_footer_font = FONTS.text(font_size_y(layout, 16)) old_footer_font = FONTS.text(font_size_y(layout, 16), 16)
small = FONTS.text(font_size_y(layout, 16 * 0.9)) small = FONTS.text(font_size_y(layout, 16 * 0.9), 16 * 0.9)
footer_top_compensation = round((text_line_height(old_footer_font) - text_line_height(small)) / 2) 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 footer_y = ty(layout, int(meta["cardHeight"]) - 95) - footer_top_compensation
if config_project_version := card.get("_project_version"): if config_project_version := card.get("_project_version"):
draw_text_box( draw_text_box(
canvas, text_layer,
str(config_project_version), str(config_project_version),
( (
tx(layout, int(meta["cardWidth"]) - 252), tx(layout, int(meta["cardWidth"]) - 252),
@@ -231,7 +404,7 @@ def render_card_image(
) )
if card.get("credit"): if card.get("credit"):
draw_text_box( draw_text_box(
canvas, text_layer,
str(card["credit"]), str(card["credit"]),
( (
tx(layout, 100), tx(layout, 100),
@@ -245,230 +418,12 @@ def render_card_image(
0.5, 0.5,
0, 0,
) )
text_layer = text_layer.resize(canvas.size, Image.Resampling.LANCZOS)
canvas.alpha_composite(text_layer)
canvas = draw_corner_bleed_marks(canvas.convert("RGB"), config, DECK_GROUPS.get(deck_dir.name, ""), False).convert("RGBA")
return canvas.convert("RGB") 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: def apply_gamma(img: Image.Image, gamma: float, black_point: float) -> Image.Image:
scale = max(1e-9, 1.0 - black_point) scale = max(1e-9, 1.0 - black_point)
lut = [] lut = []
@@ -478,37 +433,6 @@ def apply_gamma(img: Image.Image, gamma: float, black_point: float) -> Image.Ima
return img.point(lut * 3) 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: def render_standard_front_job(job: dict) -> Path:
deck_dir = job["deck_dir"] deck_dir = job["deck_dir"]
meta = job["meta"] meta = job["meta"]
@@ -517,19 +441,18 @@ def render_standard_front_job(job: dict) -> Path:
card_px = job["card_px"] card_px = job["card_px"]
starter_color = job["starter_color"] starter_color = job["starter_color"]
out = job["out"] out = job["out"]
bleed_out = job["bleed_out"]
front_template = load_rgb(deck_dir / "front.png") front_template = load_rgb(deck_dir / "front.png")
image = render_card_image( full = render_card_image(
deck_dir, deck_dir,
meta, meta,
card, card,
front_template, front_template,
starter_color, starter_color,
card_px, card_px,
crop_edges_before_text=config.crop_black_borders, config,
) )
adjusted = apply_print_adjustments(image, config, card_px, "rendered_standard_card") save_print_images(full, out, bleed_out, card_px, bleed_px(config), config)
out.parent.mkdir(parents=True, exist_ok=True)
save_temp_png(adjusted, out, config.dpi)
return out return out
@@ -547,7 +470,6 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
deck_dir = DECKS_DIR / deck_name deck_dir = DECKS_DIR / deck_name
meta = load_json(deck_dir / "deck.json") meta = load_json(deck_dir / "deck.json")
back_template = load_rgb(deck_dir / "back.png").convert("RGB") 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") deck_cards = sorted(p for p in deck_dir.glob("*.json") if p.name != "deck.json")
rendered_fronts: list[Path] = [] rendered_fronts: list[Path] = []
front_jobs: list[dict] = [] front_jobs: list[dict] = []
@@ -561,12 +483,13 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
copies = config.rent_raised_count copies = config.rent_raised_count
is_starter = bool(card.get("isStarter")) is_starter = bool(card.get("isStarter"))
colors = starter_colors_for_deck(group, deck_name, meta, config) if is_starter else [None] colors = starter_colors_for_deck(group, deck_name, meta, config) if is_starter else [None]
rendered_by_color: dict[str | None, Path] = {} rendered_by_color: dict[str | None, tuple[Path, Path]] = {}
for color_index, starter_color in enumerate(colors): for color_index, starter_color in enumerate(colors):
out = rendered_by_color.get(starter_color) rendered = rendered_by_color.get(starter_color)
if out is None: if rendered is None:
out = RENDER_DIR / "cards" / group / "fronts" / f"{deck_name}_{safe_name(card.get('title', card_json.stem))}_{color_index}.png" 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 bleed_out = RENDER_DIR / "cards" / group / "fronts" / "bleed" / f"{deck_name}_{safe_name(card.get('title', card_json.stem))}_{color_index}.png"
rendered_by_color[starter_color] = (out, bleed_out)
front_jobs.append( front_jobs.append(
{ {
"deck_dir": deck_dir, "deck_dir": deck_dir,
@@ -576,30 +499,36 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
"card_px": card_px, "card_px": card_px,
"starter_color": starter_color, "starter_color": starter_color,
"out": out, "out": out,
"bleed_out": bleed_out,
} }
) )
else:
out, bleed_out = rendered
for _ in range(copies): for _ in range(copies):
rendered_fronts.append(out) rendered_fronts.append(out)
label = card.get("title", card_json.stem) label = card.get("title", card_json.stem)
card_items.append(PrintItem(out, group, label, "front", is_starter, label.lower())) card_items.append(PrintItem(out, group, label, "front", is_starter, label.lower(), bleed_out))
with ProcessPoolExecutor(max_workers=worker_count(len(front_jobs))) as executor: with ProcessPoolExecutor(max_workers=worker_count(len(front_jobs))) as executor:
list(executor.map(render_standard_front_job, front_jobs)) list(executor.map(render_standard_front_job, front_jobs))
back_work = back_template.resize( back_source = back_template
(round(back_template.width * work_scale), round(back_template.height * work_scale)), if config.crop_black_borders:
Image.Resampling.LANCZOS, back_source = replace_source_dirty_pixels(back_source, card_source_crop(config))
) if config.gamma_enabled:
adjusted_back = apply_print_adjustments(back_work, config, card_px, "standard_card") back_source = apply_gamma(back_source, config.gamma, config.black_point)
adjusted_back, back_bleed = transformed_print_image(back_source, card_px, config, card_source_crop(config))
adjusted_back = draw_corner_bleed_marks(adjusted_back, config, group, False)
back_out = RENDER_DIR / "cards" / group / "backs" / f"{deck_name}_back.png" back_out = RENDER_DIR / "cards" / group / "backs" / f"{deck_name}_back.png"
back_out.parent.mkdir(parents=True, exist_ok=True) back_bleed_out = RENDER_DIR / "cards" / group / "backs" / "bleed" / f"{deck_name}_back.png"
save_temp_png(adjusted_back, back_out, config.dpi) save_print_images(adjusted_back, back_out, back_bleed_out, card_px, back_bleed, config)
for i in range(len(rendered_fronts)): 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())) card_items.append(PrintItem(back_out, group, f"{deck_name} back {i + 1}", "back", False, deck_name.lower(), back_bleed_out))
char_dir = DECKS_DIR / CHARACTER_DECK char_dir = DECKS_DIR / CHARACTER_DECK
char_meta = load_json(char_dir / "deck.json") char_meta = load_json(char_dir / "deck.json")
char_layout = standard_text_layout(char_meta, card_px, config.crop_black_borders) char_crop = card_source_crop(config)
char_layout, char_bleed = text_layout_for_full_bleed(char_meta, card_px, char_crop, config)
for card_json in sorted(p for p in char_dir.glob("*.json") if p.name != "deck.json"): 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) art = find_art_for_json(card_json)
if art is None: if art is None:
@@ -608,11 +537,16 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
face = cover_resize(load_rgb(art), (int(char_meta["artWidth"]), int(char_meta["artHeight"]))) 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"]))) canvas.alpha_composite(face, (int(char_meta["artOffsetX"]), int(char_meta["artOffsetY"])))
character_source = canvas.convert("RGB") character_source = canvas.convert("RGB")
adjusted = apply_print_adjustments(character_source, config, card_px, "character_card") if config.gamma_enabled:
character_source = apply_gamma(character_source, config.gamma, config.black_point)
adjusted, char_bleed = transformed_print_image(character_source, card_px, config, char_crop)
adjusted = adjusted.convert("RGBA")
ss = config.text_supersampling
text_layer = Image.new("RGBA", (adjusted.width * ss, adjusted.height * ss), (0, 0, 0, 0))
if config.project_version: if config.project_version:
small = FONTS.text(font_size_y(char_layout, 16)) small = FONTS.text(font_size_y(char_layout, 16), 16)
draw_text_box( draw_text_box(
adjusted, text_layer,
config.project_version, config.project_version,
( (
tx(char_layout, int(char_meta["cardWidth"]) - 252), tx(char_layout, int(char_meta["cardWidth"]) - 252),
@@ -626,23 +560,12 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
0.5, 0.5,
0, 0,
) )
text_layer = text_layer.resize(adjusted.size, Image.Resampling.LANCZOS)
adjusted.alpha_composite(text_layer)
adjusted = draw_corner_bleed_marks(adjusted.convert("RGB"), config, "character", True)
out = RENDER_DIR / "cards" / "characters" / f"{safe_name(card_json.stem)}.png" 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)
corner_size = 2 * bleed_px
ImageDraw.Draw(bleed_img).rectangle(
(0, bleed_img.height - corner_size, corner_size - 1, bleed_img.height - 1),
fill=(0, 0, 0),
)
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 = RENDER_DIR / "cards" / "characters" / "bleed" / f"{safe_name(card_json.stem)}.png"
bleed_out.parent.mkdir(parents=True, exist_ok=True) save_print_images(adjusted, out, bleed_out, card_px, char_bleed, config)
save_temp_png(bleed_img, bleed_out, config.dpi)
# Character edges are unique and not cut-neighbor compatible, so each # Character edges are unique and not cut-neighbor compatible, so each
# duplicate is treated as its own print group. # 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}:1", card_json.stem, "front", False, card_json.stem.lower(), bleed_out))
@@ -652,12 +575,14 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem
for board_path in sorted(board_dir.glob("*-jumbo-front.png")): for board_path in sorted(board_dir.glob("*-jumbo-front.png")):
target_mm = board_target_mm(board_path, config) 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)) target_px = (mm_to_px(target_mm[0], config.dpi), mm_to_px(target_mm[1], config.dpi))
image = crop_board_bleed(load_rgb(board_path).convert("RGB")) image = load_rgb(board_path).convert("RGB")
adjusted = apply_print_adjustments(image, config, target_px, "board") if config.gamma_enabled:
image = apply_gamma(image, config.gamma, config.black_point)
adjusted, board_bleed = transformed_print_image(image, target_px, config, board_source_crop())
out = RENDER_DIR / "boards" / board_path.name out = RENDER_DIR / "boards" / board_path.name
out.parent.mkdir(parents=True, exist_ok=True) bleed_out = RENDER_DIR / "boards" / "bleed" / board_path.name
save_temp_png(adjusted, out, config.dpi) save_print_images(adjusted, out, bleed_out, target_px, board_bleed, config)
board_items.append(PrintItem(out, "board", board_path.stem, "board")) board_items.append(PrintItem(out, "board", board_path.stem, "board", False, "", bleed_out))
return card_items, board_items return card_items, board_items
+20 -2
View File
@@ -18,6 +18,7 @@ from .settings import (
RENDER_DIR, RENDER_DIR,
ROOT, ROOT,
TEMP_DIR, TEMP_DIR,
TEXT_SUPERSAMPLING,
BuildConfig, BuildConfig,
mm_to_px, mm_to_px,
) )
@@ -45,8 +46,8 @@ def build_printable(config: BuildConfig) -> Path | None:
cols, rows = grid_capacity(config) cols, rows = grid_capacity(config)
print(f"Card grid: {cols} columns x {rows} rows on {config.paper_name.upper()} ({cols * rows} images/page)") print(f"Card grid: {cols} columns x {rows} rows on {config.paper_name.upper()} ({cols * rows} images/page)")
next_page, deferred_character_items = render_card_pages(card_items, config, 1) next_page, deferred_character_items, remaining_board_items = render_card_pages(card_items, config, 1, board_items)
next_page = render_board_pages(board_items, config, next_page, deferred_character_items) next_page = render_board_pages(remaining_board_items, config, next_page, deferred_character_items)
pdf = write_pdf(config) pdf = write_pdf(config)
if pdf: if pdf:
print(f"Wrote {pdf.relative_to(ROOT)}") print(f"Wrote {pdf.relative_to(ROOT)}")
@@ -123,6 +124,16 @@ def prompt_positive_int(label: str, default: int) -> int:
return parsed return parsed
def positive_int_arg(value: str) -> int:
try:
parsed = int(value)
except ValueError as error:
raise argparse.ArgumentTypeError("must be a positive integer") from error
if parsed <= 0:
raise argparse.ArgumentTypeError("must be a positive integer")
return parsed
def prompt_nonnegative_float(label: str, default: float) -> float: def prompt_nonnegative_float(label: str, default: float) -> float:
value = input(f"{label} [{default:g}]: ").strip() value = input(f"{label} [{default:g}]: ").strip()
if not value: if not value:
@@ -263,6 +274,7 @@ def default_config() -> BuildConfig:
clean_edge_mm=0.35, clean_edge_mm=0.35,
output_pdf_name="", output_pdf_name="",
delete_temp=True, delete_temp=True,
text_supersampling=TEXT_SUPERSAMPLING,
render_only=False, render_only=False,
) )
@@ -328,6 +340,7 @@ def interactive_config(args: argparse.Namespace) -> BuildConfig:
clean_edge_mm=default.clean_edge_mm, clean_edge_mm=default.clean_edge_mm,
output_pdf_name=output_pdf_name, output_pdf_name=output_pdf_name,
delete_temp=None, delete_temp=None,
text_supersampling=args.text_supersampling or default.text_supersampling,
render_only=default.render_only, render_only=default.render_only,
) )
@@ -352,6 +365,7 @@ def parse_args(argv=None) -> argparse.Namespace:
parser.add_argument("--render-only", action="store_true", help="Render source card/board PNGs and skip page/PDF creation.") 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("--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("--rent-raised", type=int, help="Number of Rent raised encounter cards to print.")
parser.add_argument("--text-supersampling", type=positive_int_arg, metavar="N", help="Render text at N times final resolution before downscaling.")
parser.add_argument("--keep-temp", action="store_true", help="Keep temporary rendered PNGs under print/temp/.") parser.add_argument("--keep-temp", action="store_true", help="Keep temporary rendered PNGs under print/temp/.")
parser.add_argument( parser.add_argument(
"--starter-colors", "--starter-colors",
@@ -411,6 +425,7 @@ def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfi
clean_edge_mm=config.clean_edge_mm, clean_edge_mm=config.clean_edge_mm,
output_pdf_name=args.output if args.output is not None else config.output_pdf_name, output_pdf_name=args.output if args.output is not None else config.output_pdf_name,
delete_temp=delete_temp, delete_temp=delete_temp,
text_supersampling=args.text_supersampling or config.text_supersampling,
render_only=args.render_only or config.render_only, render_only=args.render_only or config.render_only,
) )
@@ -435,6 +450,7 @@ def with_board_size(config: BuildConfig, board_mode: str, board_custom_mm: tuple
clean_edge_mm=config.clean_edge_mm, clean_edge_mm=config.clean_edge_mm,
output_pdf_name=config.output_pdf_name, output_pdf_name=config.output_pdf_name,
delete_temp=config.delete_temp, delete_temp=config.delete_temp,
text_supersampling=config.text_supersampling,
render_only=config.render_only, render_only=config.render_only,
) )
@@ -475,6 +491,7 @@ def main(argv=None) -> None:
clean_edge_mm=config.clean_edge_mm, clean_edge_mm=config.clean_edge_mm,
output_pdf_name=config.output_pdf_name, output_pdf_name=config.output_pdf_name,
delete_temp=config.delete_temp, delete_temp=config.delete_temp,
text_supersampling=config.text_supersampling,
render_only=config.render_only, render_only=config.render_only,
) )
config = apply_overrides(config, args) config = apply_overrides(config, args)
@@ -495,6 +512,7 @@ def main(argv=None) -> None:
bleed: {config.bleed_mm} mm ({mm_to_px(config.bleed_mm, config.dpi)} px at {config.dpi} DPI) 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'} 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}'} gamma: {'off' if not config.gamma_enabled else f'{config.gamma} with black point {config.black_point}'}
text supersampling: {config.text_supersampling}
output: {'rendered PNGs only' if config.render_only else 'rendered PNGs, page PNGs, and PDF'} 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>'} 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'} 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'}
+706 -232
View File
File diff suppressed because it is too large Load Diff
+3 -9
View File
@@ -61,17 +61,10 @@ CARD_SIZE_OPTIONS_MM = {
TEXT_INDENT_CHARS = 2 TEXT_INDENT_CHARS = 2
ITALIC_SHEAR = 0.18 ITALIC_SHEAR = 0.18
BOLD_OFFSETS = ((1, 0), (0, 1), (1, 1)) BOLD_OFFSETS = ((1, 0), (0, 1), (1, 1))
BASE_CARD_SIZE = (825, 1125)
FIXED_CROP = (70, 73, 70, 73) 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 TOP_BOTTOM_EDGE_REPLACE_PX = 10
CHARACTER_EDGE_REPLACE_PX = 3 DEFAULT_SOURCE_CROP_PX = 37.5
TEXT_SUPERSAMPLING = 3
MAX_WORKERS = 8 MAX_WORKERS = 8
PDF_COMPRESSION_LEVEL = 9 PDF_COMPRESSION_LEVEL = 9
@@ -96,6 +89,7 @@ class BuildConfig:
clean_edge_mm: float clean_edge_mm: float
output_pdf_name: str output_pdf_name: str
delete_temp: bool | None delete_temp: bool | None
text_supersampling: int
render_only: bool = False render_only: bool = False
+69 -38
View File
@@ -9,7 +9,6 @@ from PIL import Image, ImageChops, ImageDraw, ImageFont
from .assets import meta_unit_scale from .assets import meta_unit_scale
from .settings import ( from .settings import (
BASE_CARD_SIZE,
BOLD_OFFSETS, BOLD_OFFSETS,
DECK_GROUPS, DECK_GROUPS,
FIXED_CROP, FIXED_CROP,
@@ -19,7 +18,6 @@ from .settings import (
TEXT_AREA_BOTTOM_CROPPED_RATIO, TEXT_AREA_BOTTOM_CROPPED_RATIO,
TEXT_AREA_TOP_CROPPED_RATIOS, TEXT_AREA_TOP_CROPPED_RATIOS,
TEXT_INDENT_CHARS, TEXT_INDENT_CHARS,
TITLE_WIDTH_LIMITS,
) )
@@ -29,14 +27,19 @@ class Fonts:
self._consolas = FONTS_DIR / "consolas.ttf" self._consolas = FONTS_DIR / "consolas.ttf"
self._unicode = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf") self._unicode = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf")
self._cache: dict[tuple[str, int], ImageFont.FreeTypeFont | ImageFont.ImageFont] = {} self._cache: dict[tuple[str, int], ImageFont.FreeTypeFont | ImageFont.ImageFont] = {}
self._source_sizes: dict[int, float] = {}
self._families: dict[int, str] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
def title(self, java_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: def title(self, java_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
size = max(8, int(java_size / 3) * 2) size = max(8, int(java_size / 3) * 2)
return self._font("xirod", self._xirod, size) return self._font("xirod", self._xirod, size)
def text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: def text(self, size: int, source_size: float | None = None) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
return self._font("consolas", self._consolas, max(8, size)) font = self._font("consolas", self._consolas, max(8, size))
if source_size is not None:
self._source_sizes[id(font)] = source_size
return font
def unicode_text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: def unicode_text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
return self._font("unicode", self._unicode, max(8, size)) return self._font("unicode", self._unicode, max(8, size))
@@ -49,8 +52,15 @@ class Fonts:
self._cache[key] = ImageFont.truetype(str(path), size=size, layout_engine=ImageFont.Layout.RAQM) self._cache[key] = ImageFont.truetype(str(path), size=size, layout_engine=ImageFont.Layout.RAQM)
else: else:
self._cache[key] = ImageFont.load_default() self._cache[key] = ImageFont.load_default()
self._families[id(self._cache[key])] = name
return self._cache[key] return self._cache[key]
def source_size(self, font: ImageFont.ImageFont) -> float:
return self._source_sizes.get(id(font), float(font_px(font)))
def family(self, font: ImageFont.ImageFont) -> str:
return self._families.get(id(font), "")
FONTS = Fonts() FONTS = Fonts()
@@ -119,7 +129,7 @@ def format_card_text(text: str | None) -> str:
parts = line.split("//", 1) parts = line.split("//", 1)
action = parts[0] action = parts[0]
if not action.strip().endswith(":"): if not action.strip().endswith(":"):
action = re.sub(r" *$", ";", action) action = action.rstrip() + ";"
action = action.replace(";;", "; ").replace(":;", ":") action = action.replace(";;", "; ").replace(":;", ":")
action = KEYWORD_RE.sub(r"<b>\1</b>", action) action = KEYWORD_RE.sub(r"<b>\1</b>", action)
result = leading + action result = leading + action
@@ -239,8 +249,11 @@ def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont,
if not text: if not text:
return 0 return 0
styles = styles or set() styles = styles or set()
bold_offset = max(dx for dx, _ in BOLD_OFFSETS) if "b" in styles else 0 bold_offsets = bold_offsets_for_font(font) if "b" in styles else ()
bold_offset = max((dx for dx, _ in bold_offsets), default=0)
width = 0.0 width = 0.0
family = FONTS.family(font)
if family not in {"consolas", "unicode"}:
for segment, segment_font in text_segments(text, font): for segment, segment_font in text_segments(text, font):
for cluster in text_clusters(segment): for cluster in text_clusters(segment):
layout_text = cluster_layout_text(cluster) layout_text = cluster_layout_text(cluster)
@@ -253,29 +266,33 @@ def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont,
width += bold_offset width += bold_offset
return round(width) return round(width)
source_size = FONTS.source_size(font)
source_px = max(1, round(source_size))
scale = font_px(font) / max(1.0, source_size)
source_font = FONTS.text(source_px, source_size)
source_unicode_font = FONTS.unicode_text(source_px)
for segment, segment_font in text_segments(text, font):
measuring_font = source_unicode_font if segment_font is not font else source_font
for cluster in text_clusters(segment):
layout_text = cluster_layout_text(cluster)
if layout_text:
width += draw.textlength(layout_text, font=measuring_font)
if is_zalgo_cluster(cluster):
mark_count = sum(1 for char in cluster if unicodedata.combining(char))
width += zalgo_extra_advance(measuring_font, mark_count)
return round(round(width) * scale + bold_offset)
def bold_offsets_for_font(font: ImageFont.ImageFont) -> tuple[tuple[int, int], ...]:
source_size = FONTS.source_size(font)
scale = max(1, round(font_px(font) / max(1.0, source_size)))
return tuple((dx * scale, dy * scale) for dx, dy in BOLD_OFFSETS)
def text_line_height(font: ImageFont.ImageFont) -> int: def text_line_height(font: ImageFont.ImageFont) -> int:
if hasattr(font, "getmetrics"): source_size = FONTS.source_size(font)
ascent, descent = font.getmetrics() scale = font_px(font) / max(1.0, source_size)
return max(1, ascent + descent + 2) return max(1, round(round(source_size * 35 / 32) * scale))
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: def title_font_for_target_width(title: str, meta: dict, font_scale: float, max_width: int) -> ImageFont.ImageFont:
@@ -290,24 +307,38 @@ def title_font_for_target_width(title: str, meta: dict, font_scale: float, max_w
return FONTS.title(min_size) return FONTS.title(min_size)
def standard_text_layout(meta: dict, target_size: tuple[int, int], crop_edges: bool) -> dict: def standard_text_layout(
meta: dict,
target_size: tuple[int, int],
crop_edges: bool,
crop_box: tuple[float, float, float, float] | None = None,
scale: tuple[float, float] | None = None,
) -> dict:
card_w = int(meta["cardWidth"]) card_w = int(meta["cardWidth"])
card_h = int(meta["cardHeight"]) card_h = int(meta["cardHeight"])
unit = meta_unit_scale(meta) unit = meta_unit_scale(meta)
if crop_edges: if crop_box is not None:
left, top, right_crop, bottom_crop = crop_box
elif crop_edges:
left = round(FIXED_CROP[0] * unit) left = round(FIXED_CROP[0] * unit)
top = round(FIXED_CROP[1] * unit) top = round(FIXED_CROP[1] * unit)
right = round(FIXED_CROP[2] * unit) right_crop = round(FIXED_CROP[2] * unit)
bottom = round(FIXED_CROP[3] * unit) bottom_crop = round(FIXED_CROP[3] * unit)
else: else:
left = top = right = bottom = 0 left = top = right_crop = bottom_crop = 0
visible_w = max(1, card_w - left - right) visible_w = max(1.0, card_w - left - right_crop)
visible_h = max(1, card_h - top - bottom) visible_h = max(1.0, card_h - top - bottom_crop)
sx = target_size[0] / visible_w
sy = target_size[1] / visible_h
if scale is not None:
sx, sy = scale
return { return {
"left": left, "left": left,
"top": top, "top": top,
"sx": target_size[0] / visible_w, "sx": sx,
"sy": target_size[1] / visible_h, "sy": sy,
"crop_w": visible_w,
"crop_h": visible_h,
"target_w": target_size[0], "target_w": target_size[0],
"target_h": target_size[1], "target_h": target_size[1],
} }
@@ -368,7 +399,7 @@ def draw_styled_text(
if not text: if not text:
return return
ascent = font.getmetrics()[0] if hasattr(font, "getmetrics") else font_px(font) ascent = font.getmetrics()[0] if hasattr(font, "getmetrics") else font_px(font)
bold_offsets = BOLD_OFFSETS if "b" in styles else () bold_offsets = bold_offsets_for_font(font) if "b" in styles else ()
def draw_zalgo_cluster( def draw_zalgo_cluster(
target: Image.Image, target: Image.Image,
@@ -508,7 +539,7 @@ def wrap_runs(
current_w += text_width(draw, token, font, styles) current_w += text_width(draw, token, font, styles)
continue continue
token_w = text_width(draw, token, font, styles) token_w = text_width(draw, token, font, styles)
should_wrap = current_w + token_w > max_width should_wrap = current and current[-1][0].isspace() and current_w + token_w > max_width
if current and should_wrap: if current and should_wrap:
while current and current[-1][0].isspace(): while current and current[-1][0].isspace():
current.pop() current.pop()