From 4f9eb9ec600ed775aa1507d41e2de66af247047d Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Sun, 31 May 2026 12:32:49 +0000 Subject: [PATCH] Improve print layout options and character packing --- README.md | 1 + scripts/cards.py | 5 + scripts/cli.py | 31 +++++- scripts/layout.py | 256 +++++++++++++++++++++++++++++++++++----------- 4 files changed, 228 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index c7fa572..1b310ac 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ The final PDF is written to `print/`. Temporary rendered images are written to ` - `--dpi {300,600}`: set output resolution. Default: `300`. - `--paper {a3,a4,legal,letter,tabloid}`: choose paper size. Default: `a3`. - `--card-size SIZE`: choose card size. Use `bridge`, `mini-euro`, `poker`, `tarot`, or a custom size like `70.0x120.0` in millimeters. Default: `poker`. +- `--margin MM`: set page margin in millimeters. Default: `10`. - `--crop`: crop black borders from cards. Default: off. - `--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. diff --git a/scripts/cards.py b/scripts/cards.py index 706cc87..6c7391c 100644 --- a/scripts/cards.py +++ b/scripts/cards.py @@ -633,6 +633,11 @@ def render_sources(config: BuildConfig) -> tuple[list[PrintItem], list[PrintItem if config.crop_black_borders and config.bleed_mm > 0: bleed_px = mm_to_px(config.bleed_mm, config.dpi) bleed_img = character_bleed_crop(character_source, card_px, bleed_px) + corner_size = 2 * bleed_px + ImageDraw.Draw(bleed_img).rectangle( + (0, bleed_img.height - corner_size, corner_size - 1, bleed_img.height - 1), + fill=(0, 0, 0), + ) if config.gamma_enabled: bleed_img = apply_gamma(bleed_img, config.gamma, config.black_point) bleed_out = RENDER_DIR / "cards" / "characters" / "bleed" / f"{safe_name(card_json.stem)}.png" diff --git a/scripts/cli.py b/scripts/cli.py index 6208c91..2bea01b 100644 --- a/scripts/cli.py +++ b/scripts/cli.py @@ -98,6 +98,21 @@ def prompt_positive_int(label: str, default: int) -> int: return parsed +def prompt_nonnegative_float(label: str, default: float) -> float: + value = input(f"{label} [{default:g}]: ").strip() + if not value: + return default + try: + parsed = float(value) + except ValueError: + print(f"Invalid number '{value}', using {default:g}.") + return default + if parsed < 0: + print(f"Unsupported value '{parsed:g}', using {default:g}.") + return default + return parsed + + def prompt_bool(label: str, default: bool) -> bool: suffix = "Y/n" if default else "y/N" value = input(f"{label} [{suffix}]: ").strip().lower() @@ -183,7 +198,7 @@ def default_config() -> BuildConfig: project_version=load_project_version(), starter_unique_colors=True, rent_raised_count=3, - margin_mm=12.0, + margin_mm=10.0, bleed_mm=1.0, clean_edge_mm=0.35, output_pdf_name="", @@ -198,7 +213,8 @@ def interactive_config() -> BuildConfig: 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) + 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() @@ -217,7 +233,7 @@ def interactive_config() -> BuildConfig: project_version=default.project_version, starter_unique_colors=not unify_starter_colors, rent_raised_count=rent_raised_count, - margin_mm=default.margin_mm, + margin_mm=margin_mm, bleed_mm=default.bleed_mm, clean_edge_mm=default.clean_edge_mm, output_pdf_name=output_pdf_name, @@ -232,6 +248,7 @@ def parse_args(argv=None) -> argparse.Namespace: 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("--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.") parser.add_argument("--render-only", action="store_true", help="Render source card/board PNGs and skip page/PDF creation.") @@ -255,6 +272,11 @@ def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfi if args.rent_raised <= 0: raise SystemExit("--rent-raised must be a positive non-zero integer.") rent_raised_count = args.rent_raised + margin_mm = config.margin_mm + if args.margin is not None: + if args.margin < 0: + raise SystemExit("--margin must not be negative.") + margin_mm = args.margin return BuildConfig( paper_name=paper_name, paper_mm=PAPER_SIZES_MM[paper_name], @@ -269,7 +291,7 @@ def apply_overrides(config: BuildConfig, args: argparse.Namespace) -> BuildConfi 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, + margin_mm=margin_mm, bleed_mm=config.bleed_mm, clean_edge_mm=config.clean_edge_mm, output_pdf_name=args.output if args.output is not None else config.output_pdf_name, @@ -294,6 +316,7 @@ def main(argv=None) -> None: card version: {config.project_version or ''} starter badge colors: {'different player colors' if config.starter_unique_colors else 'same color per starter deck'} Rent raised cards: {config.rent_raised_count} + margin: {config.margin_mm:g} mm bleed: {config.bleed_mm} mm ({mm_to_px(config.bleed_mm, config.dpi)} px at {config.dpi} DPI) crop black borders: {'yes' if config.crop_black_borders else 'no'} gamma: {'off' if not config.gamma_enabled else f'{config.gamma} with black point {config.black_point}'} diff --git a/scripts/layout.py b/scripts/layout.py index 9f06c70..0059a18 100644 --- a/scripts/layout.py +++ b/scripts/layout.py @@ -245,6 +245,42 @@ def character_item_bottom_left_corner(item: PrintItem, index: int, cols: int) -> return character_bottom_left_corner(index, rotation, cols) +def slot_corners(index: int, cols: int) -> set[tuple[int, int]]: + row, col = divmod(index, cols) + return {(row, col), (row, col + 1), (row + 1, col), (row + 1, col + 1)} + + +def character_bottom_left_corner_allowed( + page: list[PrintItem | None], + index: int, + item: PrintItem, + cols: int, +) -> bool: + item_corners = slot_corners(index, cols) + item_is_character = is_character_item(item) + item_bl = character_item_bottom_left_corner(item, index, cols) if item_is_character else None + item_key = character_duplicate_key(item) if item_is_character else None + + for placed_index, placed in enumerate(page): + if placed is None: + continue + + placed_corners = slot_corners(placed_index, cols) + placed_is_character = is_character_item(placed) + placed_bl = character_item_bottom_left_corner(placed, placed_index, cols) if placed_is_character else None + placed_key = character_duplicate_key(placed) if placed_is_character else None + + if item_is_character and item_bl in placed_corners: + if not (placed_is_character and placed_bl == item_bl and placed_key == item_key): + return False + + if placed_is_character and placed_bl in item_corners: + if not (item_is_character and item_bl == placed_bl and item_key == placed_key): + return False + + return True + + def neighboring_slots(index: int, cols: int, total: int) -> list[tuple[int, str, str]]: neighbors: list[tuple[int, str, str]] = [] if index % cols: @@ -259,16 +295,8 @@ def neighboring_slots(index: int, cols: int, total: int) -> list[tuple[int, str, def can_place_item(page: list[PrintItem | None], index: int, item: PrintItem, cols: int) -> bool: - if is_character_item(item): - item_corner = character_item_bottom_left_corner(item, index, cols) - item_key = character_duplicate_key(item) - for placed_index, placed in enumerate(page): - if placed is None or not is_character_item(placed): - continue - if character_item_bottom_left_corner(placed, placed_index, cols) != item_corner: - continue - if character_duplicate_key(placed) != item_key: - return False + if not character_bottom_left_corner_allowed(page, index, item, cols): + return False for neighbor_index, item_side, neighbor_side in neighboring_slots(index, cols, len(page)): neighbor = page[neighbor_index] @@ -428,10 +456,12 @@ def fill_bottom_rows_with_spaced_characters( if empty_rows <= 0: continue + edge_margin = margin + top_slack = max(0, y0 - edge_margin) area_left = margin area_top = y0 + first_empty_row * oriented_h area_w = page_w - 2 * margin - area_h = page_h - min(margin, gap) - area_top + area_h = page_h - edge_margin - area_top + top_slack if first_empty_row > 0: area_top += gap area_h -= gap @@ -471,6 +501,73 @@ def rects_conflict(rect: tuple[int, int, int, int], occupied: list[tuple[int, in return False +def pack_spaced_items_in_free_space( + occupied: list[tuple[int, int, int, int]], + items: list[PrintItem], + config: BuildConfig, +) -> list[tuple[PrintItem, tuple[int, int, int, int], int]]: + if not items: + return [] + + page_w = mm_to_px(config.paper_mm[0], config.dpi) + page_h = mm_to_px(config.paper_mm[1], config.dpi) + margin = mm_to_px(config.margin_mm, config.dpi) + gap = mm_to_px(5.0, config.dpi) + 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 + orientations = ((oriented_w, oriented_h, 90), (card_w, card_h, 0)) + best: list[tuple[tuple[int, int, int, int], int]] = [] + seen: set[tuple[int, tuple[tuple[int, int, int, int], ...]]] = set() + + def normalized(rects: list[tuple[int, int, int, int]]) -> tuple[tuple[int, int, int, int], ...]: + return tuple(sorted(rects)) + + def candidates(rects: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int, int]]: + xs = {margin} + ys = {margin} + for left, top, right, bottom in rects: + for item_w, item_h, _ in orientations: + xs.add(right + gap) + xs.add(left - gap - item_w) + ys.add(bottom + gap) + ys.add(top - gap - item_h) + + out: list[tuple[int, int, int, int, int]] = [] + for item_w, item_h, rotation in orientations: + for top in sorted(ys): + if top < margin or top + item_h > page_h - margin: + continue + for left in sorted(xs): + if left < margin or left + item_w > page_w - margin: + continue + rect = (left, top, left + item_w, top + item_h) + if not rects_conflict(rect, rects, gap): + out.append((*rect, rotation)) + return sorted(out, key=lambda value: (value[1], value[0], value[4])) + + def dfs(rects: list[tuple[int, int, int, int]], placements: list[tuple[tuple[int, int, int, int], int]]) -> bool: + nonlocal best + if len(placements) > len(best): + best = list(placements) + if len(best) == len(items): + return True + + state = (len(placements), normalized(rects)) + if state in seen: + return False + seen.add(state) + + for left, top, right, bottom, rotation in candidates(rects): + rect = (left, top, right, bottom) + if dfs(rects + [rect], placements + [(rect, rotation)]): + return True + return False + + dfs(list(occupied), []) + return [(items[index], rect, rotation) for index, (rect, rotation) in enumerate(best)] + + 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 @@ -514,10 +611,8 @@ def fill_free_space_with_spaced_characters( 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) - edge_margin = min(margin, mm_to_px(5.0, config.dpi)) - gap = mm_to_px(5.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_w = mm_to_px(config.card_mm[0], config.dpi) oriented_w, oriented_h = card_h, card_w grid_w = cols * oriented_w grid_h = rows * oriented_h @@ -538,41 +633,10 @@ def fill_free_space_with_spaced_characters( occupied.append((left, top, left + oriented_w, top + oriented_h)) occupied.extend(rect for _, rect, _ in (page.extras or [])) - extras = list(page.extras or []) - placed_count = 0 - while placed_count < len(remaining): - placed = False - 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) - candidates = [] - for y in sorted(ys): - if y < edge_margin or y + item_h > page_h - edge_margin: - continue - for x in sorted(xs): - if x < edge_margin or x + item_w > page_w - edge_margin: - continue - candidates.append((x, y, x + item_w, y + item_h)) - for rect in sorted(candidates, key=lambda value: (value[1], value[0])): - if rects_conflict(rect, occupied, gap): - continue - extras.append((remaining[placed_count], rect, rotation)) - occupied.append(rect) - placed_count += 1 - placed = True - break - if placed: - break - if not placed: - break - if placed_count: - page.extras = extras - remaining = remaining[placed_count:] + placed = pack_spaced_items_in_free_space(occupied, remaining, config) + if placed: + page.extras = list(page.extras or []) + placed + remaining = remaining[len(placed):] return remaining @@ -590,7 +654,7 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card pages.extend(pack_cropped_standard_group(group_items, cols, rows)) character_items = fill_bottom_rows_with_spaced_characters(pages, character_items, config) else: - group_items = sorted(standard_items, key=item_order_key) + group_items = sorted(standard_items, key=noncropped_standard_item_order_key) for i in range(0, len(group_items), per_page): page = group_items[i : i + per_page] if i + per_page >= len(group_items): @@ -600,28 +664,80 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card last_page = pages[-1] if not last_page.spaced: filled_page, character_items = fill_page_with_characters(list(last_page.items), character_items, cols) - pages[-1] = CardPage(filled_page) + 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: - for chunk in chunk_spaced_cards(character_items, config): - pages.append(CardPage(chunk, True)) + pages.extend(pack_spaced_character_pages(character_items, config)) return pages -def chunk_spaced_cards(items: list[PrintItem], config: BuildConfig) -> list[list[PrintItem]]: +def pack_spaced_character_pages(items: list[PrintItem], config: BuildConfig) -> list[CardPage]: page_w = mm_to_px(config.paper_mm[0], config.dpi) page_h = mm_to_px(config.paper_mm[1], config.dpi) margin = mm_to_px(config.margin_mm, config.dpi) gap = mm_to_px(5.0, config.dpi) + edge_margin = margin card_w = mm_to_px(config.card_mm[0], config.dpi) card_h = mm_to_px(config.card_mm[1], config.dpi) oriented_w, oriented_h = card_h, card_w - usable_w = page_w - 2 * margin - usable_h = page_h - 2 * margin - cols = max(1, (usable_w + gap) // (oriented_w + gap)) - rows = max(1, (usable_h + gap) // (oriented_h + gap)) - per_page = max(1, cols * rows) - return [items[i : i + per_page] for i in range(0, len(items), per_page)] + remaining = list(items) + pages: list[CardPage] = [] + + while remaining: + occupied: list[tuple[int, int, int, int]] = [] + extras: list[tuple[PrintItem, tuple[int, int, int, int], int]] = [] + placed_count = 0 + + while placed_count < len(remaining): + best: tuple[int, int, tuple[int, int, int, int], int] | None = None + for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)): + xs = {edge_margin, page_w - edge_margin - item_w} + ys = {edge_margin, page_h - edge_margin - item_h} + for left, top, right, bottom in occupied: + xs.add(right + gap) + xs.add(left - gap - item_w) + ys.add(bottom + gap) + ys.add(top - gap - item_h) + + for top in sorted(ys): + if top < edge_margin or top + item_h > page_h - edge_margin: + continue + for left in sorted(xs): + if left < edge_margin or left + item_w > page_w - edge_margin: + continue + rect = (left, top, left + item_w, top + item_h) + if rects_conflict(rect, occupied, gap): + continue + score = (top, left) + if best is None or score < best[:2]: + best = (top, left, rect, rotation) + break + if best is not None and best[0] == top: + break + + if best is None: + break + + _, _, rect, rotation = best + extras.append((remaining[placed_count], rect, rotation)) + occupied.append(rect) + placed_count += 1 + + if placed_count == 0: + item = remaining[0] + left = (page_w - card_w) // 2 + top = (page_h - card_h) // 2 + extras.append((item, (left, top, left + card_w, top + card_h), 0)) + placed_count = 1 + + pages.append(CardPage([], True, extras)) + remaining = remaining[placed_count:] + + return pages def group_order_key(group: str) -> tuple[int, str]: @@ -643,6 +759,24 @@ def item_order_key(item: PrintItem) -> tuple[int, str, str, str]: return phase, item.sort_label or item.label.lower(), item.image_type, str(item.path) +def noncropped_standard_item_order_key(item: PrintItem) -> tuple[int, str, str, str]: + if item.image_type == "back": + phase = {"encounter": 5, "office": 6, "basement": 7}.get(item.group, 99) + elif item.group == "encounter": + phase = 0 + elif item.group == "office" and item.is_starter: + phase = 1 + elif item.group == "office": + phase = 2 + elif item.group == "basement" and item.is_starter: + phase = 3 + elif item.group == "basement": + phase = 4 + else: + phase = 99 + return phase, item.sort_label or item.label.lower(), item.image_type, str(item.path) + + def draw_crop_lines(page: Image.Image, rect: tuple[int, int, int, int]) -> None: draw = ImageDraw.Draw(page) left, top, right, bottom = rect @@ -703,7 +837,7 @@ def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: occupied_rects = [rect for item, rect, _ in placements if item is not None] occupied_rects.extend(rect for _, rect, _ in extras) - dx, dy = centered_shift(occupied_rects, page_w, page_h, min(margin, gap or margin)) + dx, dy = centered_shift(occupied_rects, page_w, page_h, margin) if dx or dy: placements = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in placements] extras = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in extras]