Improve board sizing and page packing

This commit is contained in:
ajp_anton
2026-05-31 19:57:08 +00:00
parent 4f9eb9ec60
commit 774de1c4f5
5 changed files with 766 additions and 193 deletions
+214 -39
View File
@@ -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 '<none>'}
starter badge colors: {'different player colors' if config.starter_unique_colors else 'same color per starter deck'}
Rent raised cards: {config.rent_raised_count}