diff --git a/README.md b/README.md index 1b310ac..49fc605 100644 --- a/README.md +++ b/README.md @@ -23,19 +23,23 @@ python3 build_printable.py Run with defaults: ```bash -python3 build_printable.py --yes +python3 build_printable.py --default ``` The final PDF is written to `print/`. Temporary rendered images are written to `print/temp/`. ## Supported Flags -- `--yes`: use default settings without interactive prompts. +- `--default`: use default settings without interactive prompts. +- `--mod`: use the modded print preset without interactive prompts: `600` DPI, cropped cards, and `a5-fit` boards. - `--dpi {300,600}`: set output resolution. Default: `300`. - `--paper {a3,a4,legal,letter,tabloid}`: choose paper size. Default: `a3`. - `--card-size SIZE`: choose card size. Use `bridge`, `mini-euro`, `poker`, `tarot`, or a custom size like `70.0x120.0` in millimeters. Default: `poker`. +- `--board-size SIZE`: choose board size. Use `original`, `a6`, `a5`, `a4`, `half-letter`, `letter`, or a custom size like `140.0x220.0` in millimeters. Add `-fit` to preserve aspect ratio inside that size. Default: `original`. - `--margin MM`: set page margin in millimeters. Default: `10`. - `--crop`: crop black borders from cards. Default: off. +- `--no-crop`: do not crop black borders from cards. Useful for overriding `--mod`. +- `--gamma`: enable print-brightening gamma correction. Default: on. - `--no-gamma`: disable print-brightening gamma correction. Default: gamma correction on. - `--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`. diff --git a/scripts/boards.py b/scripts/boards.py index 5e9a660..6154494 100644 --- a/scripts/boards.py +++ b/scripts/boards.py @@ -1,9 +1,32 @@ -import math 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) +TRIMMED_BOARD_ASPECT_RATIO = ORIGINAL_BOARD_MM[0] / ORIGINAL_BOARD_MM[1] +BOARD_SIZE_OPTIONS_MM = { + "original": ORIGINAL_BOARD_MM, + "a6": (105.0, 148.0), + "a5": (148.0, 210.0), + "a4": (210.0, 297.0), + "half-letter": (139.7, 215.9), + "letter": (215.9, 279.4), +} +BOARD_MODE_CHOICES = tuple( + [name for name in BOARD_SIZE_OPTIONS_MM] + + [f"{name}-fit" for name in BOARD_SIZE_OPTIONS_MM] + + ["custom", "custom-fit"] +) +VISIBLE_BOARD_MODE_CHOICES = ("original", "a6", "a6-fit", "a5", "a5-fit", "half-letter", "half-letter-fit", "custom") + + +def fit_size_inside(box_mm: tuple[float, float], ratio: float = TRIMMED_BOARD_ASPECT_RATIO) -> tuple[float, float]: + max_w, max_h = box_mm + if ratio > max_w / max_h: + return max_w, max_w / ratio + return max_h * ratio, max_h + def board_target_mm_for_mode( board_path: Path, @@ -11,29 +34,16 @@ def board_target_mm_for_mode( board_custom_mm: tuple[float, float] | None = None, ) -> tuple[float, float]: if board_custom_mm is not None: + if board_mode == "custom-fit": + return fit_size_inside(board_custom_mm) return board_custom_mm - ratio = board_aspect_ratio(board_path) - if board_mode == "a4-fit": - max_w, max_h = 210.0, 297.0 - if ratio > max_w / max_h: - return max_w, max_w / ratio - return max_h * ratio, max_h - if board_mode == "a5-fit": - max_w, max_h = 148.0, 210.0 - if ratio > max_w / max_h: - return max_w, max_w / ratio - return max_h * ratio, max_h - # Default: preserve aspect ratio while using approximately A5 area, - # capped to fit inside A4. - a5_area = 148.0 * 210.0 - width = math.sqrt(a5_area * ratio) - height = math.sqrt(a5_area / ratio) - if width > 210.0 or height > 297.0: - scale = min(210.0 / width, 297.0 / height) - width *= scale - height *= scale - return width, height + if board_mode.endswith("-fit"): + base_mode = board_mode.removesuffix("-fit") + if base_mode in BOARD_SIZE_OPTIONS_MM and base_mode != "original": + return fit_size_inside(BOARD_SIZE_OPTIONS_MM[base_mode]) + + return BOARD_SIZE_OPTIONS_MM.get(board_mode, ORIGINAL_BOARD_MM) def board_aspect_ratio(board_path: Path) -> float: @@ -50,16 +60,33 @@ 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) -def board_mode_fits_paper(board_mode: str, paper_mm: tuple[float, float], margin_mm: float) -> bool: +def board_size_fits_paper( + board_mode: str, + board_custom_mm: tuple[float, float] | None, + paper_mm: tuple[float, float], + margin_mm: float, +) -> bool: usable_w = paper_mm[0] - 2 * margin_mm usable_h = paper_mm[1] - 2 * margin_mm board_paths = sorted((DECKS_DIR / BOARD_DECK).glob("*-jumbo-front.png")) if not board_paths: return True for board_path in board_paths: - board_w, board_h = board_target_mm_for_mode(board_path, board_mode) + board_w, board_h = board_target_mm_for_mode(board_path, board_mode, board_custom_mm) fits_upright = board_w <= usable_w and board_h <= usable_h fits_rotated = board_h <= usable_w and board_w <= usable_h 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 6c7391c..896c0cb 100644 --- a/scripts/cards.py +++ b/scripts/cards.py @@ -5,7 +5,7 @@ from pathlib import Path from PIL import Image, ImageChops, ImageDraw from .assets import cover_resize, find_art_for_json, hex_color, load_json, load_rgb, meta_unit_scale, render_work_scale, safe_name, save_temp_png -from .boards import board_target_mm +from .boards import board_target_mm, crop_board_bleed from .settings import ( BASE_CARD_SIZE, BASEMENT_SINGLE_STARTER_COLOR, @@ -652,7 +652,7 @@ 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 = load_rgb(board_path).convert("RGB") + image = crop_board_bleed(load_rgb(board_path).convert("RGB")) adjusted = apply_print_adjustments(image, config, target_px, "board") out = RENDER_DIR / "boards" / board_path.name out.parent.mkdir(parents=True, exist_ok=True) diff --git a/scripts/cli.py b/scripts/cli.py index 2bea01b..95c4e0c 100644 --- a/scripts/cli.py +++ b/scripts/cli.py @@ -5,12 +5,14 @@ import textwrap from pathlib import Path from .assets import load_project_version -from .boards import board_mode_fits_paper +from .boards import BOARD_MODE_CHOICES, VISIBLE_BOARD_MODE_CHOICES, board_size_fits_paper, board_target_mm_for_mode from .cards import render_sources from .layout import grid_capacity, render_board_pages, render_card_pages from .pdf import write_pdf from .settings import ( + BOARD_DECK, CARD_SIZE_OPTIONS_MM, + DECKS_DIR, PAGES_DIR, PAPER_SIZES_MM, RENDER_DIR, @@ -43,8 +45,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 = render_card_pages(card_items, config, 1) - next_page = render_board_pages(board_items, config, next_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) pdf = write_pdf(config) if pdf: print(f"Wrote {pdf.relative_to(ROOT)}") @@ -65,6 +67,29 @@ def prompt_choice(label: str, options, default: str) -> str: return default +def board_size_label(board_mode: str, board_custom_mm: tuple[float, float] | None) -> str: + if board_custom_mm is None: + return board_mode + suffix = "-fit" if board_mode == "custom-fit" else "" + return f"{board_custom_mm[0]:g}x{board_custom_mm[1]:g}{suffix}" + + +def print_board_size_does_not_fit( + board_mode: str, + board_custom_mm: tuple[float, float] | None, + paper_mm: tuple[float, float], + margin_mm: float, +) -> None: + board_w, board_h = board_target_mm_for_mode(first_board_path(), board_mode, board_custom_mm) + usable_w = paper_mm[0] - 2 * margin_mm + usable_h = paper_mm[1] - 2 * margin_mm + print( + f"Board size '{board_size_label(board_mode, board_custom_mm)}' " + f"({board_w:.2f} x {board_h:.2f} mm) does not fit inside " + f"the printable area ({usable_w:.2f} x {usable_h:.2f} mm)." + ) + + def prompt_int(label: str, default: int, allowed=None) -> int: suffix = f" [{default}]" if allowed: @@ -134,20 +159,30 @@ def prompt_card_size(default: tuple[float, float]) -> tuple[float, float]: return CARD_SIZE_OPTIONS_MM[choice] -def prompt_board_mode(paper_mm: tuple[float, float], margin_mm: float) -> tuple[str, tuple[float, float] | None]: - choices = ["a5-area", "a5-fit"] - if board_mode_fits_paper("a4-fit", paper_mm, margin_mm): - choices.append("a4-fit") - choices.append("custom") - choice = prompt_choice("Board size", choices, "a5-area") - if choice != "custom": - return choice, None - raw = input("Custom board size in mm, width x height: ").strip().lower().replace(" ", "") - match = re.fullmatch(r"([0-9.]+)x([0-9.]+)", raw) - if match: - return "custom", (float(match.group(1)), float(match.group(2))) - print("Invalid custom board size, using a5-area.") - return "a5-area", None +def prompt_board_mode( + paper_mm: tuple[float, float], + margin_mm: float, + initial: tuple[str, tuple[float, float] | None] | None = None, +) -> tuple[str, tuple[float, float] | None]: + if initial is not None: + board_mode, custom_board = initial + if board_size_fits_paper(board_mode, custom_board, paper_mm, margin_mm): + return board_mode, custom_board + print_board_size_does_not_fit(board_mode, custom_board, paper_mm, margin_mm) + + option_text = ", ".join(VISIBLE_BOARD_MODE_CHOICES) + while True: + raw = input(f"Board size [original] ({option_text}): ").strip().lower() + if not raw: + raw = "original" + try: + board_mode, custom_board = parse_board_size_arg(raw) + except argparse.ArgumentTypeError as error: + print(error) + continue + if board_size_fits_paper(board_mode, custom_board, paper_mm, margin_mm): + return board_mode, custom_board + print_board_size_does_not_fit(board_mode, custom_board, paper_mm, margin_mm) def prompt_gamma() -> tuple[bool, float, float]: @@ -183,6 +218,31 @@ def parse_card_size_arg(value: str) -> tuple[float, float]: return width, height +def parse_board_size_arg(value: str) -> tuple[str, tuple[float, float] | None]: + normalized = value.strip().lower().replace(" ", "") + if normalized in BOARD_MODE_CHOICES and normalized != "custom": + return normalized, None + match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)x([0-9]+(?:\.[0-9]+)?)(-fit)?", normalized) + if not match: + choices = ", ".join(mode for mode in BOARD_MODE_CHOICES if not mode.startswith("custom")) + raise argparse.ArgumentTypeError(f"expected one of {choices}, or WIDTHxHEIGHT in mm") + width = float(match.group(1)) + height = float(match.group(2)) + if width <= 0 or height <= 0: + raise argparse.ArgumentTypeError("custom board size must be positive") + return ("custom-fit" if match.group(3) else "custom"), (width, height) + + +def first_board_path() -> Path: + board_paths = sorted((DECKS_DIR / BOARD_DECK).glob("*-jumbo-front.png")) + return board_paths[0] if board_paths else DECKS_DIR / BOARD_DECK / "board.png" + + +def board_size_summary(config: BuildConfig) -> str: + width, height = board_target_mm_for_mode(first_board_path(), config.board_mode, config.board_custom_mm) + return f"{config.board_mode} ({width:.2f} x {height:.2f} mm)" + + def default_config() -> BuildConfig: return BuildConfig( paper_name="a3", @@ -190,7 +250,7 @@ def default_config() -> BuildConfig: dpi=300, crop_black_borders=False, card_mm=CARD_SIZE_OPTIONS_MM["poker"], - board_mode="a5-area", + board_mode="original", board_custom_mm=None, gamma_enabled=True, gamma=0.7, @@ -207,18 +267,48 @@ def default_config() -> BuildConfig: ) -def interactive_config() -> BuildConfig: +def interactive_config(args: argparse.Namespace) -> BuildConfig: default = default_config() - paper = prompt_choice("Paper size", list(PAPER_SIZES_MM), default.paper_name) - dpi = prompt_int("DPI", default.dpi, [300, 600]) - crop = prompt_bool("Crop black borders", default.crop_black_borders) - card_mm = prompt_card_size(default.card_mm) - margin_mm = prompt_nonnegative_float("Page margin in mm", default.margin_mm) - board_mode, custom_board = prompt_board_mode(PAPER_SIZES_MM[paper], margin_mm) - unify_starter_colors = prompt_bool("Unify starter badge colors", not default.starter_unique_colors) - rent_raised_count = prompt_positive_int("Number of Rent raised", default.rent_raised_count) - gamma_enabled, gamma, black_point = prompt_gamma() - output_pdf_name = prompt_output_pdf_name() + paper = args.paper or prompt_choice("Paper size", list(PAPER_SIZES_MM), default.paper_name) + dpi = args.dpi if args.dpi is not None else prompt_int("DPI", default.dpi, [300, 600]) + + if args.crop or args.no_crop: + crop = args.crop + else: + crop = prompt_bool("Crop black borders", default.crop_black_borders) + + card_mm = args.card_size if args.card_size else prompt_card_size(default.card_mm) + + if args.margin is not None: + if args.margin < 0: + raise SystemExit("--margin must not be negative.") + margin_mm = args.margin + else: + margin_mm = prompt_nonnegative_float("Page margin in mm", default.margin_mm) + + board_mode, custom_board = prompt_board_mode(PAPER_SIZES_MM[paper], margin_mm, args.board_size) + + if args.starter_colors: + starter_unique_colors = args.starter_colors == "separate" + else: + unify_starter_colors = prompt_bool("Unify starter badge colors", not default.starter_unique_colors) + starter_unique_colors = not unify_starter_colors + + if args.rent_raised is not None: + if args.rent_raised <= 0: + raise SystemExit("--rent-raised must be a positive non-zero integer.") + rent_raised_count = args.rent_raised + else: + rent_raised_count = prompt_positive_int("Number of Rent raised", default.rent_raised_count) + + if args.gamma or args.no_gamma: + gamma_enabled = args.gamma + gamma = default.gamma + black_point = default.black_point + else: + gamma_enabled, gamma, black_point = prompt_gamma() + + output_pdf_name = args.output if args.output is not None else prompt_output_pdf_name() return BuildConfig( paper_name=paper, paper_mm=PAPER_SIZES_MM[paper], @@ -231,7 +321,7 @@ def interactive_config() -> BuildConfig: gamma=gamma, black_point=black_point, project_version=default.project_version, - starter_unique_colors=not unify_starter_colors, + starter_unique_colors=starter_unique_colors, rent_raised_count=rent_raised_count, margin_mm=margin_mm, bleed_mm=default.bleed_mm, @@ -244,13 +334,21 @@ def interactive_config() -> BuildConfig: def parse_args(argv=None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Render Blockminers sources and create printable cut sheets.") - parser.add_argument("--yes", action="store_true", help="Use defaults without prompting.") + preset = parser.add_mutually_exclusive_group() + preset.add_argument("--default", action="store_true", help="Use default settings without prompting.") + preset.add_argument("--mod", action="store_true", help="Use the high-resolution cropped mod preset.") + preset.add_argument("--yes", action="store_true", help=argparse.SUPPRESS) parser.add_argument("--dpi", type=int, choices=[300, 600], help="Override DPI.") parser.add_argument("--paper", choices=sorted(PAPER_SIZES_MM), help="Override paper size.") parser.add_argument("--card-size", metavar="SIZE", type=parse_card_size_arg, help="Override card size by name or WIDTHxHEIGHT in mm.") + parser.add_argument("--board-size", metavar="SIZE", type=parse_board_size_arg, help="Override board size by name or WIDTHxHEIGHT in mm.") parser.add_argument("--margin", type=float, help="Page margin in millimeters.") - parser.add_argument("--crop", action="store_true", help="Crop black borders.") - parser.add_argument("--no-gamma", action="store_true", help="Disable print brightening gamma.") + crop_group = parser.add_mutually_exclusive_group() + crop_group.add_argument("--crop", action="store_true", help="Crop black borders.") + crop_group.add_argument("--no-crop", action="store_true", help="Do not crop black borders.") + gamma_group = parser.add_mutually_exclusive_group() + gamma_group.add_argument("--gamma", action="store_true", help="Enable print brightening gamma.") + gamma_group.add_argument("--no-gamma", action="store_true", help="Disable print brightening gamma.") 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.") @@ -277,15 +375,32 @@ def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfi if args.margin < 0: raise SystemExit("--margin must not be negative.") margin_mm = args.margin + crop_black_borders = config.crop_black_borders + if args.crop: + crop_black_borders = True + if args.no_crop: + crop_black_borders = False + + gamma_enabled = config.gamma_enabled + if args.gamma: + gamma_enabled = True + if args.no_gamma: + gamma_enabled = False + + board_mode = config.board_mode + board_custom_mm = config.board_custom_mm + if args.board_size: + board_mode, board_custom_mm = args.board_size + return BuildConfig( paper_name=paper_name, paper_mm=PAPER_SIZES_MM[paper_name], dpi=dpi, - crop_black_borders=True if args.crop else config.crop_black_borders, + crop_black_borders=crop_black_borders, card_mm=args.card_size if args.card_size else config.card_mm, - board_mode=config.board_mode, - board_custom_mm=config.board_custom_mm, - gamma_enabled=False if args.no_gamma else config.gamma_enabled, + board_mode=board_mode, + board_custom_mm=board_custom_mm, + gamma_enabled=gamma_enabled, gamma=config.gamma, black_point=config.black_point, project_version=config.project_version, @@ -300,10 +415,70 @@ def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfi ) +def with_board_size(config: BuildConfig, board_mode: str, board_custom_mm: tuple[float, float] | None) -> BuildConfig: + return BuildConfig( + paper_name=config.paper_name, + paper_mm=config.paper_mm, + dpi=config.dpi, + crop_black_borders=config.crop_black_borders, + card_mm=config.card_mm, + board_mode=board_mode, + board_custom_mm=board_custom_mm, + gamma_enabled=config.gamma_enabled, + gamma=config.gamma, + black_point=config.black_point, + project_version=config.project_version, + starter_unique_colors=config.starter_unique_colors, + rent_raised_count=config.rent_raised_count, + margin_mm=config.margin_mm, + bleed_mm=config.bleed_mm, + clean_edge_mm=config.clean_edge_mm, + output_pdf_name=config.output_pdf_name, + delete_temp=config.delete_temp, + render_only=config.render_only, + ) + + +def ensure_board_size_fits(config: BuildConfig, should_prompt: bool) -> BuildConfig: + if board_size_fits_paper(config.board_mode, config.board_custom_mm, config.paper_mm, config.margin_mm): + return config + print_board_size_does_not_fit(config.board_mode, config.board_custom_mm, config.paper_mm, config.margin_mm) + if not should_prompt: + raise SystemExit(1) + board_mode, custom_board = prompt_board_mode(config.paper_mm, config.margin_mm) + return with_board_size(config, board_mode, custom_board) + + def main(argv=None) -> None: args = parse_args(argv) - config = default_config() if args.yes else interactive_config() + if args.default or args.mod or args.yes: + config = default_config() + else: + config = interactive_config(args) + if args.mod: + config = BuildConfig( + paper_name=config.paper_name, + paper_mm=config.paper_mm, + dpi=600, + crop_black_borders=True, + card_mm=config.card_mm, + board_mode="a5-fit", + board_custom_mm=None, + gamma_enabled=config.gamma_enabled, + gamma=config.gamma, + black_point=config.black_point, + project_version=config.project_version, + starter_unique_colors=config.starter_unique_colors, + rent_raised_count=config.rent_raised_count, + margin_mm=config.margin_mm, + bleed_mm=config.bleed_mm, + clean_edge_mm=config.clean_edge_mm, + output_pdf_name=config.output_pdf_name, + delete_temp=config.delete_temp, + render_only=config.render_only, + ) config = apply_overrides(config, args) + config = ensure_board_size_fits(config, args.board_size is not None) print( textwrap.dedent( @@ -312,7 +487,7 @@ def main(argv=None) -> None: paper: {config.paper_name.upper()} {config.paper_mm[0]} x {config.paper_mm[1]} mm dpi: {config.dpi} card: {config.card_mm[0]} x {config.card_mm[1]} mm - board mode: {config.board_mode} + board: {board_size_summary(config)} card version: {config.project_version or ''} starter badge colors: {'different player colors' if config.starter_unique_colors else 'same color per starter deck'} Rent raised cards: {config.rent_raised_count} diff --git a/scripts/layout.py b/scripts/layout.py index 0059a18..4c56717 100644 --- a/scripts/layout.py +++ b/scripts/layout.py @@ -438,9 +438,8 @@ def fill_bottom_rows_with_spaced_characters( card_h = mm_to_px(config.card_mm[1], config.dpi) oriented_w, oriented_h = card_h, card_w grid_w = cols * oriented_w - grid_h = rows * oriented_h x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 - y0 = margin + ((page_h - 2 * margin) - grid_h) // 2 + y0 = margin remaining = list(character_items) for page in pages: @@ -456,12 +455,10 @@ def fill_bottom_rows_with_spaced_characters( if empty_rows <= 0: continue - edge_margin = margin - top_slack = max(0, y0 - edge_margin) area_left = margin area_top = y0 + first_empty_row * oriented_h area_w = page_w - 2 * margin - area_h = page_h - edge_margin - area_top + top_slack + area_h = page_h - margin - area_top if first_empty_row > 0: area_top += gap area_h -= gap @@ -568,35 +565,321 @@ def pack_spaced_items_in_free_space( return [(items[index], rect, rotation) for index, (rect, rotation) in enumerate(best)] +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 + y0 = margin + rects: list[tuple[int, int, int, int]] = [] + for index, item in enumerate(page.items): + if item is None: + continue + row, col = divmod(index, cols) + left = x0 + col * oriented_w + top = y0 + row * oriented_h + rects.append((left, top, left + oriented_w, top + oriented_h)) + rects.extend(rect for _, rect, _ in (page.extras or [])) + 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 standard_move_is_compatible(receiver: CardPage, moved: list[PrintItem], config: BuildConfig) -> bool: + if not moved: + 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) + + +def remove_last_standard_items(page: CardPage, count: int) -> tuple[CardPage, list[PrintItem]]: + items = list(page.items) + moved_with_indices: list[tuple[int, PrintItem]] = [] + for index in range(len(items) - 1, -1, -1): + item = items[index] + if item is None or is_character_item(item): + continue + moved_with_indices.append((index, item)) + items[index] = None + if len(moved_with_indices) == count: + break + moved_with_indices.reverse() + 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: + items = list(page.items) + moved_index = 0 + 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) + + +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} + return True + + +def distribute_standard_overflow( + pages: list[CardPage], + overflow: list[PrintItem], + config: BuildConfig, +) -> None: + cols, rows = grid_capacity(config) + per_page = cols * rows + remaining = list(overflow) + + for page_index, page in enumerate(pages): + if not remaining or page.extras: + continue + items = list(page.items) + changed = False + for slot_index, existing in enumerate(items): + if existing is not None: + continue + for item_index, item in enumerate(remaining): + if page_accepts_standard_item(CardPage(items), item, config): + items[slot_index] = item + del remaining[item_index] + changed = True + break + if not remaining: + break + if changed: + 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] = [] + next_remaining: list[PrintItem] = [] + for item in remaining: + if len(chunk) < per_page and (group is None or item.group == group): + chunk.append(item) + else: + next_remaining.append(item) + pages.append(CardPage(chunk + [None] * (per_page - len(chunk)))) + remaining = next_remaining + + +def reserve_character_rows_by_evicting_standard( + pages: list[CardPage], + character_items: list[PrintItem], + config: BuildConfig, +) -> list[PrintItem]: + cols, rows = grid_capacity(config) + if cols < 2 or rows < 2 or len(character_items) < 2: + return character_items + + remaining = list(character_items) + overflow: list[PrintItem] = [] + for page_index, page in enumerate(pages): + if len(remaining) < 2 or page.spaced or page.extras: + continue + standard_count = sum(1 for item in page.items if item is not None and not is_character_item(item)) + if standard_count != cols * rows: + continue + trial_page, moved = remove_last_standard_items(page, cols) + if len(moved) != cols: + continue + placed = pack_spaced_items_in_free_space(standard_grid_rects(trial_page, config), remaining[:2], config) + if len(placed) != 2: + continue + pages[page_index] = CardPage(trial_page.items, trial_page.spaced, placed) + overflow.extend(moved) + remaining = remaining[2:] + + if overflow: + distribute_standard_overflow(pages, overflow, config) + return remaining + + +def swap_standard_space_for_characters( + pages: list[CardPage], + character_items: list[PrintItem], + config: BuildConfig, +) -> list[PrintItem]: + remaining = list(character_items) + if not remaining: + return remaining + + while remaining: + best = None + best_score = 0 + + for receiver_index, receiver in enumerate(pages): + receiver_extras = list(receiver.extras or []) + if not receiver_extras: + continue + empty_slots = [index for index, item in enumerate(receiver.items) if item is None] + if not empty_slots: + continue + + for donor_index, donor in enumerate(pages): + if donor_index == receiver_index 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)) + max_move = min(len(empty_slots), donor_standard_count) + for move_count in range(max_move, 0, -1): + donor_trial, moved = remove_last_standard_items(donor, move_count) + 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) + 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) + if net_gain > best_score: + best_score = net_gain + best = (receiver_index, donor_index, receiver_trial, donor_trial, placed, char_pool) + if best_score >= len(remaining): + break + if best_score >= len(remaining): + break + if best_score >= len(remaining): + break + + if best is None: + return remaining + + receiver_index, donor_index, receiver_trial, donor_trial, placed, char_pool = best + pages[receiver_index] = receiver_trial + donor_trial.extras = placed + pages[donor_index] = donor_trial + remaining = char_pool[len(placed) :] + + return remaining + + +def normalize_standard_order(pages: list[CardPage], config: BuildConfig) -> None: + standard_items = [ + item + for page in pages + for item in page.items + if item is not None and not is_character_item(item) + ] + if not standard_items: + return + + if config.crop_black_borders: + standard_items = sorted(standard_items, key=lambda item: (group_order_key(item.group), item_order_key(item))) + else: + standard_items = sorted(standard_items, key=noncropped_standard_item_order_key) + + if config.crop_black_borders: + buckets: dict[tuple[str, str], list[PrintItem]] = {} + for item in standard_items: + buckets.setdefault((item.group, item.image_type), []).append(item) + + for page in pages: + items = list(page.items) + for index, item in enumerate(items): + if item is None or is_character_item(item): + continue + key = (item.group, item.image_type) + items[index] = buckets[key].pop(0) if buckets.get(key) else item + page.items = items + return + + gap = mm_to_px(5.0, config.dpi) + slot_rects = standard_page_slot_rects(config) + slots: list[tuple[CardPage, int]] = [] + for page in pages: + if page.spaced: + continue + extra_rects = [rect for _, rect, _ in (page.extras or [])] + for index, item in enumerate(page.items): + if item is not None and is_character_item(item): + continue + if index >= len(slot_rects): + continue + if extra_rects and rects_conflict(slot_rects[index], extra_rects, gap): + continue + slots.append((page, index)) + + item_iter = iter(standard_items) + filled: set[tuple[int, int]] = set() + for page, index in slots: + try: + item = next(item_iter) + except StopIteration: + break + page.items[index] = item + filled.add((id(page), index)) + + for page in pages: + for index, item in enumerate(page.items): + if item is not None and not is_character_item(item) and (id(page), index) not in filled: + page.items[index] = 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 + y0 = margin + rects: list[tuple[int, int, int, int]] = [] + for index in range(cols * rows): + row, col = divmod(index, cols) + left = x0 + col * oriented_w + top = y0 + row * oriented_h + rects.append((left, top, left + oriented_w, top + oriented_h)) + 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 centered_shift( - rects: list[tuple[int, int, int, int]], +def center_rows_horizontally( + entries, page_w: int, - page_h: int, - edge_margin: int, -) -> tuple[int, int]: - if not rects: - return 0, 0 - left = min(rect[0] for rect in rects) - top = min(rect[1] for rect in rects) - right = max(rect[2] for rect in rects) - bottom = max(rect[3] for rect in rects) - bbox_w = right - left - bbox_h = bottom - top - target_left = (page_w - bbox_w) // 2 - target_top = (page_h - bbox_h) // 2 - min_left = edge_margin - max_left = page_w - edge_margin - bbox_w - min_top = edge_margin - max_top = page_h - edge_margin - bbox_h - if max_left >= min_left: - target_left = min(max(target_left, min_left), max_left) - if max_top >= min_top: - target_top = min(max(target_top, min_top), max_top) - return target_left - left, target_top - top + 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( @@ -607,17 +890,15 @@ def fill_free_space_with_spaced_characters( if not character_items: return character_items - cols, rows = grid_capacity(config) + cols, _ = grid_capacity(config) 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_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 - grid_h = rows * oriented_h x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 - y0 = margin + ((page_h - 2 * margin) - grid_h) // 2 + y0 = margin remaining = list(character_items) for page in pages: @@ -652,7 +933,9 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card for group in groups: group_items = [item for item in standard_items if item.group == group] pages.extend(pack_cropped_standard_group(group_items, cols, rows)) - character_items = fill_bottom_rows_with_spaced_characters(pages, character_items, config) + 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) else: group_items = sorted(standard_items, key=noncropped_standard_item_order_key) for i in range(0, len(group_items), per_page): @@ -660,18 +943,9 @@ 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)) - if character_items and pages: - last_page = pages[-1] - if not last_page.spaced: - filled_page, character_items = fill_page_with_characters(list(last_page.items), character_items, cols) - if not character_items: - pages[-1] = CardPage(filled_page) - else: - character_items = sorted((item for item in items if item.group.startswith("character:")), key=item_order_key) - character_items = fill_free_space_with_spaced_characters(pages, character_items, config) - - if character_items: - pages.extend(pack_spaced_character_pages(character_items, config)) + 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) return pages @@ -679,58 +953,19 @@ def pack_spaced_character_pages(items: list[PrintItem], config: BuildConfig) -> 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) - edge_margin = margin 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 remaining = list(items) pages: list[CardPage] = [] while remaining: - occupied: list[tuple[int, int, int, int]] = [] - extras: list[tuple[PrintItem, tuple[int, int, int, int], int]] = [] - placed_count = 0 - - while placed_count < len(remaining): - best: tuple[int, int, tuple[int, int, int, int], int] | None = None - for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)): - xs = {edge_margin, page_w - edge_margin - item_w} - ys = {edge_margin, page_h - edge_margin - item_h} - for left, top, right, bottom in occupied: - xs.add(right + gap) - xs.add(left - gap - item_w) - ys.add(bottom + gap) - ys.add(top - gap - item_h) - - for top in sorted(ys): - if top < edge_margin or top + item_h > page_h - edge_margin: - continue - for left in sorted(xs): - if left < edge_margin or left + item_w > page_w - edge_margin: - continue - rect = (left, top, left + item_w, top + item_h) - if rects_conflict(rect, occupied, gap): - continue - score = (top, left) - if best is None or score < best[:2]: - best = (top, left, rect, rotation) - break - if best is not None and best[0] == top: - break - - if best is None: - break - - _, _, rect, rotation = best - extras.append((remaining[placed_count], rect, rotation)) - occupied.append(rect) - placed_count += 1 + extras = pack_spaced_items_in_free_space([], remaining, config) + placed_count = len(extras) if placed_count == 0: item = remaining[0] left = (page_w - card_w) // 2 - top = (page_h - card_h) // 2 + top = margin extras.append((item, (left, top, left + card_w, top + card_h), 0)) placed_count = 1 @@ -777,6 +1012,85 @@ def noncropped_standard_item_order_key(item: PrintItem) -> tuple[int, str, str, return phase, item.sort_label or item.label.lower(), item.image_type, str(item.path) +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]: + character_indices: list[int] = [] + character_only_pages = 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 [])) + 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 + last_character_page = max(character_indices) if character_indices else -1 + return len(pages), last_character_page, character_only_pages + + +def fill_cropped_character_pages( + base_pages: list[CardPage], + character_items: list[PrintItem], + config: BuildConfig, + reserve_first: bool, +) -> list[CardPage]: + pages = clone_pages(base_pages) + remaining = list(character_items) + + if reserve_first: + remaining = reserve_character_rows_by_evicting_standard(pages, remaining, config) + remaining = fill_bottom_rows_with_spaced_characters(pages, remaining, config) + remaining = swap_standard_space_for_characters(pages, remaining, config) + if not reserve_first: + remaining = reserve_character_rows_by_evicting_standard(pages, remaining, config) + remaining = fill_bottom_rows_with_spaced_characters(pages, remaining, config) + remaining = swap_standard_space_for_characters(pages, remaining, config) + + normalize_standard_order(pages, config) + if remaining: + pages.extend(pack_spaced_character_pages(remaining, config)) + return pages + + +def fill_noncropped_character_pages( + base_pages: list[CardPage], + character_items: list[PrintItem], + config: BuildConfig, + reserve_first: bool, +) -> list[CardPage]: + cols, _ = grid_capacity(config) + pages = clone_pages(base_pages) + remaining = list(character_items) + + if reserve_first: + remaining = reserve_character_rows_by_evicting_standard(pages, remaining, config) + + if remaining and pages: + last_page = pages[-1] + if not last_page.spaced: + filled_page, filled_remaining = fill_page_with_characters(list(last_page.items), remaining, cols) + if not filled_remaining: + pages[-1] = CardPage(filled_page) + remaining = [] + + remaining = fill_free_space_with_spaced_characters(pages, remaining, config) + remaining = swap_standard_space_for_characters(pages, remaining, config) + + if not reserve_first: + remaining = reserve_character_rows_by_evicting_standard(pages, remaining, config) + remaining = fill_free_space_with_spaced_characters(pages, remaining, config) + remaining = swap_standard_space_for_characters(pages, remaining, config) + + normalize_standard_order(pages, config) + if remaining: + pages.extend(pack_spaced_character_pages(remaining, config)) + return pages + + def draw_crop_lines(page: Image.Image, rect: tuple[int, int, int, int]) -> None: draw = ImageDraw.Draw(page) left, top, right, bottom = rect @@ -786,8 +1100,22 @@ 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 render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> int: +def split_trailing_character_only_pages(pages: list[CardPage]) -> tuple[list[CardPage], list[PrintItem]]: + deferred: list[PrintItem] = [] + kept = list(pages) + while kept: + page = kept[-1] + extras = page.extras or [] + if page.items or not extras or any(not is_character_item(item) for item, _, _ in extras): + break + deferred = [item for item, _, _ in extras] + deferred + kept.pop() + return kept, deferred + + +def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> tuple[int, list[PrintItem]]: pages = grouped_card_pages(items, config) + pages, deferred_character_items = split_trailing_character_only_pages(pages) 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) @@ -823,9 +1151,8 @@ def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: page_cols = cols page_rows = rows grid_w = page_cols * oriented_w + max(0, page_cols - 1) * gap - grid_h = page_rows * oriented_h + max(0, page_rows - 1) * gap x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 - y0 = margin + ((page_h - 2 * margin) - grid_h) // 2 + y0 = margin placements: list[tuple[PrintItem | None, tuple[int, int, int, int], int]] = [] for idx, item in enumerate(page_items): row = idx // page_cols @@ -835,12 +1162,17 @@ 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)) - occupied_rects = [rect for item, rect, _ in placements if item is not None] - occupied_rects.extend(rect for _, rect, _ in extras) - dx, dy = centered_shift(occupied_rects, page_w, page_h, margin) - if dx or dy: - placements = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in placements] - extras = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in extras] + 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: @@ -878,10 +1210,11 @@ def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: save_temp_png(page, out, config.dpi) return out, sum(1 for item in page_items if item is not None) + len(extras) - 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) + if pages: + 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 def plan_board_rows(items: list[PrintItem], config: BuildConfig) -> list[list[tuple[PrintItem, bool]]]: @@ -916,8 +1249,13 @@ def plan_board_rows(items: list[PrintItem], config: BuildConfig) -> list[list[tu return pages -def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> int: - if not items: +def render_board_pages( + items: list[PrintItem], + config: BuildConfig, + start_index: int, + character_items: list[PrintItem] | None = None, +) -> int: + if not items and not character_items: return start_index pages = plan_board_rows(items, config) page_w = mm_to_px(config.paper_mm[0], config.dpi) @@ -926,17 +1264,9 @@ def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index: 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 []) - def render_one_board_page(page_offset_and_items: tuple[int, list[tuple[PrintItem, bool]]]) -> tuple[Path, int]: - page_offset, page_items = page_offset_and_items - 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 plan_board_placements(page_items: list[tuple[PrintItem, bool]]) -> tuple[list[tuple[PrintItem, tuple[int, int, int, int], int]], list[tuple[PrintItem, tuple[int, int, int, int], int]]]: rows: list[list[tuple[PrintItem, bool, int, int]]] = [] row: list[tuple[PrintItem, bool, int, int]] = [] row_w = row_h = 0 @@ -953,9 +1283,8 @@ def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index: if row: rows.append(row) - total_h = sum(max(bh for _, _, _, bh in r) for r in rows) + gap * max(0, len(rows) - 1) - y = margin + ((page_h - 2 * margin) - total_h) // 2 - placements: list[tuple[PrintItem, bool, tuple[int, int, int, int]]] = [] + 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) @@ -963,21 +1292,57 @@ def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index: for item, rotate, bw, bh in row: top = y + (row_h - bh) // 2 rect = (x, top, x + bw, top + bh) - placements.append((item, rotate, rect)) + board_placements.append((item, rect, 90 if rotate else 0)) x += bw + gap y += row_h + gap - for _, _, rect in placements: + 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:] + + 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) :] + planned_pages.append((board_placements, char_placements)) + + for page in pack_spaced_character_pages(remaining_characters, config): + planned_pages.append(([], list(page.extras or []))) + remaining_characters = [] + + 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, rotate, rect in placements: + for item, rect, rotate in placements: face = load_page_image(item.path) - bleed = make_bleed(face, bleed_px) + 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, rotate, rect in placements: + for item, rect, rotate in placements: face = load_page_image(item.path) if rotate: face = face.rotate(90, expand=True) @@ -987,9 +1352,11 @@ def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index: out = PAGES_DIR / f"page_{start_index + page_offset:03d}.png" out.parent.mkdir(parents=True, exist_ok=True) save_temp_png(page, out, config.dpi) - return out, len(page_items) + return out, len(placements) - with ThreadPoolExecutor(max_workers=worker_count(len(pages))) as executor: - for out, count in executor.map(render_one_board_page, enumerate(pages)): - print(f"Wrote {out.relative_to(ROOT)} ({count} boards)") - return start_index + len(pages) + if planned_pages: + with ThreadPoolExecutor(max_workers=worker_count(len(planned_pages))) as executor: + for out, count in executor.map(render_one_board_page, enumerate(planned_pages)): + noun = "images" if count != 1 else "image" + print(f"Wrote {out.relative_to(ROOT)} ({count} {noun})") + return start_index + len(planned_pages)