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) 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.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]