598 lines
25 KiB
Python
598 lines
25 KiB
Python
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]
|