import math import shutil from concurrent.futures import ProcessPoolExecutor from pathlib import Path from PIL import Image, ImageDraw 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 from .settings import ( BASEMENT_SINGLE_STARTER_COLOR, BOARD_DECK, CARD_DECKS, CHARACTER_DECK, CHECK_COLORS, DEFAULT_SOURCE_CROP_PX, DECK_GROUPS, DECKS_DIR, FIXED_CROP, OFFICE_SINGLE_STARTER_COLOR, PLAYER_COLORS, RENDER_DIR, STARTER_DECKS, TITLE_WIDTH_LIMITS, 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, 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, y_offset: int, fill: tuple[int, int, int], ) -> None: draw_banner_shape(image, meta, y_offset, fill) def draw_starter_banner(image: Image.Image, meta: dict, y_offset: int, color: str) -> 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, 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(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) def card_source_crop(config: BuildConfig) -> tuple[float, float, float, float]: 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( deck_dir: Path, meta: dict, card: dict, front_template: Image.Image, starter_color: str | None, target_size: tuple[int, int], config: BuildConfig, ) -> Image.Image: 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") 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) 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, check_offset, hex_color("#ead500")) 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, check_offset, check_color, ) 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, check_offset, (255, 255, 255)) banner_texts.append(("VIRUS", check_offset, hex_color(meta.get("foregroundColor"), "#000000"))) source_canvas.alpha_composite(front_template.convert("RGBA"), (0, 0)) source_without_text = source_canvas.convert("RGB") if config.crop_black_borders: source_without_text = replace_source_dirty_pixels(source_without_text, crop) if config.gamma_enabled: 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: draw_banner_text(text_layer, meta, layout, banner_text, y_offset, text_color) title = card.get("title") if title: 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) title_color = hex_color(meta.get("titleColor"), "#000000") title_y = ty(layout, int(meta["titleOffset"])) title_box_h = th(layout, 16) 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") 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_source_size = int(meta.get("fontSize") or 32) 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( text_layer, 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), 16) 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_y = ty(layout, int(meta["cardHeight"]) - 95) - footer_top_compensation if config_project_version := card.get("_project_version"): draw_text_box( text_layer, 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( text_layer, str(card["credit"]), ( tx(layout, 100), footer_y, tw(layout, 500), th(layout, 30), ), small, (255, 255, 255), 0.0, 0.5, 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") 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 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"] bleed_out = job["bleed_out"] front_template = load_rgb(deck_dir / "front.png") full = render_card_image( deck_dir, meta, card, front_template, starter_color, card_px, config, ) save_print_images(full, out, bleed_out, card_px, bleed_px(config), config) 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") 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, tuple[Path, Path]] = {} for color_index, starter_color in enumerate(colors): rendered = rendered_by_color.get(starter_color) if rendered is None: out = RENDER_DIR / "cards" / group / "fronts" / f"{deck_name}_{safe_name(card.get('title', card_json.stem))}_{color_index}.png" 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( { "deck_dir": deck_dir, "meta": meta, "card": card, "config": config, "card_px": card_px, "starter_color": starter_color, "out": out, "bleed_out": bleed_out, } ) else: out, bleed_out = rendered 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(), bleed_out)) with ProcessPoolExecutor(max_workers=worker_count(len(front_jobs))) as executor: list(executor.map(render_standard_front_job, front_jobs)) back_source = back_template if config.crop_black_borders: back_source = replace_source_dirty_pixels(back_source, card_source_crop(config)) if config.gamma_enabled: 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_bleed_out = RENDER_DIR / "cards" / group / "backs" / "bleed" / f"{deck_name}_back.png" save_print_images(adjusted_back, back_out, back_bleed_out, card_px, back_bleed, config) 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(), back_bleed_out)) char_dir = DECKS_DIR / CHARACTER_DECK char_meta = load_json(char_dir / "deck.json") 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"): 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") 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: small = FONTS.text(font_size_y(char_layout, 16), 16) draw_text_box( text_layer, 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, ) 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" bleed_out = RENDER_DIR / "cards" / "characters" / "bleed" / f"{safe_name(card_json.stem)}.png" save_print_images(adjusted, out, bleed_out, card_px, char_bleed, config) # 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") 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 bleed_out = RENDER_DIR / "boards" / "bleed" / board_path.name save_print_images(adjusted, out, bleed_out, target_px, board_bleed, config) board_items.append(PrintItem(out, "board", board_path.stem, "board", False, "", bleed_out)) 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]