import argparse import re import shutil import textwrap from pathlib import Path from .assets import load_project_version 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, ROOT, TEMP_DIR, BuildConfig, mm_to_px, ) def should_delete_temp(config: BuildConfig) -> bool: if config.render_only: return False if config.delete_temp is not None: return config.delete_temp return prompt_bool("Delete temporary rendered files", True) def build_printable(config: BuildConfig) -> Path | None: print("Rendering source cards...") card_items, board_items = render_sources(config) print(f"Rendered {len(card_items)} card-side images and {len(board_items)} boards.") if config.render_only: print(f"Render-only mode: wrote source images under {RENDER_DIR.relative_to(ROOT)} and skipped page/PDF creation.") return None if PAGES_DIR.exists(): shutil.rmtree(PAGES_DIR) PAGES_DIR.mkdir(parents=True, exist_ok=True) 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) pdf = write_pdf(config) if pdf: print(f"Wrote {pdf.relative_to(ROOT)}") if should_delete_temp(config): shutil.rmtree(TEMP_DIR, ignore_errors=True) print(f"Deleted {TEMP_DIR.relative_to(ROOT)}") return pdf def prompt_choice(label: str, options, default: str) -> str: option_text = ", ".join(options) value = input(f"{label} [{default}] ({option_text}): ").strip().lower() if not value: return default if value in options: return value print(f"Unknown option '{value}', using {default}.") 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: suffix += " (" + ", ".join(str(x) for x in allowed) + ")" value = input(f"{label}{suffix}: ").strip() if not value: return default try: parsed = int(value) except ValueError: print(f"Invalid number '{value}', using {default}.") return default if allowed and parsed not in allowed: print(f"Unsupported value '{parsed}', using {default}.") return default return parsed def prompt_positive_int(label: str, default: int) -> int: value = input(f"{label} [{default}]: ").strip() if not value: return default try: parsed = int(value) except ValueError: print(f"Invalid number '{value}', using {default}.") return default if parsed <= 0: print(f"Unsupported value '{parsed}', using {default}.") return default return parsed def prompt_nonnegative_float(label: str, default: float) -> float: value = input(f"{label} [{default:g}]: ").strip() if not value: return default try: parsed = float(value) except ValueError: print(f"Invalid number '{value}', using {default:g}.") return default if parsed < 0: print(f"Unsupported value '{parsed:g}', using {default:g}.") return default return parsed def prompt_bool(label: str, default: bool) -> bool: suffix = "Y/n" if default else "y/N" value = input(f"{label} [{suffix}]: ").strip().lower() if not value: return default return value.startswith("y") def prompt_card_size(default: tuple[float, float]) -> tuple[float, float]: options = list(CARD_SIZE_OPTIONS_MM) choice = prompt_choice("Card size", options + ["custom"], "poker") if choice == "custom": raw = input("Custom card size in mm, width x height: ").strip().lower().replace(" ", "") match = re.fullmatch(r"([0-9.]+)x([0-9.]+)", raw) if match: return float(match.group(1)), float(match.group(2)) print(f"Invalid custom size, using {default[0]} x {default[1]} mm.") return default return CARD_SIZE_OPTIONS_MM[choice] 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]: choice = prompt_choice("Gamma correction", ["default", "none", "custom"], "default") if choice == "none": return False, 1.0, 0.0 if choice == "custom": raw = input("Gamma exponent [0.7]: ").strip() try: gamma = float(raw) if raw else 0.7 except ValueError: gamma = 0.7 return True, gamma, 0.02 return True, 0.7, 0.02 def prompt_output_pdf_name() -> str: return input("Output PDF name [blockminers_printable.pdf]: ").strip() def parse_card_size_arg(value: str) -> tuple[float, float]: normalized = value.strip().lower().replace(" ", "") if normalized in CARD_SIZE_OPTIONS_MM: return CARD_SIZE_OPTIONS_MM[normalized] match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)x([0-9]+(?:\.[0-9]+)?)", normalized) if not match: choices = ", ".join(sorted(CARD_SIZE_OPTIONS_MM)) 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 card size must be positive") 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", paper_mm=PAPER_SIZES_MM["a3"], dpi=300, crop_black_borders=False, card_mm=CARD_SIZE_OPTIONS_MM["poker"], board_mode="original", board_custom_mm=None, gamma_enabled=True, gamma=0.7, black_point=0.02, project_version=load_project_version(), starter_unique_colors=True, rent_raised_count=3, margin_mm=10.0, bleed_mm=1.0, clean_edge_mm=0.35, output_pdf_name="", delete_temp=True, render_only=False, ) def interactive_config(args: argparse.Namespace) -> BuildConfig: default = default_config() 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], dpi=dpi, crop_black_borders=crop, card_mm=card_mm, board_mode=board_mode, board_custom_mm=custom_board, gamma_enabled=gamma_enabled, gamma=gamma, black_point=black_point, project_version=default.project_version, starter_unique_colors=starter_unique_colors, rent_raised_count=rent_raised_count, margin_mm=margin_mm, bleed_mm=default.bleed_mm, clean_edge_mm=default.clean_edge_mm, output_pdf_name=output_pdf_name, delete_temp=None, render_only=default.render_only, ) def parse_args(argv=None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Render Blockminers sources and create printable cut sheets.") 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.") 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.") parser.add_argument("--keep-temp", action="store_true", help="Keep temporary rendered PNGs under print/temp/.") parser.add_argument( "--starter-colors", choices=["separate", "same"], help="Use five player colors for starter badges, or one category color repeated.", ) return parser.parse_args(argv) def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfig: paper_name = args.paper or config.paper_name dpi = args.dpi or config.dpi delete_temp = False if args.keep_temp else config.delete_temp rent_raised_count = config.rent_raised_count 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 margin_mm = config.margin_mm if args.margin is not None: 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=crop_black_borders, card_mm=args.card_size if args.card_size else config.card_mm, 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, starter_unique_colors=(args.starter_colors == "separate") if args.starter_colors else config.starter_unique_colors, rent_raised_count=rent_raised_count, margin_mm=margin_mm, bleed_mm=config.bleed_mm, 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, render_only=args.render_only or config.render_only, ) 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) 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( f""" Build settings: 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: {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} margin: {config.margin_mm:g} mm 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}'} 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'} """ ).strip() ) build_printable(config) if __name__ == "__main__": main()