Improve board sizing and page packing
This commit is contained in:
@@ -23,19 +23,23 @@ python3 build_printable.py
|
|||||||
Run with defaults:
|
Run with defaults:
|
||||||
|
|
||||||
```bash
|
```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/`.
|
The final PDF is written to `print/`. Temporary rendered images are written to `print/temp/`.
|
||||||
|
|
||||||
## Supported Flags
|
## 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`.
|
- `--dpi {300,600}`: set output resolution. Default: `300`.
|
||||||
- `--paper {a3,a4,legal,letter,tabloid}`: choose paper size. Default: `a3`.
|
- `--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`.
|
- `--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`.
|
- `--margin MM`: set page margin in millimeters. Default: `10`.
|
||||||
- `--crop`: crop black borders from cards. Default: off.
|
- `--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.
|
- `--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.
|
- `--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`.
|
- `--output NAME`: write the PDF as `print/NAME`; `.pdf` is added if missing. Default filename: `blockminers_printable.pdf`.
|
||||||
|
|||||||
+51
-24
@@ -1,9 +1,32 @@
|
|||||||
import math
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .assets import load_json, load_rgb
|
from .assets import load_json, load_rgb
|
||||||
from .settings import BOARD_DECK, DECKS_DIR, BuildConfig
|
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(
|
def board_target_mm_for_mode(
|
||||||
board_path: Path,
|
board_path: Path,
|
||||||
@@ -11,29 +34,16 @@ def board_target_mm_for_mode(
|
|||||||
board_custom_mm: tuple[float, float] | None = None,
|
board_custom_mm: tuple[float, float] | None = None,
|
||||||
) -> tuple[float, float]:
|
) -> tuple[float, float]:
|
||||||
if board_custom_mm is not None:
|
if board_custom_mm is not None:
|
||||||
|
if board_mode == "custom-fit":
|
||||||
|
return fit_size_inside(board_custom_mm)
|
||||||
return 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,
|
if board_mode.endswith("-fit"):
|
||||||
# capped to fit inside A4.
|
base_mode = board_mode.removesuffix("-fit")
|
||||||
a5_area = 148.0 * 210.0
|
if base_mode in BOARD_SIZE_OPTIONS_MM and base_mode != "original":
|
||||||
width = math.sqrt(a5_area * ratio)
|
return fit_size_inside(BOARD_SIZE_OPTIONS_MM[base_mode])
|
||||||
height = math.sqrt(a5_area / ratio)
|
|
||||||
if width > 210.0 or height > 297.0:
|
return BOARD_SIZE_OPTIONS_MM.get(board_mode, ORIGINAL_BOARD_MM)
|
||||||
scale = min(210.0 / width, 297.0 / height)
|
|
||||||
width *= scale
|
|
||||||
height *= scale
|
|
||||||
return width, height
|
|
||||||
|
|
||||||
|
|
||||||
def board_aspect_ratio(board_path: Path) -> float:
|
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)
|
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_w = paper_mm[0] - 2 * margin_mm
|
||||||
usable_h = paper_mm[1] - 2 * margin_mm
|
usable_h = paper_mm[1] - 2 * margin_mm
|
||||||
board_paths = sorted((DECKS_DIR / BOARD_DECK).glob("*-jumbo-front.png"))
|
board_paths = sorted((DECKS_DIR / BOARD_DECK).glob("*-jumbo-front.png"))
|
||||||
if not board_paths:
|
if not board_paths:
|
||||||
return True
|
return True
|
||||||
for board_path in board_paths:
|
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_upright = board_w <= usable_w and board_h <= usable_h
|
||||||
fits_rotated = board_h <= usable_w and board_w <= usable_h
|
fits_rotated = board_h <= usable_w and board_w <= usable_h
|
||||||
if not (fits_upright or fits_rotated):
|
if not (fits_upright or fits_rotated):
|
||||||
return False
|
return False
|
||||||
return True
|
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))
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
from PIL import Image, ImageChops, ImageDraw
|
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 .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 (
|
from .settings import (
|
||||||
BASE_CARD_SIZE,
|
BASE_CARD_SIZE,
|
||||||
BASEMENT_SINGLE_STARTER_COLOR,
|
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")):
|
for board_path in sorted(board_dir.glob("*-jumbo-front.png")):
|
||||||
target_mm = board_target_mm(board_path, config)
|
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))
|
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")
|
adjusted = apply_print_adjustments(image, config, target_px, "board")
|
||||||
out = RENDER_DIR / "boards" / board_path.name
|
out = RENDER_DIR / "boards" / board_path.name
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
+214
-39
@@ -5,12 +5,14 @@ import textwrap
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .assets import load_project_version
|
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 .cards import render_sources
|
||||||
from .layout import grid_capacity, render_board_pages, render_card_pages
|
from .layout import grid_capacity, render_board_pages, render_card_pages
|
||||||
from .pdf import write_pdf
|
from .pdf import write_pdf
|
||||||
from .settings import (
|
from .settings import (
|
||||||
|
BOARD_DECK,
|
||||||
CARD_SIZE_OPTIONS_MM,
|
CARD_SIZE_OPTIONS_MM,
|
||||||
|
DECKS_DIR,
|
||||||
PAGES_DIR,
|
PAGES_DIR,
|
||||||
PAPER_SIZES_MM,
|
PAPER_SIZES_MM,
|
||||||
RENDER_DIR,
|
RENDER_DIR,
|
||||||
@@ -43,8 +45,8 @@ def build_printable(config: BuildConfig) -> Path | None:
|
|||||||
|
|
||||||
cols, rows = grid_capacity(config)
|
cols, rows = grid_capacity(config)
|
||||||
print(f"Card grid: {cols} columns x {rows} rows on {config.paper_name.upper()} ({cols * rows} images/page)")
|
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, deferred_character_items = render_card_pages(card_items, config, 1)
|
||||||
next_page = render_board_pages(board_items, config, next_page)
|
next_page = render_board_pages(board_items, config, next_page, deferred_character_items)
|
||||||
pdf = write_pdf(config)
|
pdf = write_pdf(config)
|
||||||
if pdf:
|
if pdf:
|
||||||
print(f"Wrote {pdf.relative_to(ROOT)}")
|
print(f"Wrote {pdf.relative_to(ROOT)}")
|
||||||
@@ -65,6 +67,29 @@ def prompt_choice(label: str, options, default: str) -> str:
|
|||||||
return 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:
|
def prompt_int(label: str, default: int, allowed=None) -> int:
|
||||||
suffix = f" [{default}]"
|
suffix = f" [{default}]"
|
||||||
if allowed:
|
if allowed:
|
||||||
@@ -134,20 +159,30 @@ def prompt_card_size(default: tuple[float, float]) -> tuple[float, float]:
|
|||||||
return CARD_SIZE_OPTIONS_MM[choice]
|
return CARD_SIZE_OPTIONS_MM[choice]
|
||||||
|
|
||||||
|
|
||||||
def prompt_board_mode(paper_mm: tuple[float, float], margin_mm: float) -> tuple[str, tuple[float, float] | None]:
|
def prompt_board_mode(
|
||||||
choices = ["a5-area", "a5-fit"]
|
paper_mm: tuple[float, float],
|
||||||
if board_mode_fits_paper("a4-fit", paper_mm, margin_mm):
|
margin_mm: float,
|
||||||
choices.append("a4-fit")
|
initial: tuple[str, tuple[float, float] | None] | None = None,
|
||||||
choices.append("custom")
|
) -> tuple[str, tuple[float, float] | None]:
|
||||||
choice = prompt_choice("Board size", choices, "a5-area")
|
if initial is not None:
|
||||||
if choice != "custom":
|
board_mode, custom_board = initial
|
||||||
return choice, None
|
if board_size_fits_paper(board_mode, custom_board, paper_mm, margin_mm):
|
||||||
raw = input("Custom board size in mm, width x height: ").strip().lower().replace(" ", "")
|
return board_mode, custom_board
|
||||||
match = re.fullmatch(r"([0-9.]+)x([0-9.]+)", raw)
|
print_board_size_does_not_fit(board_mode, custom_board, paper_mm, margin_mm)
|
||||||
if match:
|
|
||||||
return "custom", (float(match.group(1)), float(match.group(2)))
|
option_text = ", ".join(VISIBLE_BOARD_MODE_CHOICES)
|
||||||
print("Invalid custom board size, using a5-area.")
|
while True:
|
||||||
return "a5-area", None
|
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]:
|
def prompt_gamma() -> tuple[bool, float, float]:
|
||||||
@@ -183,6 +218,31 @@ def parse_card_size_arg(value: str) -> tuple[float, float]:
|
|||||||
return width, height
|
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:
|
def default_config() -> BuildConfig:
|
||||||
return BuildConfig(
|
return BuildConfig(
|
||||||
paper_name="a3",
|
paper_name="a3",
|
||||||
@@ -190,7 +250,7 @@ def default_config() -> BuildConfig:
|
|||||||
dpi=300,
|
dpi=300,
|
||||||
crop_black_borders=False,
|
crop_black_borders=False,
|
||||||
card_mm=CARD_SIZE_OPTIONS_MM["poker"],
|
card_mm=CARD_SIZE_OPTIONS_MM["poker"],
|
||||||
board_mode="a5-area",
|
board_mode="original",
|
||||||
board_custom_mm=None,
|
board_custom_mm=None,
|
||||||
gamma_enabled=True,
|
gamma_enabled=True,
|
||||||
gamma=0.7,
|
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()
|
default = default_config()
|
||||||
paper = prompt_choice("Paper size", list(PAPER_SIZES_MM), default.paper_name)
|
paper = args.paper or prompt_choice("Paper size", list(PAPER_SIZES_MM), default.paper_name)
|
||||||
dpi = prompt_int("DPI", default.dpi, [300, 600])
|
dpi = args.dpi if args.dpi is not None else 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)
|
if args.crop or args.no_crop:
|
||||||
margin_mm = prompt_nonnegative_float("Page margin in mm", default.margin_mm)
|
crop = args.crop
|
||||||
board_mode, custom_board = prompt_board_mode(PAPER_SIZES_MM[paper], margin_mm)
|
else:
|
||||||
unify_starter_colors = prompt_bool("Unify starter badge colors", not default.starter_unique_colors)
|
crop = prompt_bool("Crop black borders", default.crop_black_borders)
|
||||||
rent_raised_count = prompt_positive_int("Number of Rent raised", default.rent_raised_count)
|
|
||||||
gamma_enabled, gamma, black_point = prompt_gamma()
|
card_mm = args.card_size if args.card_size else prompt_card_size(default.card_mm)
|
||||||
output_pdf_name = prompt_output_pdf_name()
|
|
||||||
|
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(
|
return BuildConfig(
|
||||||
paper_name=paper,
|
paper_name=paper,
|
||||||
paper_mm=PAPER_SIZES_MM[paper],
|
paper_mm=PAPER_SIZES_MM[paper],
|
||||||
@@ -231,7 +321,7 @@ def interactive_config() -> BuildConfig:
|
|||||||
gamma=gamma,
|
gamma=gamma,
|
||||||
black_point=black_point,
|
black_point=black_point,
|
||||||
project_version=default.project_version,
|
project_version=default.project_version,
|
||||||
starter_unique_colors=not unify_starter_colors,
|
starter_unique_colors=starter_unique_colors,
|
||||||
rent_raised_count=rent_raised_count,
|
rent_raised_count=rent_raised_count,
|
||||||
margin_mm=margin_mm,
|
margin_mm=margin_mm,
|
||||||
bleed_mm=default.bleed_mm,
|
bleed_mm=default.bleed_mm,
|
||||||
@@ -244,13 +334,21 @@ def interactive_config() -> BuildConfig:
|
|||||||
|
|
||||||
def parse_args(argv=None) -> argparse.Namespace:
|
def parse_args(argv=None) -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="Render Blockminers sources and create printable cut sheets.")
|
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("--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("--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("--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("--margin", type=float, help="Page margin in millimeters.")
|
||||||
parser.add_argument("--crop", action="store_true", help="Crop black borders.")
|
crop_group = parser.add_mutually_exclusive_group()
|
||||||
parser.add_argument("--no-gamma", action="store_true", help="Disable print brightening gamma.")
|
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("--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("--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("--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:
|
if args.margin < 0:
|
||||||
raise SystemExit("--margin must not be negative.")
|
raise SystemExit("--margin must not be negative.")
|
||||||
margin_mm = args.margin
|
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(
|
return BuildConfig(
|
||||||
paper_name=paper_name,
|
paper_name=paper_name,
|
||||||
paper_mm=PAPER_SIZES_MM[paper_name],
|
paper_mm=PAPER_SIZES_MM[paper_name],
|
||||||
dpi=dpi,
|
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,
|
card_mm=args.card_size if args.card_size else config.card_mm,
|
||||||
board_mode=config.board_mode,
|
board_mode=board_mode,
|
||||||
board_custom_mm=config.board_custom_mm,
|
board_custom_mm=board_custom_mm,
|
||||||
gamma_enabled=False if args.no_gamma else config.gamma_enabled,
|
gamma_enabled=gamma_enabled,
|
||||||
gamma=config.gamma,
|
gamma=config.gamma,
|
||||||
black_point=config.black_point,
|
black_point=config.black_point,
|
||||||
project_version=config.project_version,
|
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:
|
def main(argv=None) -> None:
|
||||||
args = parse_args(argv)
|
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 = apply_overrides(config, args)
|
||||||
|
config = ensure_board_size_fits(config, args.board_size is not None)
|
||||||
|
|
||||||
print(
|
print(
|
||||||
textwrap.dedent(
|
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
|
paper: {config.paper_name.upper()} {config.paper_mm[0]} x {config.paper_mm[1]} mm
|
||||||
dpi: {config.dpi}
|
dpi: {config.dpi}
|
||||||
card: {config.card_mm[0]} x {config.card_mm[1]} mm
|
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>'}
|
card version: {config.project_version or '<none>'}
|
||||||
starter badge colors: {'different player colors' if config.starter_unique_colors else 'same color per starter deck'}
|
starter badge colors: {'different player colors' if config.starter_unique_colors else 'same color per starter deck'}
|
||||||
Rent raised cards: {config.rent_raised_count}
|
Rent raised cards: {config.rent_raised_count}
|
||||||
|
|||||||
+493
-126
@@ -438,9 +438,8 @@ def fill_bottom_rows_with_spaced_characters(
|
|||||||
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
||||||
oriented_w, oriented_h = card_h, card_w
|
oriented_w, oriented_h = card_h, card_w
|
||||||
grid_w = cols * oriented_w
|
grid_w = cols * oriented_w
|
||||||
grid_h = rows * oriented_h
|
|
||||||
x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
|
x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
|
||||||
y0 = margin + ((page_h - 2 * margin) - grid_h) // 2
|
y0 = margin
|
||||||
remaining = list(character_items)
|
remaining = list(character_items)
|
||||||
|
|
||||||
for page in pages:
|
for page in pages:
|
||||||
@@ -456,12 +455,10 @@ def fill_bottom_rows_with_spaced_characters(
|
|||||||
if empty_rows <= 0:
|
if empty_rows <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
edge_margin = margin
|
|
||||||
top_slack = max(0, y0 - edge_margin)
|
|
||||||
area_left = margin
|
area_left = margin
|
||||||
area_top = y0 + first_empty_row * oriented_h
|
area_top = y0 + first_empty_row * oriented_h
|
||||||
area_w = page_w - 2 * margin
|
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:
|
if first_empty_row > 0:
|
||||||
area_top += gap
|
area_top += gap
|
||||||
area_h -= 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)]
|
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]:
|
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
|
return rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy
|
||||||
|
|
||||||
|
|
||||||
def centered_shift(
|
def center_rows_horizontally(
|
||||||
rects: list[tuple[int, int, int, int]],
|
entries,
|
||||||
page_w: int,
|
page_w: int,
|
||||||
page_h: int,
|
margin: int,
|
||||||
edge_margin: int,
|
):
|
||||||
) -> tuple[int, int]:
|
rows: list[list[int]] = []
|
||||||
if not rects:
|
for index, entry in enumerate(entries):
|
||||||
return 0, 0
|
rect = entry[1]
|
||||||
left = min(rect[0] for rect in rects)
|
placed = False
|
||||||
top = min(rect[1] for rect in rects)
|
for row in rows:
|
||||||
right = max(rect[2] for rect in rects)
|
row_top = min(entries[i][1][1] for i in row)
|
||||||
bottom = max(rect[3] for rect in rects)
|
row_bottom = max(entries[i][1][3] for i in row)
|
||||||
bbox_w = right - left
|
if rect[1] < row_bottom and rect[3] > row_top:
|
||||||
bbox_h = bottom - top
|
row.append(index)
|
||||||
target_left = (page_w - bbox_w) // 2
|
placed = True
|
||||||
target_top = (page_h - bbox_h) // 2
|
break
|
||||||
min_left = edge_margin
|
if not placed:
|
||||||
max_left = page_w - edge_margin - bbox_w
|
rows.append([index])
|
||||||
min_top = edge_margin
|
|
||||||
max_top = page_h - edge_margin - bbox_h
|
out = list(entries)
|
||||||
if max_left >= min_left:
|
usable_w = page_w - 2 * margin
|
||||||
target_left = min(max(target_left, min_left), max_left)
|
for row in rows:
|
||||||
if max_top >= min_top:
|
left = min(out[i][1][0] for i in row)
|
||||||
target_top = min(max(target_top, min_top), max_top)
|
right = max(out[i][1][2] for i in row)
|
||||||
return target_left - left, target_top - top
|
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(
|
def fill_free_space_with_spaced_characters(
|
||||||
@@ -607,17 +890,15 @@ def fill_free_space_with_spaced_characters(
|
|||||||
if not character_items:
|
if not character_items:
|
||||||
return 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_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)
|
margin = mm_to_px(config.margin_mm, config.dpi)
|
||||||
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
||||||
card_w = mm_to_px(config.card_mm[0], config.dpi)
|
card_w = mm_to_px(config.card_mm[0], config.dpi)
|
||||||
oriented_w, oriented_h = card_h, card_w
|
oriented_w, oriented_h = card_h, card_w
|
||||||
grid_w = cols * oriented_w
|
grid_w = cols * oriented_w
|
||||||
grid_h = rows * oriented_h
|
|
||||||
x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
|
x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
|
||||||
y0 = margin + ((page_h - 2 * margin) - grid_h) // 2
|
y0 = margin
|
||||||
remaining = list(character_items)
|
remaining = list(character_items)
|
||||||
|
|
||||||
for page in pages:
|
for page in pages:
|
||||||
@@ -652,7 +933,9 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card
|
|||||||
for group in groups:
|
for group in groups:
|
||||||
group_items = [item for item in standard_items if item.group == group]
|
group_items = [item for item in standard_items if item.group == group]
|
||||||
pages.extend(pack_cropped_standard_group(group_items, cols, rows))
|
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:
|
else:
|
||||||
group_items = sorted(standard_items, key=noncropped_standard_item_order_key)
|
group_items = sorted(standard_items, key=noncropped_standard_item_order_key)
|
||||||
for i in range(0, len(group_items), per_page):
|
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):
|
if i + per_page >= len(group_items):
|
||||||
page = page + [None] * (per_page - len(page))
|
page = page + [None] * (per_page - len(page))
|
||||||
pages.append(CardPage(page))
|
pages.append(CardPage(page))
|
||||||
if character_items and pages:
|
existing_first = fill_noncropped_character_pages(pages, character_items, config, reserve_first=False)
|
||||||
last_page = pages[-1]
|
reserve_first = fill_noncropped_character_pages(pages, character_items, config, reserve_first=True)
|
||||||
if not last_page.spaced:
|
pages = min((existing_first, reserve_first), key=character_page_score)
|
||||||
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))
|
|
||||||
return pages
|
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_w = mm_to_px(config.paper_mm[0], config.dpi)
|
||||||
page_h = mm_to_px(config.paper_mm[1], config.dpi)
|
page_h = mm_to_px(config.paper_mm[1], config.dpi)
|
||||||
margin = mm_to_px(config.margin_mm, 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_w = mm_to_px(config.card_mm[0], config.dpi)
|
||||||
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
||||||
oriented_w, oriented_h = card_h, card_w
|
|
||||||
remaining = list(items)
|
remaining = list(items)
|
||||||
pages: list[CardPage] = []
|
pages: list[CardPage] = []
|
||||||
|
|
||||||
while remaining:
|
while remaining:
|
||||||
occupied: list[tuple[int, int, int, int]] = []
|
extras = pack_spaced_items_in_free_space([], remaining, config)
|
||||||
extras: list[tuple[PrintItem, tuple[int, int, int, int], int]] = []
|
placed_count = len(extras)
|
||||||
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
|
|
||||||
|
|
||||||
if placed_count == 0:
|
if placed_count == 0:
|
||||||
item = remaining[0]
|
item = remaining[0]
|
||||||
left = (page_w - card_w) // 2
|
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))
|
extras.append((item, (left, top, left + card_w, top + card_h), 0))
|
||||||
placed_count = 1
|
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)
|
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:
|
def draw_crop_lines(page: Image.Image, rect: tuple[int, int, int, int]) -> None:
|
||||||
draw = ImageDraw.Draw(page)
|
draw = ImageDraw.Draw(page)
|
||||||
left, top, right, bottom = rect
|
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)
|
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 = 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_w = mm_to_px(config.paper_mm[0], config.dpi)
|
||||||
page_h = mm_to_px(config.paper_mm[1], config.dpi)
|
page_h = mm_to_px(config.paper_mm[1], config.dpi)
|
||||||
margin = mm_to_px(config.margin_mm, 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_cols = cols
|
||||||
page_rows = rows
|
page_rows = rows
|
||||||
grid_w = page_cols * oriented_w + max(0, page_cols - 1) * gap
|
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
|
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]] = []
|
placements: list[tuple[PrintItem | None, tuple[int, int, int, int], int]] = []
|
||||||
for idx, item in enumerate(page_items):
|
for idx, item in enumerate(page_items):
|
||||||
row = idx // page_cols
|
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)
|
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))
|
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]
|
visible_entries = [(item, rect, rotate) for item, rect, rotate in placements if item is not None] + list(extras)
|
||||||
occupied_rects.extend(rect for _, rect, _ in extras)
|
centered_entries = center_rows_horizontally(visible_entries, page_w, margin)
|
||||||
dx, dy = centered_shift(occupied_rects, page_w, page_h, margin)
|
centered_by_identity = {(id(item), rect, rotate): centered for (item, rect, rotate), centered in zip(visible_entries, centered_entries)}
|
||||||
if dx or dy:
|
placements = [
|
||||||
placements = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in placements]
|
centered_by_identity.get((id(item), rect, rotate), (item, rect, rotate))
|
||||||
extras = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in extras]
|
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:
|
for item, rect, _ in placements:
|
||||||
if item is None:
|
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)
|
save_temp_png(page, out, config.dpi)
|
||||||
return out, sum(1 for item in page_items if item is not None) + len(extras)
|
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:
|
if pages:
|
||||||
for out, count in executor.map(render_one_card_page, enumerate(pages)):
|
with ThreadPoolExecutor(max_workers=worker_count(len(pages))) as executor:
|
||||||
print(f"Wrote {out.relative_to(ROOT)} ({count} card images)")
|
for out, count in executor.map(render_one_card_page, enumerate(pages)):
|
||||||
return start_index + len(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]]]:
|
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
|
return pages
|
||||||
|
|
||||||
|
|
||||||
def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> int:
|
def render_board_pages(
|
||||||
if not items:
|
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
|
return start_index
|
||||||
pages = plan_board_rows(items, config)
|
pages = plan_board_rows(items, config)
|
||||||
page_w = mm_to_px(config.paper_mm[0], config.dpi)
|
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)
|
gap = mm_to_px(5.0, config.dpi)
|
||||||
bleed_px = mm_to_px(config.bleed_mm, config.dpi)
|
bleed_px = mm_to_px(config.bleed_mm, config.dpi)
|
||||||
usable_w = page_w - 2 * margin
|
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]:
|
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]]]:
|
||||||
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]
|
|
||||||
|
|
||||||
rows: list[list[tuple[PrintItem, bool, int, int]]] = []
|
rows: list[list[tuple[PrintItem, bool, int, int]]] = []
|
||||||
row: list[tuple[PrintItem, bool, int, int]] = []
|
row: list[tuple[PrintItem, bool, int, int]] = []
|
||||||
row_w = row_h = 0
|
row_w = row_h = 0
|
||||||
@@ -953,9 +1283,8 @@ def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index:
|
|||||||
if row:
|
if row:
|
||||||
rows.append(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
|
||||||
y = margin + ((page_h - 2 * margin) - total_h) // 2
|
board_placements: list[tuple[PrintItem, tuple[int, int, int, int], int]] = []
|
||||||
placements: list[tuple[PrintItem, bool, tuple[int, int, int, int]]] = []
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
row_w = sum(bw for _, _, bw, _ in row) + gap * max(0, len(row) - 1)
|
row_w = sum(bw for _, _, bw, _ in row) + gap * max(0, len(row) - 1)
|
||||||
row_h = max(bh for _, _, _, bh in row)
|
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:
|
for item, rotate, bw, bh in row:
|
||||||
top = y + (row_h - bh) // 2
|
top = y + (row_h - bh) // 2
|
||||||
rect = (x, top, x + bw, top + bh)
|
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
|
x += bw + gap
|
||||||
y += row_h + 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)
|
draw_crop_lines(page, rect)
|
||||||
for item, rotate, rect in placements:
|
for item, rect, rotate in placements:
|
||||||
face = load_page_image(item.path)
|
face = load_page_image(item.path)
|
||||||
bleed = make_bleed(face, bleed_px)
|
bleed = make_item_bleed(item, face)
|
||||||
if rotate:
|
if rotate:
|
||||||
bleed = bleed.rotate(90, expand=True)
|
bleed = bleed.rotate(90, expand=True)
|
||||||
cx = (rect[0] + rect[2]) // 2
|
cx = (rect[0] + rect[2]) // 2
|
||||||
cy = (rect[1] + rect[3]) // 2
|
cy = (rect[1] + rect[3]) // 2
|
||||||
page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 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)
|
face = load_page_image(item.path)
|
||||||
if rotate:
|
if rotate:
|
||||||
face = face.rotate(90, expand=True)
|
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 = PAGES_DIR / f"page_{start_index + page_offset:03d}.png"
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
save_temp_png(page, out, config.dpi)
|
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:
|
if planned_pages:
|
||||||
for out, count in executor.map(render_one_board_page, enumerate(pages)):
|
with ThreadPoolExecutor(max_workers=worker_count(len(planned_pages))) as executor:
|
||||||
print(f"Wrote {out.relative_to(ROOT)} ({count} boards)")
|
for out, count in executor.map(render_one_board_page, enumerate(planned_pages)):
|
||||||
return start_index + len(pages)
|
noun = "images" if count != 1 else "image"
|
||||||
|
print(f"Wrote {out.relative_to(ROOT)} ({count} {noun})")
|
||||||
|
return start_index + len(planned_pages)
|
||||||
|
|||||||
Reference in New Issue
Block a user