Initial Blockminers printer
This commit is contained in:
+310
@@ -0,0 +1,310 @@
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from .assets import load_project_version
|
||||
from .boards import board_mode_fits_paper
|
||||
from .cards import render_sources
|
||||
from .layout import grid_capacity, render_board_pages, render_card_pages
|
||||
from .pdf import write_pdf
|
||||
from .settings import (
|
||||
CARD_SIZE_OPTIONS_MM,
|
||||
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 = render_card_pages(card_items, config, 1)
|
||||
next_page = render_board_pages(board_items, config, next_page)
|
||||
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 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_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) -> 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_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 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="a5-area",
|
||||
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=12.0,
|
||||
bleed_mm=1.0,
|
||||
clean_edge_mm=0.35,
|
||||
output_pdf_name="",
|
||||
delete_temp=True,
|
||||
render_only=False,
|
||||
)
|
||||
|
||||
|
||||
def interactive_config() -> 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)
|
||||
board_mode, custom_board = prompt_board_mode(PAPER_SIZES_MM[paper], default.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()
|
||||
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=not unify_starter_colors,
|
||||
rent_raised_count=rent_raised_count,
|
||||
margin_mm=default.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.")
|
||||
parser.add_argument("--yes", action="store_true", help="Use defaults without prompting.")
|
||||
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("--crop", action="store_true", help="Crop black borders.")
|
||||
parser.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
|
||||
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,
|
||||
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,
|
||||
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=config.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 main(argv=None) -> None:
|
||||
args = parse_args(argv)
|
||||
config = default_config() if args.yes else interactive_config()
|
||||
config = apply_overrides(config, args)
|
||||
|
||||
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 mode: {config.board_mode}
|
||||
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}
|
||||
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 '<not written>'}
|
||||
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()
|
||||
Reference in New Issue
Block a user