diff --git a/README.md b/README.md index 49fc605..79bd93d 100644 --- a/README.md +++ b/README.md @@ -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. - `--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`. +- `--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. - `--starter-colors {separate,same}`: use separate starter badge colors or one repeated color per starter deck. Default: `separate`. diff --git a/scripts/assets.py b/scripts/assets.py index 73d90a5..60849d2 100644 --- a/scripts/assets.py +++ b/scripts/assets.py @@ -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)) -def render_work_scale(meta: dict, target_size: tuple[int, int]) -> float: - sx = target_size[0] / float(meta["cardWidth"]) - sy = target_size[1] / float(meta["cardHeight"]) - return max(2.0, sx, sy) - - def meta_unit_scale(meta: dict) -> float: return float(meta.get("cardWidth", 825)) / 825.0 diff --git a/scripts/boards.py b/scripts/boards.py index 6154494..8243014 100644 --- a/scripts/boards.py +++ b/scripts/boards.py @@ -1,6 +1,5 @@ from pathlib import Path -from .assets import load_json, load_rgb from .settings import BOARD_DECK, DECKS_DIR, BuildConfig 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) - -def board_aspect_ratio(board_path: Path) -> float: - deck_json = board_path.parent / "deck.json" - if deck_json.exists(): - meta = load_json(deck_json) - if meta.get("cardWidth") and meta.get("cardHeight"): - return float(meta["cardWidth"]) / float(meta["cardHeight"]) - with load_rgb(board_path) as img: - return img.width / img.height - - def board_target_mm(board_path: Path, config: BuildConfig) -> tuple[float, float]: return board_target_mm_for_mode(board_path, config.board_mode, config.board_custom_mm) @@ -78,15 +66,3 @@ def board_size_fits_paper( if not (fits_upright or fits_rotated): return False 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)) diff --git a/scripts/cards.py b/scripts/cards.py index 896c0cb..432cb42 100644 --- a/scripts/cards.py +++ b/scripts/cards.py @@ -1,26 +1,27 @@ +import math import shutil from concurrent.futures import ProcessPoolExecutor 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 .boards import board_target_mm, crop_board_bleed +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 ( - BASE_CARD_SIZE, BASEMENT_SINGLE_STARTER_COLOR, BOARD_DECK, CARD_DECKS, CHARACTER_DECK, - CHARACTER_EDGE_REPLACE_PX, CHECK_COLORS, + DEFAULT_SOURCE_CROP_PX, + DECK_GROUPS, DECKS_DIR, FIXED_CROP, OFFICE_SINGLE_STARTER_COLOR, PLAYER_COLORS, RENDER_DIR, - SIDE_EDGE_REPLACE_BANDS, STARTER_DECKS, + TITLE_WIDTH_LIMITS, TOP_BOTTOM_EDGE_REPLACE_PX, BuildConfig, PrintItem, @@ -39,7 +40,6 @@ from .text import ( text_line_height, th, title_font_for_target_width, - title_width_limit, transformed_text_area, tw, tx, @@ -82,26 +82,13 @@ def draw_banner_text( 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: +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"]) @@ -110,14 +97,193 @@ def draw_starter_banner(image: Image.Image, meta: dict, y_offset: int, color: st 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_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) - 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 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( @@ -127,9 +293,10 @@ def render_card_image( front_template: Image.Image, starter_color: str | None, target_size: tuple[int, int], - crop_edges_before_text: bool = False, + config: BuildConfig, ) -> 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") banner_texts: list[tuple[str, int, tuple[int, int, int]]] = [] @@ -140,11 +307,11 @@ def render_card_image( check_offset = 0 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))) 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) + 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: @@ -152,36 +319,40 @@ def render_card_image( 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) + 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)) - 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") + 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(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") 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) 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) + 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"]) @@ -194,10 +365,12 @@ def render_card_image( 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))) + 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( - canvas, + text_layer, body_text, flavour_text, (text_x, text_y, text_w, text_h), @@ -209,13 +382,13 @@ def render_card_image( 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)) + 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( - canvas, + text_layer, str(config_project_version), ( tx(layout, int(meta["cardWidth"]) - 252), @@ -231,7 +404,7 @@ def render_card_image( ) if card.get("credit"): draw_text_box( - canvas, + text_layer, str(card["credit"]), ( tx(layout, 100), @@ -245,230 +418,12 @@ def render_card_image( 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 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 = [] @@ -478,37 +433,6 @@ def apply_gamma(img: Image.Image, gamma: float, black_point: float) -> Image.Ima 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"] @@ -517,19 +441,18 @@ def render_standard_front_job(job: dict) -> Path: 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") - image = render_card_image( + full = render_card_image( deck_dir, meta, card, front_template, starter_color, card_px, - crop_edges_before_text=config.crop_black_borders, + config, ) - 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) + save_print_images(full, out, bleed_out, card_px, bleed_px(config), config) return out @@ -547,7 +470,6 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem 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] = [] @@ -561,12 +483,13 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem 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] = {} + rendered_by_color: dict[str | None, tuple[Path, Path]] = {} for color_index, starter_color in enumerate(colors): - out = rendered_by_color.get(starter_color) - if out is None: + 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" - 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( { "deck_dir": deck_dir, @@ -576,30 +499,36 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem "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())) + 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_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_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_out.parent.mkdir(parents=True, exist_ok=True) - save_temp_png(adjusted_back, back_out, config.dpi) + 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())) + 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_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"): art = find_art_for_json(card_json) 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"]))) 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.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)) + small = FONTS.text(font_size_y(char_layout, 16), 16) draw_text_box( - adjusted, + text_layer, config.project_version, ( 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, ) + 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.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) + 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)) @@ -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")): 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 = crop_board_bleed(load_rgb(board_path).convert("RGB")) - adjusted = apply_print_adjustments(image, config, target_px, "board") + 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 - 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")) + 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 diff --git a/scripts/cli.py b/scripts/cli.py index 95c4e0c..0d06fed 100644 --- a/scripts/cli.py +++ b/scripts/cli.py @@ -18,6 +18,7 @@ from .settings import ( RENDER_DIR, ROOT, TEMP_DIR, + TEXT_SUPERSAMPLING, BuildConfig, mm_to_px, ) @@ -45,8 +46,8 @@ def build_printable(config: BuildConfig) -> Path | None: cols, rows = grid_capacity(config) print(f"Card grid: {cols} columns x {rows} rows on {config.paper_name.upper()} ({cols * rows} images/page)") - next_page, deferred_character_items = render_card_pages(card_items, config, 1) - next_page = render_board_pages(board_items, config, next_page, deferred_character_items) + next_page, deferred_character_items, remaining_board_items = render_card_pages(card_items, config, 1, board_items) + next_page = render_board_pages(remaining_board_items, config, next_page, deferred_character_items) pdf = write_pdf(config) if pdf: print(f"Wrote {pdf.relative_to(ROOT)}") @@ -123,6 +124,16 @@ def prompt_positive_int(label: str, default: int) -> int: 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: value = input(f"{label} [{default:g}]: ").strip() if not value: @@ -263,6 +274,7 @@ def default_config() -> BuildConfig: clean_edge_mm=0.35, output_pdf_name="", delete_temp=True, + text_supersampling=TEXT_SUPERSAMPLING, render_only=False, ) @@ -328,6 +340,7 @@ def interactive_config(args: argparse.Namespace) -> BuildConfig: clean_edge_mm=default.clean_edge_mm, output_pdf_name=output_pdf_name, delete_temp=None, + text_supersampling=args.text_supersampling or default.text_supersampling, 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("--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("--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( "--starter-colors", @@ -411,6 +425,7 @@ def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfi clean_edge_mm=config.clean_edge_mm, output_pdf_name=args.output if args.output is not None else config.output_pdf_name, delete_temp=delete_temp, + text_supersampling=args.text_supersampling or config.text_supersampling, 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, output_pdf_name=config.output_pdf_name, delete_temp=config.delete_temp, + text_supersampling=config.text_supersampling, render_only=config.render_only, ) @@ -475,6 +491,7 @@ def main(argv=None) -> None: clean_edge_mm=config.clean_edge_mm, output_pdf_name=config.output_pdf_name, delete_temp=config.delete_temp, + text_supersampling=config.text_supersampling, render_only=config.render_only, ) 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) 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}'} + text supersampling: {config.text_supersampling} 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 ''} 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'} diff --git a/scripts/layout.py b/scripts/layout.py index 4c56717..58699d3 100644 --- a/scripts/layout.py +++ b/scripts/layout.py @@ -6,7 +6,6 @@ from pathlib import Path from PIL import Image, ImageDraw from .assets import save_temp_png -from .cards import make_bleed from .settings import BuildConfig, PAGES_DIR, PrintItem, ROOT, mm_to_px, worker_count @@ -30,48 +29,6 @@ def grid_capacity(config: BuildConfig) -> tuple[int, int]: return int(cols), int(rows) -def columns_to_pages(column_items: list[list[PrintItem]], cols_per_page: int, rows: int) -> list[list[PrintItem | None]]: - pages: list[list[PrintItem | None]] = [] - for i in range(0, len(column_items), cols_per_page): - page_columns = column_items[i : i + cols_per_page] - page: list[PrintItem | None] = [None] * (cols_per_page * rows) - column_groups: dict[str, list[tuple[int, list[PrintItem]]]] = {} - group_order: list[str] = [] - - for col_index, column in enumerate(page_columns): - if not column: - continue - key = column[0].image_type - if key not in column_groups: - column_groups[key] = [] - group_order.append(key) - column_groups[key].append((col_index, column)) - - for key in group_order: - columns = column_groups[key] - sorted_items = [item for _, column in columns for item in column] - row_major_slots = [ - row_index * cols_per_page + col_index - for row_index in range(rows) - for col_index, column in columns - if row_index < len(column) - ] - for slot, item in zip(row_major_slots, sorted_items): - page[slot] = item - pages.append(page) - return pages - - -def interleave_columns(*column_groups: list[list[PrintItem]]) -> list[list[PrintItem]]: - columns: list[list[PrintItem]] = [] - max_len = max((len(group) for group in column_groups), default=0) - for index in range(max_len): - for group in column_groups: - if index < len(group): - columns.append(group[index]) - return columns - - def balanced_columns(items: list[PrintItem], column_count: int) -> list[list[PrintItem]]: if column_count <= 0 or not items: return [] @@ -437,8 +394,6 @@ def fill_bottom_rows_with_spaced_characters( card_w = mm_to_px(config.card_mm[0], config.dpi) card_h = mm_to_px(config.card_mm[1], config.dpi) oriented_w, oriented_h = card_h, card_w - grid_w = cols * oriented_w - x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 y0 = margin remaining = list(character_items) @@ -472,11 +427,7 @@ def fill_bottom_rows_with_spaced_characters( if capacity <= 0: continue - used_cols = min(fit_cols, capacity) - used_rows = math.ceil(capacity / fit_cols) - placement_w = used_cols * item_w + max(0, used_cols - 1) * gap - placement_h = used_rows * item_h + max(0, used_rows - 1) * gap - place_x0 = area_left + (area_w - placement_w) // 2 + place_x0 = area_left place_y0 = area_top extras = list(page.extras or []) for index in range(capacity): @@ -498,6 +449,20 @@ def rects_conflict(rect: tuple[int, int, int, int], occupied: list[tuple[int, in return False +def row_band_compatible(rect: tuple[int, int, int, int], occupied: list[tuple[int, int, int, int]]) -> bool: + left, top, right, bottom = rect + width = right - left + height = bottom - top + for other_left, other_top, other_right, other_bottom in occupied: + if top >= other_bottom or bottom <= other_top: + continue + if top != other_top or bottom != other_bottom: + return False + if width != other_right - other_left or height != other_bottom - other_top: + return False + return True + + def pack_spaced_items_in_free_space( occupied: list[tuple[int, int, int, int]], items: list[PrintItem], @@ -514,7 +479,7 @@ def pack_spaced_items_in_free_space( card_h = mm_to_px(config.card_mm[1], config.dpi) oriented_w, oriented_h = card_h, card_w orientations = ((oriented_w, oriented_h, 90), (card_w, card_h, 0)) - best: list[tuple[tuple[int, int, int, int], int]] = [] + best: list[tuple[int, tuple[int, int, int, int], int]] = [] seen: set[tuple[int, tuple[tuple[int, int, int, int], ...]]] = set() def normalized(rects: list[tuple[int, int, int, int]]) -> tuple[tuple[int, int, int, int], ...]: @@ -539,7 +504,7 @@ def pack_spaced_items_in_free_space( if left < margin or left + item_w > page_w - margin: continue rect = (left, top, left + item_w, top + item_h) - if not rects_conflict(rect, rects, gap): + if row_band_compatible(rect, rects) and not rects_conflict(rect, rects, gap): out.append((*rect, rotation)) return sorted(out, key=lambda value: (value[1], value[0], value[4])) @@ -565,15 +530,163 @@ def pack_spaced_items_in_free_space( return [(items[index], rect, rotation) for index, (rect, rotation) in enumerate(best)] +def item_print_size(item: PrintItem, config: BuildConfig) -> tuple[int, int]: + if item.path.exists(): + with Image.open(item.path) as img: + return img.size + return mm_to_px(config.card_mm[0], config.dpi), mm_to_px(config.card_mm[1], config.dpi) + + +def item_orientations(item: PrintItem, config: BuildConfig) -> list[tuple[int, int, int]]: + width, height = item_print_size(item, config) + if item.layout_rotation is not None: + rotation = item.layout_rotation + return [(height, width, rotation)] if abs(rotation) % 180 else [(width, height, rotation)] + if width == height: + return [(width, height, 0)] + return [(width, height, 0), (height, width, 90)] + + +def loose_items_can_touch(items: list[PrintItem]) -> bool: + if not items: + return False + if any(is_character_item(item) or item.group == "board" for item in items): + return False + groups = {item.group for item in items} + image_types = {item.image_type for item in items} + return len(groups) == 1 and len(image_types) == 1 + + +def compatible_standard_items(left: PrintItem, right: PrintItem) -> bool: + return ( + not is_character_item(left) + and not is_character_item(right) + and left.group != "board" + and right.group != "board" + and left.group == right.group + and left.image_type == right.image_type + ) + + +def normalize_connected_standard_rotations( + placements: list[tuple[PrintItem, tuple[int, int, int, int], int]], +) -> list[tuple[PrintItem, tuple[int, int, int, int], int]]: + if not placements: + return placements + + normalized = list(placements) + by_band: dict[tuple[int, int], list[int]] = {} + for index, (_, rect, _) in enumerate(normalized): + by_band.setdefault((rect[1], rect[3]), []).append(index) + + for indices in by_band.values(): + indices.sort(key=lambda index: normalized[index][1][0]) + run: list[int] = [] + + def flush() -> None: + if len(run) < 2: + return + _, first_rect, _ = normalized[run[0]] + landscape = first_rect[2] - first_rect[0] > first_rect[3] - first_rect[1] + if not landscape: + for run_index in run: + item, rect, _ = normalized[run_index] + normalized[run_index] = (item, rect, 0) + return + for offset, run_index in enumerate(run): + item, rect, _ = normalized[run_index] + normalized[run_index] = (item, rect, 90 if offset % 2 == 0 else -90) + + for index in indices: + if not run: + run = [index] + continue + previous_index = run[-1] + previous_item, previous_rect, _ = normalized[previous_index] + item, rect, _ = normalized[index] + touches = previous_rect[2] == rect[0] + if touches and compatible_standard_items(previous_item, item): + run.append(index) + else: + flush() + run = [index] + flush() + + return normalized + + +def pack_loose_items_in_free_space( + occupied: list[tuple[int, int, int, int]], + items: list[PrintItem], + config: BuildConfig, +) -> list[tuple[PrintItem, tuple[int, int, int, int], int]]: + if not items: + return [] + + page_w = mm_to_px(config.paper_mm[0], config.dpi) + page_h = mm_to_px(config.paper_mm[1], config.dpi) + margin = mm_to_px(config.margin_mm, config.dpi) + gap = mm_to_px(5.0, config.dpi) + internal_gap = 0 if loose_items_can_touch(items) else gap + orientations = [item_orientations(item, config) for item in items] + placed_start = len(occupied) + + def gap_to_rect(rect_index: int) -> int: + return internal_gap if rect_index >= placed_start else gap + + def rect_conflicts(rect: tuple[int, int, int, int], rects: list[tuple[int, int, int, int]]) -> bool: + left, top, right, bottom = rect + for index, other in enumerate(rects): + other_left, other_top, other_right, other_bottom = other + other_gap = gap_to_rect(index) + if left < other_right + other_gap and right > other_left - other_gap and top < other_bottom + other_gap and bottom > other_top - other_gap: + return True + return False + + def candidates(rects: list[tuple[int, int, int, int]], item_index: int) -> list[tuple[int, int, int, int, int]]: + xs = {margin} + ys = {margin} + for rect_index, (left, top, right, bottom) in enumerate(rects): + rect_gap = gap_to_rect(rect_index) + for item_w, item_h, _ in orientations[item_index]: + xs.add(right + rect_gap) + xs.add(left - rect_gap - item_w) + ys.add(bottom + rect_gap) + ys.add(top - rect_gap - item_h) + + out: list[tuple[int, int, int, int, int]] = [] + for item_w, item_h, rotation in orientations[item_index]: + for top in sorted(ys): + if top < margin or top + item_h > page_h - margin: + continue + for left in sorted(xs): + if left < margin or left + item_w > page_w - margin: + continue + rect = (left, top, left + item_w, top + item_h) + if row_band_compatible(rect, rects) and not rect_conflicts(rect, rects): + out.append((*rect, rotation)) + return sorted(out, key=lambda value: (value[1], value[0], value[4])) + + rects = list(occupied) + placements: list[tuple[PrintItem, tuple[int, int, int, int], int]] = [] + for index, item in enumerate(items): + candidate_rects = candidates(rects, index) + if not candidate_rects: + continue + left, top, right, bottom, rotation = candidate_rects[0] + rect = (left, top, right, bottom) + rects.append(rect) + placements.append((item, rect, rotation)) + return normalize_connected_standard_rotations(placements) + + def standard_grid_rects(page: CardPage, config: BuildConfig) -> list[tuple[int, int, int, int]]: cols, _ = grid_capacity(config) - page_w = mm_to_px(config.paper_mm[0], config.dpi) margin = mm_to_px(config.margin_mm, config.dpi) card_w = mm_to_px(config.card_mm[0], config.dpi) card_h = mm_to_px(config.card_mm[1], config.dpi) oriented_w, oriented_h = card_h, card_w - grid_w = cols * oriented_w - x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 + x0 = margin y0 = margin rects: list[tuple[int, int, int, int]] = [] for index, item in enumerate(page.items): @@ -587,8 +700,342 @@ def standard_grid_rects(page: CardPage, config: BuildConfig) -> list[tuple[int, return rects -def standard_groups_on_page(page: CardPage) -> set[str]: - return {item.group for item in page.items if item is not None and not is_character_item(item)} +def page_loose_items(page: CardPage) -> list[PrintItem]: + return [item for item in page.items if item is not None] + [item for item, _, _ in (page.extras or [])] + + +def place_loose_items_on_pages( + pages: list[CardPage], + loose_items: list[PrintItem], + config: BuildConfig, +) -> list[PrintItem]: + remaining = list(loose_items) + for page in pages: + if not remaining or page.spaced: + continue + occupied = standard_grid_rects(page, config) + if loose_items_can_touch(remaining): + placements = pack_loose_items_in_free_space(occupied, remaining, config) + elif all(item_print_size(item, config) == (mm_to_px(config.card_mm[0], config.dpi), mm_to_px(config.card_mm[1], config.dpi)) for item in remaining): + placements = pack_spaced_items_in_free_space(occupied, remaining, config) + else: + placements = pack_loose_items_in_free_space(occupied, remaining, config) + if not placements: + continue + page.extras = list(page.extras or []) + placements + for placed_item, _, _ in placements: + remaining.remove(placed_item) + return remaining + + +def sparse_standard_upright_row( + standard_items: list[PrintItem], + config: BuildConfig, +) -> list[tuple[PrintItem, tuple[int, int, int, int], int]] | None: + if not standard_items: + return None + groups = {item.group for item in standard_items} + if len(groups) != 1: + return None + + page_w = mm_to_px(config.paper_mm[0], config.dpi) + margin = mm_to_px(config.margin_mm, config.dpi) + gap = mm_to_px(5.0, config.dpi) + card_w = mm_to_px(config.card_mm[0], config.dpi) + card_h = mm_to_px(config.card_mm[1], config.dpi) + ordered: list[PrintItem] = [] + for image_type in ("front", "back"): + ordered.extend(item for item in standard_items if item.image_type == image_type) + if len(ordered) != len(standard_items): + return None + + row_w = len(ordered) * card_w + if any(item.image_type != ordered[0].image_type for item in ordered): + row_w += gap + if row_w > page_w - 2 * margin: + return None + + placements: list[tuple[PrintItem, tuple[int, int, int, int], int]] = [] + x = margin + previous_type: str | None = None + for item in ordered: + if previous_type is not None and item.image_type != previous_type: + x += gap + rect = (x, margin, x + card_w, margin + card_h) + placements.append((item, rect, 0)) + x += card_w + previous_type = item.image_type + return placements + + +def pack_landscape_card_rows_below( + occupied: list[tuple[int, int, int, int]], + items: list[PrintItem], + config: BuildConfig, + internal_gap: int | None = None, +) -> list[tuple[PrintItem, tuple[int, int, int, int], int]]: + if not items: + return [] + + page_w = mm_to_px(config.paper_mm[0], config.dpi) + page_h = mm_to_px(config.paper_mm[1], config.dpi) + margin = mm_to_px(config.margin_mm, config.dpi) + gap = mm_to_px(5.0, config.dpi) + row_gap = gap if occupied else 0 + item_gap = gap if internal_gap is None else internal_gap + card_w = mm_to_px(config.card_mm[0], config.dpi) + card_h = mm_to_px(config.card_mm[1], config.dpi) + item_w, item_h = card_h, card_w + usable_w = page_w - 2 * margin + cols = max(1, (usable_w + item_gap) // (item_w + item_gap)) + top = max((rect[3] for rect in occupied), default=margin) + row_gap + placements: list[tuple[PrintItem, tuple[int, int, int, int], int]] = [] + + for index, item in enumerate(items): + row, col = divmod(index, cols) + left = margin + col * (item_w + item_gap) + y = top + row * (item_h + gap) + rect = (left, y, left + item_w, y + item_h) + if rect[3] > page_h - margin: + break + rotation = 90 if col % 2 == 0 else -90 + placements.append((item, rect, rotation)) + return normalize_connected_standard_rotations(placements) + + +def reflow_sparse_standard_page_for_trailing( + page: CardPage, + trailing: list[PrintItem], + config: BuildConfig, +) -> tuple[CardPage, list[PrintItem]] | None: + if page.spaced or not page.extras: + return None + cols, _ = grid_capacity(config) + standard_items = [item for item in page.items if item is not None and not is_character_item(item)] + if not standard_items or len(standard_items) > 2 * cols: + return None + if any(is_character_item(item) or item.group == "board" for item in trailing): + return None + + top_row = sparse_standard_upright_row(standard_items, config) + if top_row is None: + return None + + extras = [item for item, _, _ in (page.extras or [])] + occupied = [rect for _, rect, _ in top_row] + if extras and all(is_character_item(item) for item in extras): + extra_placements = pack_landscape_card_rows_below(occupied, extras, config) + else: + extra_placements = pack_spaced_items_in_free_space(occupied, extras, config) + if len(extra_placements) != len(extras): + return None + + occupied.extend(rect for _, rect, _ in extra_placements) + trailing_placements = pack_landscape_card_rows_below(occupied, trailing, config, mm_to_px(5.0, config.dpi)) + placed_trailing = [item for item, _, _ in trailing_placements] + remaining = list(trailing) + for item in placed_trailing: + remaining.remove(item) + if len(remaining) == len(trailing): + return None + + _, rows = grid_capacity(config) + blank_items = [None] * (cols * rows) + return CardPage(blank_items, False, top_row + extra_placements + trailing_placements), remaining + + +def absorb_remaining_by_deferring_small_extras( + pages: list[CardPage], + remaining_items: list[PrintItem], + config: BuildConfig, +) -> tuple[list[CardPage], list[PrintItem], list[PrintItem]] | None: + if not remaining_items or any(is_character_item(item) for item in remaining_items): + return None + + trial_pages = clone_pages(pages) + remaining = list(remaining_items) + deferred: list[PrintItem] = [] + changed = False + + for page in trial_pages: + if not remaining or page.spaced or not page.extras: + continue + displaced = [item for item, _, _ in page.extras] + if len(displaced) > 2: + continue + + empty_page = CardPage(list(page.items), page.spaced, None) + placements = pack_loose_items_in_free_space(standard_grid_rects(empty_page, config), remaining, config) + if len(placements) <= len(displaced): + continue + + page.extras = placements + for placed_item, _, _ in placements: + remaining.remove(placed_item) + deferred.extend(displaced) + changed = True + + if not changed: + return None + return trial_pages, remaining, deferred + + +def reflow_sparse_pages_for_trailing( + pages: list[CardPage], + trailing: list[PrintItem], + config: BuildConfig, +) -> tuple[list[CardPage], list[PrintItem]] | None: + for page_index, page in enumerate(pages): + result = reflow_sparse_standard_page_for_trailing(page, trailing, config) + if result is None: + continue + new_page, remaining = result + trial_pages = clone_pages(pages) + trial_pages[page_index] = new_page + return trial_pages, remaining + return None + + +def compact_trailing_pages(pages: list[CardPage], config: BuildConfig) -> tuple[list[CardPage], list[PrintItem]]: + pages = clone_pages(pages) + deferred: list[PrintItem] = [] + while len(pages) > 1: + trailing = page_loose_items(pages[-1]) + if not trailing: + pages.pop() + continue + + trial_pages = clone_pages(pages[:-1]) + remaining = place_loose_items_on_pages(trial_pages, trailing, config) + if remaining: + reflowed = reflow_sparse_pages_for_trailing(pages[:-1], trailing, config) + if reflowed is not None: + trial_pages, remaining = reflowed + if remaining: + absorbed = absorb_remaining_by_deferring_small_extras(trial_pages, remaining, config) + if absorbed is not None: + trial_pages, remaining, newly_deferred = absorbed + deferred.extend(newly_deferred) + if not remaining: + pages = trial_pages + break + if len(remaining) < len(trailing): + pages = trial_pages + deferred.extend(remaining) + break + swapped = absorb_trailing_by_evicting_extras(pages[:-1], trailing, config) + if swapped is None: + swapped = absorb_trailing_by_evicting_extras_and_standards(pages[:-1], trailing, config) + if swapped is None: + deferred_swap = absorb_trailing_by_deferring_extras(pages[:-1], trailing, config) + if deferred_swap is not None: + swapped, newly_deferred = deferred_swap + deferred.extend(newly_deferred) + if swapped is None: + if len(remaining) < len(trailing): + pages = trial_pages + deferred.extend(remaining) + break + pages = swapped + break + else: + pages = trial_pages + break + return pages, deferred + + +def absorb_trailing_by_evicting_extras( + base_pages: list[CardPage], + trailing: list[PrintItem], + config: BuildConfig, +) -> list[CardPage] | None: + if not trailing or any(is_character_item(item) for item in trailing): + return None + + for target_index, page in enumerate(base_pages): + if page.spaced or not page.extras or len(page.extras) > 2: + continue + trial_pages = clone_pages(base_pages) + target = trial_pages[target_index] + displaced = [item for item, _, _ in (target.extras or [])] + target.extras = None + trial_pages[target_index], remaining_trailing = add_standard_items_to_empty_slots_with_remaining(target, trailing, config) + if remaining_trailing: + continue + remaining_displaced = place_loose_items_on_pages(trial_pages, displaced, config) + if not remaining_displaced: + return trial_pages + + return None + + +def absorb_trailing_by_evicting_extras_and_standards( + base_pages: list[CardPage], + trailing: list[PrintItem], + config: BuildConfig, +) -> list[CardPage] | None: + if not trailing or any(is_character_item(item) for item in trailing): + return None + + for target_index, page in enumerate(base_pages): + if page.spaced or not page.extras or len(page.extras) > 2: + continue + + displaced = [item for item, _, _ in (page.extras or [])] + target_without_extras = CardPage(list(page.items), page.spaced, None) + target_with_trailing, remaining_trailing = add_standard_items_to_empty_slots_with_remaining(target_without_extras, trailing, config) + if remaining_trailing: + continue + + empty_slots = sum(1 for item in target_with_trailing.items if item is None) + if empty_slots <= 0: + continue + + for donor_index, donor in enumerate(base_pages): + if donor_index == target_index or donor.spaced or donor.extras: + continue + donor_standard_count = sum(1 for item in donor.items if item is not None and not is_character_item(item)) + for move_count in range(min(empty_slots, donor_standard_count), 0, -1): + donor_trial, moved = remove_last_standard_items(donor, move_count) + if len(moved) != move_count: + continue + target_filled, remaining_moved = add_standard_items_to_empty_slots_with_remaining(target_with_trailing, moved, config) + if remaining_moved: + continue + + trial_pages = clone_pages(base_pages) + trial_pages[target_index] = target_filled + trial_pages[donor_index] = donor_trial + remaining_displaced = place_loose_items_on_pages(trial_pages, displaced, config) + if not remaining_displaced: + return trial_pages + + return None + + +def absorb_trailing_by_deferring_extras( + base_pages: list[CardPage], + trailing: list[PrintItem], + config: BuildConfig, +) -> tuple[list[CardPage], list[PrintItem]] | None: + if not trailing or any(is_character_item(item) for item in trailing): + return None + + for target_index, page in enumerate(base_pages): + if page.spaced or not page.extras or len(page.extras) > 2: + continue + + trial_pages = clone_pages(base_pages) + target = trial_pages[target_index] + displaced = [item for item, _, _ in (target.extras or [])] + target.extras = None + placements = pack_loose_items_in_free_space(standard_grid_rects(target, config), trailing, config) + if len(placements) != len(trailing): + continue + target.extras = placements + return trial_pages, displaced + + return None def standard_move_is_compatible(receiver: CardPage, moved: list[PrintItem], config: BuildConfig) -> bool: @@ -596,9 +1043,18 @@ def standard_move_is_compatible(receiver: CardPage, moved: list[PrintItem], conf return False if not config.crop_black_borders: return True - receiver_groups = standard_groups_on_page(receiver) - moved_groups = {item.group for item in moved} - return len(moved_groups) == 1 and (not receiver_groups or moved_groups == receiver_groups) + cols, _ = grid_capacity(config) + items = list(receiver.items) + moved_index = 0 + for index, existing in enumerate(items): + if existing is not None or moved_index >= len(moved): + continue + item = moved[moved_index] + if not standard_slot_accepts_item(items, index, item, cols, config): + return False + items[index] = item + moved_index += 1 + return moved_index == len(moved) def remove_last_standard_items(page: CardPage, count: int) -> tuple[CardPage, list[PrintItem]]: @@ -616,20 +1072,48 @@ def remove_last_standard_items(page: CardPage, count: int) -> tuple[CardPage, li return CardPage(items, page.spaced, list(page.extras or [])), [item for _, item in moved_with_indices] -def add_standard_items_to_empty_slots(page: CardPage, moved: list[PrintItem]) -> CardPage: +def add_standard_items_to_empty_slots_with_remaining( + page: CardPage, + moved: list[PrintItem], + config: BuildConfig, +) -> tuple[CardPage, list[PrintItem]]: + cols, _ = grid_capacity(config) items = list(page.items) - moved_index = 0 + remaining = list(moved) for index, item in enumerate(items): - if item is None and moved_index < len(moved): - items[index] = moved[moved_index] - moved_index += 1 - return CardPage(items, page.spaced, None) + if item is None and remaining: + moved_item = remaining[0] + if standard_slot_accepts_item(items, index, moved_item, cols, config): + items[index] = moved_item + remaining.pop(0) + return CardPage(items, page.spaced, None), remaining -def page_accepts_standard_item(page: CardPage, item: PrintItem, config: BuildConfig) -> bool: - if config.crop_black_borders: - groups = standard_groups_on_page(page) - return not groups or groups == {item.group} +def add_standard_items_to_empty_slots(page: CardPage, moved: list[PrintItem], config: BuildConfig) -> CardPage: + return add_standard_items_to_empty_slots_with_remaining(page, moved, config)[0] + + +def standard_slot_accepts_item( + items: list[PrintItem | None], + index: int, + item: PrintItem, + cols: int, + config: BuildConfig, +) -> bool: + if not config.crop_black_borders: + return True + groups = {placed.group for placed in items if placed is not None and not is_character_item(placed)} + if groups and groups != {item.group}: + return False + total = len(items) + for neighbor_index in (index - cols, index + cols): + if neighbor_index < 0 or neighbor_index >= total: + continue + neighbor = items[neighbor_index] + if neighbor is None or is_character_item(neighbor): + continue + if neighbor.group != item.group or neighbor.image_type != item.image_type: + return False return True @@ -651,7 +1135,7 @@ def distribute_standard_overflow( if existing is not None: continue for item_index, item in enumerate(remaining): - if page_accepts_standard_item(CardPage(items), item, config): + if standard_slot_accepts_item(items, slot_index, item, cols, config): items[slot_index] = item del remaining[item_index] changed = True @@ -662,15 +1146,22 @@ def distribute_standard_overflow( pages[page_index] = CardPage(items, page.spaced, page.extras) while remaining: - group = remaining[0].group if config.crop_black_borders else None - chunk: list[PrintItem] = [] + page_items: list[PrintItem | None] = [None] * per_page next_remaining: list[PrintItem] = [] + page_group = remaining[0].group if config.crop_black_borders else None for item in remaining: - if len(chunk) < per_page and (group is None or item.group == group): - chunk.append(item) - else: + if page_group is not None and item.group != page_group: next_remaining.append(item) - pages.append(CardPage(chunk + [None] * (per_page - len(chunk)))) + continue + placed = False + for slot_index, existing in enumerate(page_items): + if existing is None and standard_slot_accepts_item(page_items, slot_index, item, cols, config): + page_items[slot_index] = item + placed = True + break + if not placed: + next_remaining.append(item) + pages.append(CardPage(page_items)) remaining = next_remaining @@ -706,6 +1197,49 @@ def reserve_character_rows_by_evicting_standard( return remaining +def place_characters_in_tight_grid_corners( + pages: list[CardPage], + character_items: list[PrintItem], + config: BuildConfig, +) -> list[PrintItem]: + cols, rows = grid_capacity(config) + if cols < 2 or rows < 2 or not character_items: + return character_items + + corner_slots = [(0, -90), (cols * rows - 1, 90)] + remaining = list(character_items) + overflow: list[PrintItem] = [] + + for page in pages: + if not remaining or page.spaced or page.extras: + continue + + items = list(page.items) + for slot_index, rotation in corner_slots: + if not remaining: + break + current = items[slot_index] + if current is not None and is_character_item(current): + continue + + item = replace(remaining[0], layout_rotation=rotation) + trial = list(items) + trial[slot_index] = None + if not can_place_item(trial, slot_index, item, cols): + continue + + if current is not None: + overflow.append(current) + items[slot_index] = item + remaining = remaining[1:] + + page.items = items + + if overflow: + distribute_standard_overflow(pages, overflow, config) + return remaining + + def swap_standard_space_for_characters( pages: list[CardPage], character_items: list[PrintItem], @@ -737,7 +1271,7 @@ def swap_standard_space_for_characters( if len(moved) != move_count or not standard_move_is_compatible(receiver, moved, config): continue - receiver_trial = add_standard_items_to_empty_slots(receiver, moved) + receiver_trial = add_standard_items_to_empty_slots(receiver, moved, config) char_pool = [item for item, _, _ in receiver_extras] + remaining placed = pack_spaced_items_in_free_space(standard_grid_rects(donor_trial, config), char_pool, config) net_gain = len(placed) - len(receiver_extras) @@ -827,13 +1361,11 @@ def normalize_standard_order(pages: list[CardPage], config: BuildConfig) -> None def standard_page_slot_rects(config: BuildConfig) -> list[tuple[int, int, int, int]]: cols, rows = grid_capacity(config) - page_w = mm_to_px(config.paper_mm[0], config.dpi) margin = mm_to_px(config.margin_mm, config.dpi) card_w = mm_to_px(config.card_mm[0], config.dpi) card_h = mm_to_px(config.card_mm[1], config.dpi) oriented_w, oriented_h = card_h, card_w - grid_w = cols * oriented_w - x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 + x0 = margin y0 = margin rects: list[tuple[int, int, int, int]] = [] for index in range(cols * rows): @@ -844,44 +1376,6 @@ def standard_page_slot_rects(config: BuildConfig) -> list[tuple[int, int, int, i return rects -def shift_rect(rect: tuple[int, int, int, int], dx: int, dy: int) -> tuple[int, int, int, int]: - return rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy - - -def center_rows_horizontally( - entries, - page_w: int, - margin: int, -): - rows: list[list[int]] = [] - for index, entry in enumerate(entries): - rect = entry[1] - placed = False - for row in rows: - row_top = min(entries[i][1][1] for i in row) - row_bottom = max(entries[i][1][3] for i in row) - if rect[1] < row_bottom and rect[3] > row_top: - row.append(index) - placed = True - break - if not placed: - rows.append([index]) - - out = list(entries) - usable_w = page_w - 2 * margin - for row in rows: - left = min(out[i][1][0] for i in row) - right = max(out[i][1][2] for i in row) - row_w = right - left - dx = margin + (usable_w - row_w) // 2 - left - if dx: - for index in row: - values = list(out[index]) - values[1] = shift_rect(values[1], dx, 0) - out[index] = tuple(values) - return out - - def fill_free_space_with_spaced_characters( pages: list[CardPage], character_items: list[PrintItem], @@ -891,13 +1385,11 @@ def fill_free_space_with_spaced_characters( return character_items cols, _ = grid_capacity(config) - page_w = mm_to_px(config.paper_mm[0], config.dpi) margin = mm_to_px(config.margin_mm, config.dpi) card_h = mm_to_px(config.card_mm[1], config.dpi) card_w = mm_to_px(config.card_mm[0], config.dpi) oriented_w, oriented_h = card_h, card_w - grid_w = cols * oriented_w - x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 + x0 = margin y0 = margin remaining = list(character_items) @@ -935,7 +1427,7 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card pages.extend(pack_cropped_standard_group(group_items, cols, rows)) existing_first = fill_cropped_character_pages(pages, character_items, config, reserve_first=False) reserve_first = fill_cropped_character_pages(pages, character_items, config, reserve_first=True) - pages = min((existing_first, reserve_first), key=character_page_score) + pages = min((existing_first, reserve_first), key=grouped_character_page_score) else: group_items = sorted(standard_items, key=noncropped_standard_item_order_key) for i in range(0, len(group_items), per_page): @@ -943,15 +1435,14 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card if i + per_page >= len(group_items): page = page + [None] * (per_page - len(page)) pages.append(CardPage(page)) - existing_first = fill_noncropped_character_pages(pages, character_items, config, reserve_first=False) - reserve_first = fill_noncropped_character_pages(pages, character_items, config, reserve_first=True) - pages = min((existing_first, reserve_first), key=character_page_score) + existing_first = fill_noncropped_character_pages(pages, character_items, config, reserve_first=False, tight_grid_first=False) + reserve_first = fill_noncropped_character_pages(pages, character_items, config, reserve_first=True, tight_grid_first=False) + tight_grid_first = fill_noncropped_character_pages(pages, character_items, config, reserve_first=False, tight_grid_first=True) + pages = min((existing_first, reserve_first, tight_grid_first), key=character_page_score) return pages def pack_spaced_character_pages(items: list[PrintItem], config: BuildConfig) -> list[CardPage]: - page_w = mm_to_px(config.paper_mm[0], config.dpi) - page_h = mm_to_px(config.paper_mm[1], config.dpi) margin = mm_to_px(config.margin_mm, config.dpi) card_w = mm_to_px(config.card_mm[0], config.dpi) card_h = mm_to_px(config.card_mm[1], config.dpi) @@ -964,7 +1455,7 @@ def pack_spaced_character_pages(items: list[PrintItem], config: BuildConfig) -> if placed_count == 0: item = remaining[0] - left = (page_w - card_w) // 2 + left = margin top = margin extras.append((item, (left, top, left + card_w, top + card_h), 0)) placed_count = 1 @@ -1016,20 +1507,42 @@ def clone_pages(pages: list[CardPage]) -> list[CardPage]: return [CardPage(list(page.items), page.spaced, list(page.extras or []) if page.extras else None) for page in pages] -def character_page_score(pages: list[CardPage]) -> tuple[int, int, int]: +def character_page_score(pages: list[CardPage]) -> tuple[int, int, int, int]: character_indices: list[int] = [] character_only_pages = 0 + extra_character_count = 0 for page_index, page in enumerate(pages): page_items = [item for item in page.items if item is not None] - page_items.extend(item for item, _, _ in (page.extras or [])) + extra_items = [item for item, _, _ in (page.extras or [])] + page_items.extend(extra_items) has_character = any(is_character_item(item) for item in page_items) has_standard = any(not is_character_item(item) for item in page_items) if has_character: character_indices.append(page_index) if has_character and not has_standard: character_only_pages += 1 + extra_character_count += sum(1 for item in extra_items if is_character_item(item)) last_character_page = max(character_indices) if character_indices else -1 - return len(pages), last_character_page, character_only_pages + return len(pages), last_character_page, character_only_pages, extra_character_count + + +def grouped_character_page_score(pages: list[CardPage]) -> tuple[int, int, int, int, int]: + character_indices: list[int] = [] + character_only_pages = 0 + extra_character_count = 0 + for page_index, page in enumerate(pages): + page_items = [item for item in page.items if item is not None] + extra_items = [item for item, _, _ in (page.extras or [])] + page_items.extend(extra_items) + has_character = any(is_character_item(item) for item in page_items) + has_standard = any(not is_character_item(item) for item in page_items) + if has_character: + character_indices.append(page_index) + if has_character and not has_standard: + character_only_pages += 1 + extra_character_count += sum(1 for item in extra_items if is_character_item(item)) + last_character_page = max(character_indices) if character_indices else -1 + return len(pages), len(character_indices), character_only_pages, extra_character_count, last_character_page def fill_cropped_character_pages( @@ -1061,11 +1574,15 @@ def fill_noncropped_character_pages( character_items: list[PrintItem], config: BuildConfig, reserve_first: bool, + tight_grid_first: bool, ) -> list[CardPage]: cols, _ = grid_capacity(config) pages = clone_pages(base_pages) remaining = list(character_items) + if tight_grid_first: + remaining = place_characters_in_tight_grid_corners(pages, remaining, config) + if reserve_first: remaining = reserve_character_rows_by_evicting_standard(pages, remaining, config) @@ -1100,6 +1617,38 @@ def draw_crop_lines(page: Image.Image, rect: tuple[int, int, int, int]) -> None: draw.line((0, bottom, page.width, bottom), fill=(0, 0, 0), width=1) +def load_page_image(cache: dict[Path, Image.Image], path: Path) -> Image.Image: + if path not in cache: + cache[path] = Image.open(path).convert("RGB") + return cache[path] + + +def rotated(img: Image.Image, degrees: int) -> Image.Image: + return img.rotate(degrees, expand=True) if degrees else img + + +def paste_print_placements(page: Image.Image, placements: list[tuple[PrintItem, tuple[int, int, int, int], int]]) -> None: + image_cache: dict[Path, Image.Image] = {} + + for _, rect, _ in placements: + draw_crop_lines(page, rect) + + for item, rect, rotation in placements: + face = load_page_image(image_cache, item.path) + bleed = load_page_image(image_cache, item.bleed_path) if item.bleed_path is not None else face + bleed = rotated(bleed, rotation) + cx = (rect[0] + rect[2]) // 2 + cy = (rect[1] + rect[3]) // 2 + page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2)) + + for item, rect, rotation in placements: + face = rotated(load_page_image(image_cache, item.path), rotation) + size = (rect[2] - rect[0], rect[3] - rect[1]) + if face.size != size: + face = face.resize(size, Image.Resampling.LANCZOS) + page.paste(face, (rect[0], rect[1])) + + def split_trailing_character_only_pages(pages: list[CardPage]) -> tuple[list[CardPage], list[PrintItem]]: deferred: list[PrintItem] = [] kept = list(pages) @@ -1113,17 +1662,24 @@ def split_trailing_character_only_pages(pages: list[CardPage]) -> tuple[list[Car return kept, deferred -def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> tuple[int, list[PrintItem]]: +def render_card_pages( + items: list[PrintItem], + config: BuildConfig, + start_index: int, + board_items: list[PrintItem] | None = None, +) -> tuple[int, list[PrintItem], list[PrintItem]]: pages = grouped_card_pages(items, config) + pages, compact_deferred_items = compact_trailing_pages(pages, config) + remaining_board_items = place_loose_items_on_pages(pages, list(board_items or []), config) pages, deferred_character_items = split_trailing_character_only_pages(pages) + deferred_character_items = compact_deferred_items + deferred_character_items page_w = mm_to_px(config.paper_mm[0], config.dpi) page_h = mm_to_px(config.paper_mm[1], config.dpi) margin = mm_to_px(config.margin_mm, config.dpi) - bleed_px = mm_to_px(config.bleed_mm, config.dpi) card_w = mm_to_px(config.card_mm[0], config.dpi) card_h = mm_to_px(config.card_mm[1], config.dpi) oriented_w, oriented_h = card_h, card_w - cols, rows = grid_capacity(config) + cols, _ = grid_capacity(config) def render_one_card_page(page_offset_and_plan: tuple[int, CardPage]) -> tuple[Path, int]: page_offset, page_plan = page_offset_and_plan @@ -1131,27 +1687,13 @@ def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: spaced = page_plan.spaced extras = page_plan.extras or [] page = Image.new("RGB", (page_w, page_h), "white") - image_cache: dict[Path, Image.Image] = {} - - def load_page_image(path: Path) -> Image.Image: - if path not in image_cache: - image_cache[path] = Image.open(path).convert("RGB") - return image_cache[path] - - def make_item_bleed(item: PrintItem, face: Image.Image) -> Image.Image: - if item.bleed_path is not None and bleed_px > 0: - return load_page_image(item.bleed_path) - return make_bleed(face, bleed_px) gap = mm_to_px(5.0, config.dpi) if spaced else 0 if spaced: page_cols = max(1, (page_w - 2 * margin + gap) // (oriented_w + gap)) - page_rows = math.ceil(len(page_items) / page_cols) else: page_cols = cols - page_rows = rows - grid_w = page_cols * oriented_w + max(0, page_cols - 1) * gap - x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 + x0 = margin y0 = margin placements: list[tuple[PrintItem | None, tuple[int, int, int, int], int]] = [] for idx, item in enumerate(page_items): @@ -1162,48 +1704,8 @@ def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: rotate = item.layout_rotation if item is not None and item.layout_rotation is not None else (-90 if col % 2 else 90) placements.append((item, (left, top, left + oriented_w, top + oriented_h), rotate)) - visible_entries = [(item, rect, rotate) for item, rect, rotate in placements if item is not None] + list(extras) - centered_entries = center_rows_horizontally(visible_entries, page_w, margin) - centered_by_identity = {(id(item), rect, rotate): centered for (item, rect, rotate), centered in zip(visible_entries, centered_entries)} - placements = [ - centered_by_identity.get((id(item), rect, rotate), (item, rect, rotate)) - for item, rect, rotate in placements - ] - extras = [ - centered_by_identity[(id(item), rect, rotate)] - for item, rect, rotate in extras - ] - - for item, rect, _ in placements: - if item is None: - continue - draw_crop_lines(page, rect) - for _, rect, _ in extras: - draw_crop_lines(page, rect) - for item, rect, rotate in placements: - if item is None: - continue - face = load_page_image(item.path) - bleed = make_item_bleed(item, face).rotate(rotate, expand=True) - cx = (rect[0] + rect[2]) // 2 - cy = (rect[1] + rect[3]) // 2 - page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2)) - for item, rect, rotate in extras: - face = load_page_image(item.path) - bleed = make_item_bleed(item, face).rotate(rotate, expand=True) - cx = (rect[0] + rect[2]) // 2 - cy = (rect[1] + rect[3]) // 2 - page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2)) - for item, rect, rotate in placements: - if item is None: - continue - face = load_page_image(item.path).rotate(rotate, expand=True) - face = face.resize((oriented_w, oriented_h), Image.Resampling.LANCZOS) - page.paste(face, (rect[0], rect[1])) - for item, rect, rotate in extras: - face = load_page_image(item.path).rotate(rotate, expand=True) - face = face.resize((rect[2] - rect[0], rect[3] - rect[1]), Image.Resampling.LANCZOS) - page.paste(face, (rect[0], rect[1])) + drawable = [(item, rect, rotate) for item, rect, rotate in placements if item is not None] + extras + paste_print_placements(page, drawable) out = PAGES_DIR / f"page_{start_index + page_offset:03d}.png" out.parent.mkdir(parents=True, exist_ok=True) @@ -1214,7 +1716,7 @@ def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: with ThreadPoolExecutor(max_workers=worker_count(len(pages))) as executor: for out, count in executor.map(render_one_card_page, enumerate(pages)): print(f"Wrote {out.relative_to(ROOT)} ({count} card images)") - return start_index + len(pages), deferred_character_items + return start_index + len(pages), deferred_character_items, remaining_board_items def plan_board_rows(items: list[PrintItem], config: BuildConfig) -> list[list[tuple[PrintItem, bool]]]: @@ -1262,7 +1764,6 @@ def render_board_pages( page_h = mm_to_px(config.paper_mm[1], config.dpi) margin = mm_to_px(config.margin_mm, config.dpi) gap = mm_to_px(5.0, config.dpi) - bleed_px = mm_to_px(config.bleed_mm, config.dpi) usable_w = page_w - 2 * margin remaining_characters = list(character_items or []) @@ -1286,9 +1787,8 @@ def render_board_pages( y = margin board_placements: list[tuple[PrintItem, tuple[int, int, int, int], int]] = [] for row in rows: - row_w = sum(bw for _, _, bw, _ in row) + gap * max(0, len(row) - 1) row_h = max(bh for _, _, _, bh in row) - x = margin + (usable_w - row_w) // 2 + x = margin for item, rotate, bw, bh in row: top = y + (row_h - bh) // 2 rect = (x, top, x + bw, top + bh) @@ -1296,20 +1796,20 @@ def render_board_pages( x += bw + gap y += row_h + gap - char_placements = pack_spaced_items_in_free_space( - [rect for _, rect, _ in board_placements], - remaining_characters, - config, - ) - centered = center_rows_horizontally(board_placements + char_placements, page_w, margin) - board_count = len(board_placements) - return centered[:board_count], centered[board_count:] + occupied = [rect for _, rect, _ in board_placements] + standard_items = [item for item in remaining_characters if not is_character_item(item)] + character_items = [item for item in remaining_characters if is_character_item(item)] + standard_placements = pack_loose_items_in_free_space(occupied, standard_items, config) + occupied.extend(rect for _, rect, _ in standard_placements) + character_placements = pack_spaced_items_in_free_space(occupied, character_items, config) + return board_placements, standard_placements + character_placements planned_pages = [] for page_items in pages: board_placements, char_placements = plan_board_placements(page_items) if char_placements: - remaining_characters = remaining_characters[len(char_placements) :] + for placed_item, _, _ in char_placements: + remaining_characters.remove(placed_item) planned_pages.append((board_placements, char_placements)) for page in pack_spaced_character_pages(remaining_characters, config): @@ -1319,35 +1819,9 @@ def render_board_pages( def render_one_board_page(page_offset_and_plan) -> tuple[Path, int]: page_offset, (board_placements, char_placements) = page_offset_and_plan page = Image.new("RGB", (page_w, page_h), "white") - image_cache: dict[Path, Image.Image] = {} - - def load_page_image(path: Path) -> Image.Image: - if path not in image_cache: - image_cache[path] = Image.open(path).convert("RGB") - return image_cache[path] - - def make_item_bleed(item: PrintItem, face: Image.Image) -> Image.Image: - if item.bleed_path is not None and bleed_px > 0: - return load_page_image(item.bleed_path) - return make_bleed(face, bleed_px) placements = board_placements + char_placements - for _, rect, _ in placements: - draw_crop_lines(page, rect) - for item, rect, rotate in placements: - face = load_page_image(item.path) - bleed = make_item_bleed(item, face) - if rotate: - bleed = bleed.rotate(90, expand=True) - cx = (rect[0] + rect[2]) // 2 - cy = (rect[1] + rect[3]) // 2 - page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2)) - for item, rect, rotate in placements: - face = load_page_image(item.path) - if rotate: - face = face.rotate(90, expand=True) - face = face.resize((rect[2] - rect[0], rect[3] - rect[1]), Image.Resampling.LANCZOS) - page.paste(face, (rect[0], rect[1])) + paste_print_placements(page, placements) out = PAGES_DIR / f"page_{start_index + page_offset:03d}.png" out.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/settings.py b/scripts/settings.py index e896ee0..9b58be5 100644 --- a/scripts/settings.py +++ b/scripts/settings.py @@ -61,17 +61,10 @@ CARD_SIZE_OPTIONS_MM = { TEXT_INDENT_CHARS = 2 ITALIC_SHEAR = 0.18 BOLD_OFFSETS = ((1, 0), (0, 1), (1, 1)) -BASE_CARD_SIZE = (825, 1125) FIXED_CROP = (70, 73, 70, 73) -SIDE_EDGE_REPLACE_BANDS = ( - (0.0, 0.2, 10), - (0.2, 0.4, 3), - (0.4, 0.6, 1), - (0.6, 0.8, 3), - (0.8, 1.0, 10), -) TOP_BOTTOM_EDGE_REPLACE_PX = 10 -CHARACTER_EDGE_REPLACE_PX = 3 +DEFAULT_SOURCE_CROP_PX = 37.5 +TEXT_SUPERSAMPLING = 3 MAX_WORKERS = 8 PDF_COMPRESSION_LEVEL = 9 @@ -96,6 +89,7 @@ class BuildConfig: clean_edge_mm: float output_pdf_name: str delete_temp: bool | None + text_supersampling: int render_only: bool = False diff --git a/scripts/text.py b/scripts/text.py index b99d0a7..1b97008 100644 --- a/scripts/text.py +++ b/scripts/text.py @@ -9,7 +9,6 @@ from PIL import Image, ImageChops, ImageDraw, ImageFont from .assets import meta_unit_scale from .settings import ( - BASE_CARD_SIZE, BOLD_OFFSETS, DECK_GROUPS, FIXED_CROP, @@ -19,7 +18,6 @@ from .settings import ( TEXT_AREA_BOTTOM_CROPPED_RATIO, TEXT_AREA_TOP_CROPPED_RATIOS, TEXT_INDENT_CHARS, - TITLE_WIDTH_LIMITS, ) @@ -29,14 +27,19 @@ class Fonts: self._consolas = FONTS_DIR / "consolas.ttf" self._unicode = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf") self._cache: dict[tuple[str, int], ImageFont.FreeTypeFont | ImageFont.ImageFont] = {} + self._source_sizes: dict[int, float] = {} + self._families: dict[int, str] = {} self._lock = threading.Lock() def title(self, java_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: size = max(8, int(java_size / 3) * 2) return self._font("xirod", self._xirod, size) - def text(self, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: - return self._font("consolas", self._consolas, max(8, size)) + def text(self, size: int, source_size: float | None = None) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + 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: 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) else: self._cache[key] = ImageFont.load_default() + self._families[id(self._cache[key])] = name 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() @@ -119,7 +129,7 @@ def format_card_text(text: str | None) -> str: parts = line.split("//", 1) action = parts[0] if not action.strip().endswith(":"): - action = re.sub(r" *$", ";", action) + action = action.rstrip() + ";" action = action.replace(";;", "; ").replace(":;", ":") action = KEYWORD_RE.sub(r"\1", action) result = leading + action @@ -239,43 +249,50 @@ def text_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, if not text: return 0 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 + family = FONTS.family(font) + if family not in {"consolas", "unicode"}: + for segment, segment_font in text_segments(text, font): + for cluster in text_clusters(segment): + layout_text = cluster_layout_text(cluster) + if layout_text: + width += draw.textlength(layout_text, font=segment_font) + if is_zalgo_cluster(cluster): + mark_count = sum(1 for char in cluster if unicodedata.combining(char)) + width += zalgo_extra_advance(segment_font, mark_count) + if bold_offset: + width += bold_offset + return round(width) + + 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=segment_font) + 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(segment_font, mark_count) - if bold_offset: - width += bold_offset - return round(width) + 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: - if hasattr(font, "getmetrics"): - ascent, descent = font.getmetrics() - return max(1, ascent + descent + 2) - return max(1, font_px(font) + 2) - - -TRIMMED_CARD_WIDTHS: dict[Path, int] = {} - - -def trimmed_card_width(deck_dir: Path) -> int: - deck_dir = deck_dir.resolve() - if deck_dir not in TRIMMED_CARD_WIDTHS: - TRIMMED_CARD_WIDTHS[deck_dir] = BASE_CARD_SIZE[0] - FIXED_CROP[0] - FIXED_CROP[2] - return TRIMMED_CARD_WIDTHS[deck_dir] - - -def title_width_limit(deck_dir: Path, meta: dict) -> int: - group = DECK_GROUPS.get(deck_dir.name, "") - ratio = TITLE_WIDTH_LIMITS.get(group, 0.94) - crop_scale = meta_unit_scale(meta) - return math.floor(trimmed_card_width(deck_dir) * crop_scale * ratio) + source_size = FONTS.source_size(font) + scale = font_px(font) / max(1.0, source_size) + return max(1, round(round(source_size * 35 / 32) * scale)) 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) -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_h = int(meta["cardHeight"]) 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) top = round(FIXED_CROP[1] * unit) - right = round(FIXED_CROP[2] * unit) - bottom = round(FIXED_CROP[3] * unit) + right_crop = round(FIXED_CROP[2] * unit) + bottom_crop = round(FIXED_CROP[3] * unit) else: - left = top = right = bottom = 0 - visible_w = max(1, card_w - left - right) - visible_h = max(1, card_h - top - bottom) + left = top = right_crop = bottom_crop = 0 + visible_w = max(1.0, card_w - left - right_crop) + 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 { "left": left, "top": top, - "sx": target_size[0] / visible_w, - "sy": target_size[1] / visible_h, + "sx": sx, + "sy": sy, + "crop_w": visible_w, + "crop_h": visible_h, "target_w": target_size[0], "target_h": target_size[1], } @@ -368,7 +399,7 @@ def draw_styled_text( if not text: return ascent = font.getmetrics()[0] if hasattr(font, "getmetrics") else font_px(font) - bold_offsets = BOLD_OFFSETS if "b" in styles else () + bold_offsets = bold_offsets_for_font(font) if "b" in styles else () def draw_zalgo_cluster( target: Image.Image, @@ -508,7 +539,7 @@ def wrap_runs( current_w += text_width(draw, token, font, styles) continue token_w = text_width(draw, token, font, styles) - should_wrap = current_w + token_w > max_width + should_wrap = current and current[-1][0].isspace() and current_w + token_w > max_width if current and should_wrap: while current and current[-1][0].isspace(): current.pop()