import math from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, replace from pathlib import Path from PIL import Image, ImageDraw from .assets import save_temp_png from .cards import make_bleed from .settings import BuildConfig, PAGES_DIR, PrintItem, ROOT, mm_to_px, worker_count @dataclass class CardPage: items: list[PrintItem | None] spaced: bool = False extras: list[tuple[PrintItem, tuple[int, int, int, int], int]] | None = None def grid_capacity(config: BuildConfig) -> tuple[int, int]: 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) card_w = mm_to_px(config.card_mm[0], config.dpi) card_h = mm_to_px(config.card_mm[1], config.dpi) oriented_w = card_h oriented_h = card_w cols = max(1, (page_w - 2 * margin) // oriented_w) rows = max(1, (page_h - 2 * margin) // oriented_h) return int(cols), int(rows) def columns_to_pages(column_items: list[list[PrintItem]], cols_per_page: int, rows: int) -> list[list[PrintItem | None]]: pages: list[list[PrintItem | None]] = [] for i in range(0, len(column_items), cols_per_page): page_columns = column_items[i : i + cols_per_page] page: list[PrintItem | None] = [None] * (cols_per_page * rows) column_groups: dict[str, list[tuple[int, list[PrintItem]]]] = {} group_order: list[str] = [] for col_index, column in enumerate(page_columns): if not column: continue key = column[0].image_type if key not in column_groups: column_groups[key] = [] group_order.append(key) column_groups[key].append((col_index, column)) for key in group_order: columns = column_groups[key] sorted_items = [item for _, column in columns for item in column] row_major_slots = [ row_index * cols_per_page + col_index for row_index in range(rows) for col_index, column in columns if row_index < len(column) ] for slot, item in zip(row_major_slots, sorted_items): page[slot] = item pages.append(page) return pages def interleave_columns(*column_groups: list[list[PrintItem]]) -> list[list[PrintItem]]: columns: list[list[PrintItem]] = [] max_len = max((len(group) for group in column_groups), default=0) for index in range(max_len): for group in column_groups: if index < len(group): columns.append(group[index]) return columns def balanced_columns(items: list[PrintItem], column_count: int) -> list[list[PrintItem]]: if column_count <= 0 or not items: return [] columns: list[list[PrintItem]] = [[] for _ in range(column_count)] for index, item in enumerate(items): columns[index % len(columns)].append(item) return columns def column_height(column: list[PrintItem | None]) -> int: for index in range(len(column) - 1, -1, -1): if column[index] is not None: return index + 1 return 0 def column_type(column: list[PrintItem | None]) -> str | None: for item in column: if item is not None: return item.image_type return None def optimize_mixed_columns(columns: list[list[PrintItem | None]], rows: int) -> list[list[PrintItem | None]]: columns = [list(column) for column in columns] while True: heights = [column_height(column) for column in columns] if not heights: return columns current_max = max(heights) best_move = None best_height = current_max for donor_index, donor in enumerate(columns): donor_type = column_type(donor) donor_height = heights[donor_index] if donor_type is None or donor_height <= 1 or donor_height < current_max: continue moved_item = donor[donor_height - 1] if moved_item is None: continue for receiver_index, receiver in enumerate(columns): if receiver_index == donor_index: continue receiver_type = column_type(receiver) receiver_height = heights[receiver_index] if receiver_type is None or receiver_type == donor_type: continue insert_index = receiver_height + 1 if insert_index >= rows: continue candidate_heights = list(heights) candidate_heights[donor_index] = donor_height - 1 candidate_heights[receiver_index] = insert_index + 1 candidate_max = max(candidate_heights) if candidate_max < best_height: best_height = candidate_max best_move = (donor_index, receiver_index, donor_height - 1, insert_index, moved_item) if best_move is None: return columns donor_index, receiver_index, donor_slot, receiver_slot, moved_item = best_move columns[donor_index][donor_slot] = None while len(columns[receiver_index]) <= receiver_slot: columns[receiver_index].append(None) columns[receiver_index][receiver_slot] = moved_item def page_from_columns(columns: list[list[PrintItem | None]], cols_per_page: int, rows: int) -> list[PrintItem | None]: page: list[PrintItem | None] = [None] * (cols_per_page * rows) for col_index, column in enumerate(columns[:cols_per_page]): for row_index, item in enumerate(column[:rows]): if item is not None: page[row_index * cols_per_page + col_index] = item return page def full_pages_then_remainder(items: list[PrintItem], cols: int, rows: int) -> tuple[list[CardPage], list[PrintItem]]: per_page = cols * rows pages: list[CardPage] = [] full_count = len(items) // per_page for page_index in range(full_count): start = page_index * per_page pages.append(CardPage(list(items[start : start + per_page]))) return pages, items[full_count * per_page :] def best_mixed_column_counts(front_count: int, back_count: int, cols: int, rows: int) -> tuple[int, int]: best_score: tuple[int, int, int] | None = None best_counts = (0, 0) for front_cols in range(cols + 1): back_cols = cols - front_cols if front_count and front_cols == 0: continue if back_count and back_cols == 0: continue if not front_count and front_cols: continue if not back_count and back_cols: continue front_placed = min(front_count, front_cols * rows) if front_cols else 0 back_placed = min(back_count, back_cols * rows) if back_cols else 0 if front_placed + back_placed <= 0: continue front_h = math.ceil(front_placed / front_cols) if front_cols else 0 back_h = math.ceil(back_placed / back_cols) if back_cols else 0 used_cols = (front_cols if front_placed else 0) + (back_cols if back_placed else 0) score = (front_placed + back_placed, -max(front_h, back_h), -used_cols) if best_score is None or score > best_score: best_score = score best_counts = (front_cols if front_placed else 0, back_cols if back_placed else 0) return best_counts def pack_remainder_pages(front_items: list[PrintItem], back_items: list[PrintItem], cols: int, rows: int) -> list[CardPage]: pages: list[CardPage] = [] fronts = list(front_items) backs = list(back_items) while fronts or backs: front_cols, back_cols = best_mixed_column_counts(len(fronts), len(backs), cols, rows) if front_cols + back_cols <= 0: break front_take = min(len(fronts), front_cols * rows) back_take = min(len(backs), back_cols * rows) page_columns = balanced_columns(fronts[:front_take], front_cols) + balanced_columns(backs[:back_take], back_cols) page_columns = optimize_mixed_columns(page_columns, rows) pages.append(CardPage(page_from_columns(page_columns, cols, rows))) fronts = fronts[front_take:] backs = backs[back_take:] return pages def pack_cropped_standard_group(group_items: list[PrintItem], cols: int, rows: int) -> list[CardPage]: fronts = sorted((item for item in group_items if item.image_type != "back"), key=item_order_key) backs = sorted((item for item in group_items if item.image_type == "back"), key=item_order_key) front_pages, front_remainder = full_pages_then_remainder(fronts, cols, rows) back_pages, back_remainder = full_pages_then_remainder(backs, cols, rows) return front_pages + back_pages + pack_remainder_pages(front_remainder, back_remainder, cols, rows) def is_character_item(item: PrintItem) -> bool: return item.group.startswith("character:") def character_edge_compatible(rotation: int, side: str) -> bool: if rotation == 90: edge_by_side = {"left": "top", "right": "bottom", "top": "right", "bottom": "left"} else: edge_by_side = {"left": "bottom", "right": "top", "top": "left", "bottom": "right"} return edge_by_side[side] in {"top", "right"} def item_edge_compatible(item: PrintItem, side: str, cols: int, index: int) -> bool: if not is_character_item(item): return True rotation = item.layout_rotation if item.layout_rotation is not None else (-90 if index % cols else 90) return character_edge_compatible(rotation, side) def character_duplicate_key(item: PrintItem) -> str: return item.sort_label or item.label.lower() def character_bottom_left_corner(index: int, rotation: int, cols: int) -> tuple[int, int]: row, col = divmod(index, cols) if rotation == 90: return row + 1, col + 1 return row, col def character_item_bottom_left_corner(item: PrintItem, index: int, cols: int) -> tuple[int, int]: rotation = item.layout_rotation if item.layout_rotation is not None else (-90 if index % cols else 90) 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: neighbors.append((index - 1, "left", "right")) if index % cols != cols - 1 and index + 1 < total: neighbors.append((index + 1, "right", "left")) if index >= cols: neighbors.append((index - cols, "top", "bottom")) if index + cols < total: neighbors.append((index + cols, "bottom", "top")) return neighbors def can_place_item(page: list[PrintItem | None], index: int, item: PrintItem, cols: int) -> bool: 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] if neighbor is None: continue if not item_edge_compatible(item, item_side, cols, index): return False if not item_edge_compatible(neighbor, neighbor_side, cols, neighbor_index): return False return True def character_pairs(items: list[PrintItem]) -> tuple[list[tuple[PrintItem, PrintItem]], list[PrintItem]]: by_key: dict[str, list[PrintItem]] = {} for item in items: by_key.setdefault(character_duplicate_key(item), []).append(item) pairs: list[tuple[PrintItem, PrintItem]] = [] leftovers: list[PrintItem] = [] for key in sorted(by_key): group = by_key[key] for i in range(0, len(group) - 1, 2): pairs.append((group[i], group[i + 1])) if len(group) % 2: leftovers.append(group[-1]) return pairs, leftovers def shared_character_corner_slots(page: list[PrintItem | None], cols: int) -> list[tuple[int, int]]: slots: list[tuple[int, int]] = [] for nw_index, item in enumerate(page): if item is not None or nw_index % cols == cols - 1: continue se_index = nw_index + cols + 1 if se_index >= len(page) or page[se_index] is not None: continue slots.append((nw_index, se_index)) return slots def fill_single_characters( page: list[PrintItem | None], character_items: list[PrintItem], cols: int, ) -> tuple[list[PrintItem | None], list[PrintItem]]: empty_indices = [index for index, item in enumerate(page) if item is None] best_page = list(page) best_count = 0 def dfs(slot_pos: int, char_pos: int) -> None: nonlocal best_count, best_page if char_pos > best_count: best_count = char_pos best_page = list(page) if char_pos == len(character_items) or slot_pos == len(empty_indices): return if char_pos + (len(empty_indices) - slot_pos) <= best_count: return index = empty_indices[slot_pos] for rotation in (90, -90): item = replace(character_items[char_pos], layout_rotation=rotation) if can_place_item(page, index, item, cols): page[index] = item dfs(slot_pos + 1, char_pos + 1) page[index] = None dfs(slot_pos + 1, char_pos) dfs(0, 0) return best_page, character_items[best_count:] def fill_page_with_characters( page: list[PrintItem | None], character_items: list[PrintItem], cols: int, ) -> tuple[list[PrintItem | None], list[PrintItem]]: pairs, leftovers = character_pairs(character_items) if not pairs: return fill_single_characters(page, character_items, cols) best_page = list(page) best_remaining = list(character_items) best_score = (-1, -1) def dfs(pair_pos: int, placed_pairs: int, unplaced: list[PrintItem]) -> None: nonlocal best_page, best_remaining, best_score remaining_pairs = len(pairs) - pair_pos max_possible_total = len(character_items) if max_possible_total < best_score[0]: return if max_possible_total == best_score[0] and placed_pairs + remaining_pairs <= best_score[1]: return if pair_pos == len(pairs): remaining_for_single_fill = sorted(leftovers + unplaced, key=item_order_key) candidate_page, candidate_remaining = fill_single_characters(list(page), remaining_for_single_fill, cols) total_placed = len(character_items) - len(candidate_remaining) score = (total_placed, placed_pairs) if score > best_score: best_score = score best_page = candidate_page best_remaining = candidate_remaining return first, second = pairs[pair_pos] for nw_index, se_index in shared_character_corner_slots(page, cols): nw_item = replace(first, layout_rotation=90) se_item = replace(second, layout_rotation=-90) if not can_place_item(page, nw_index, nw_item, cols): continue page[nw_index] = nw_item if can_place_item(page, se_index, se_item, cols): page[se_index] = se_item dfs(pair_pos + 1, placed_pairs + 1, unplaced) page[se_index] = None page[nw_index] = None dfs(pair_pos + 1, placed_pairs, unplaced + [first, second]) dfs(0, 0, []) return best_page, best_remaining def fill_bottom_rows_with_spaced_characters( pages: list[CardPage], character_items: list[PrintItem], config: BuildConfig, ) -> list[PrintItem]: if not character_items: return character_items cols, rows = grid_capacity(config) 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 grid_w = cols * oriented_w grid_h = rows * oriented_h x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 y0 = margin + ((page_h - 2 * margin) - grid_h) // 2 remaining = list(character_items) for page in pages: if not remaining or page.spaced: continue first_empty_row = rows for row in range(rows - 1, -1, -1): row_items = page.items[row * cols : (row + 1) * cols] if any(item is not None for item in row_items): break first_empty_row = row empty_rows = rows - first_empty_row 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 - edge_margin - area_top + top_slack if first_empty_row > 0: area_top += gap area_h -= gap layout_options = [] for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)): fit_cols = max(1, (area_w + gap) // (item_w + gap)) fit_rows = max(0, (area_h + gap) // (item_h + gap)) layout_options.append((fit_cols * fit_rows, fit_cols, fit_rows, item_w, item_h, rotation)) capacity_slots, fit_cols, fit_rows, item_w, item_h, rotation = max(layout_options) capacity = min(len(remaining), capacity_slots) if capacity <= 0: continue used_cols = min(fit_cols, capacity) used_rows = math.ceil(capacity / fit_cols) placement_w = used_cols * item_w + max(0, used_cols - 1) * gap placement_h = used_rows * item_h + max(0, used_rows - 1) * gap place_x0 = area_left + (area_w - placement_w) // 2 place_y0 = area_top extras = list(page.extras or []) for index in range(capacity): row, col = divmod(index, fit_cols) left = place_x0 + col * (item_w + gap) top = place_y0 + row * (item_h + gap) extras.append((remaining[index], (left, top, left + item_w, top + item_h), rotation)) page.extras = extras remaining = remaining[capacity:] return fill_free_space_with_spaced_characters(pages, remaining, config) def rects_conflict(rect: tuple[int, int, int, int], occupied: list[tuple[int, int, int, int]], gap: int) -> bool: left, top, right, bottom = rect for other_left, other_top, other_right, other_bottom in occupied: if left < other_right + gap and right > other_left - gap and top < other_bottom + gap and bottom > other_top - gap: return True 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 def centered_shift( rects: list[tuple[int, int, int, int]], page_w: int, page_h: int, edge_margin: int, ) -> tuple[int, int]: if not rects: return 0, 0 left = min(rect[0] for rect in rects) top = min(rect[1] for rect in rects) right = max(rect[2] for rect in rects) bottom = max(rect[3] for rect in rects) bbox_w = right - left bbox_h = bottom - top target_left = (page_w - bbox_w) // 2 target_top = (page_h - bbox_h) // 2 min_left = edge_margin max_left = page_w - edge_margin - bbox_w min_top = edge_margin max_top = page_h - edge_margin - bbox_h if max_left >= min_left: target_left = min(max(target_left, min_left), max_left) if max_top >= min_top: target_top = min(max(target_top, min_top), max_top) return target_left - left, target_top - top def fill_free_space_with_spaced_characters( pages: list[CardPage], character_items: list[PrintItem], config: BuildConfig, ) -> list[PrintItem]: if not character_items: return character_items cols, rows = grid_capacity(config) 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) 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 x0 = margin + ((page_w - 2 * margin) - grid_w) // 2 y0 = margin + ((page_h - 2 * margin) - grid_h) // 2 remaining = list(character_items) for page in pages: if not remaining or page.spaced: continue occupied: 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 occupied.append((left, top, left + oriented_w, top + oriented_h)) occupied.extend(rect for _, rect, _ in (page.extras or [])) 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 def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[CardPage]: cols, rows = grid_capacity(config) per_page = cols * rows pages: list[CardPage] = [] standard_items = [item for item in items if not item.group.startswith("character:")] character_items = sorted((item for item in items if item.group.startswith("character:")), key=item_order_key) if config.crop_black_borders: groups = sorted({item.group for item in standard_items}, key=group_order_key) for group in groups: group_items = [item for item in standard_items if item.group == group] 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=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): page = page + [None] * (per_page - len(page)) pages.append(CardPage(page)) if character_items and pages: last_page = pages[-1] if not last_page.spaced: 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 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 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]: base_order = {"encounter": 0, "office": 1, "basement": 2} if group in base_order: return base_order[group], group if group.startswith("character:"): return 3, group return 99, group def item_order_key(item: PrintItem) -> tuple[int, str, str, str]: if item.image_type == "back": phase = 2 elif item.is_starter: phase = 0 else: phase = 1 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 draw.line((left, 0, left, page.height), fill=(0, 0, 0), width=1) draw.line((right, 0, right, page.height), fill=(0, 0, 0), width=1) draw.line((0, top, page.width, top), 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: pages = grouped_card_pages(items, config) 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) bleed_px = mm_to_px(config.bleed_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 cols, rows = grid_capacity(config) def render_one_card_page(page_offset_and_plan: tuple[int, CardPage]) -> tuple[Path, int]: page_offset, page_plan = page_offset_and_plan page_items = page_plan.items spaced = page_plan.spaced extras = page_plan.extras or [] 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) gap = mm_to_px(5.0, config.dpi) if spaced else 0 if spaced: page_cols = max(1, (page_w - 2 * margin + gap) // (oriented_w + gap)) page_rows = math.ceil(len(page_items) / page_cols) else: page_cols = cols page_rows = rows 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 y0 = margin + ((page_h - 2 * margin) - grid_h) // 2 placements: list[tuple[PrintItem | None, tuple[int, int, int, int], int]] = [] for idx, item in enumerate(page_items): row = idx // page_cols col = idx % page_cols left = x0 + col * (oriented_w + gap) top = y0 + row * (oriented_h + gap) 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)) 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, 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] for item, rect, _ in placements: if item is None: continue draw_crop_lines(page, rect) for _, rect, _ in extras: draw_crop_lines(page, rect) for item, rect, rotate in placements: if item is None: continue face = load_page_image(item.path) bleed = make_item_bleed(item, face).rotate(rotate, expand=True) cx = (rect[0] + rect[2]) // 2 cy = (rect[1] + rect[3]) // 2 page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2)) for item, rect, rotate in extras: face = load_page_image(item.path) bleed = make_item_bleed(item, face).rotate(rotate, expand=True) cx = (rect[0] + rect[2]) // 2 cy = (rect[1] + rect[3]) // 2 page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2)) for item, rect, rotate in placements: if item is None: continue face = load_page_image(item.path).rotate(rotate, expand=True) face = face.resize((oriented_w, oriented_h), Image.Resampling.LANCZOS) page.paste(face, (rect[0], rect[1])) for item, rect, rotate in extras: face = load_page_image(item.path).rotate(rotate, expand=True) face = face.resize((rect[2] - rect[0], rect[3] - rect[1]), Image.Resampling.LANCZOS) page.paste(face, (rect[0], rect[1])) out = PAGES_DIR / f"page_{start_index + page_offset:03d}.png" out.parent.mkdir(parents=True, exist_ok=True) save_temp_png(page, out, config.dpi) 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: for out, count in executor.map(render_one_card_page, enumerate(pages)): print(f"Wrote {out.relative_to(ROOT)} ({count} card images)") return start_index + len(pages) def plan_board_rows(items: list[PrintItem], config: BuildConfig) -> list[list[tuple[PrintItem, bool]]]: 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) usable_w = page_w - 2 * margin usable_h = page_h - 2 * margin pages: list[list[tuple[PrintItem, bool]]] = [] current: list[tuple[PrintItem, bool]] = [] row_w = row_h = used_h = 0 for item in items: with Image.open(item.path) as img: options = [(False, img.width, img.height), (True, img.height, img.width)] rotate, bw, bh = min((o for o in options if o[1] <= usable_w), key=lambda o: o[2], default=options[0]) needed_w = bw if row_w == 0 else row_w + gap + bw if row_w and needed_w > usable_w: used_h += row_h + (gap if used_h else 0) row_w = row_h = 0 if used_h and used_h + bh > usable_h: pages.append(current) current = [] used_h = row_w = row_h = 0 current.append((item, rotate)) row_w = bw if row_w == 0 else row_w + gap + bw row_h = max(row_h, bh) if current: pages.append(current) return pages def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> int: if not items: return start_index pages = plan_board_rows(items, config) 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) bleed_px = mm_to_px(config.bleed_mm, config.dpi) usable_w = page_w - 2 * margin def render_one_board_page(page_offset_and_items: tuple[int, list[tuple[PrintItem, bool]]]) -> tuple[Path, 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]]] = [] row: list[tuple[PrintItem, bool, int, int]] = [] row_w = row_h = 0 for item, rotate in page_items: with Image.open(item.path) as img: bw, bh = (img.height, img.width) if rotate else (img.width, img.height) if row and row_w + gap + bw > usable_w: rows.append(row) row = [] row_w = row_h = 0 row.append((item, rotate, bw, bh)) row_w = bw if row_w == 0 else row_w + gap + bw row_h = max(row_h, bh) if 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 + ((page_h - 2 * margin) - total_h) // 2 placements: list[tuple[PrintItem, bool, tuple[int, int, int, int]]] = [] for row in rows: row_w = sum(bw for _, _, bw, _ in row) + gap * max(0, len(row) - 1) row_h = max(bh for _, _, _, bh in row) x = margin + (usable_w - row_w) // 2 for item, rotate, bw, bh in row: top = y + (row_h - bh) // 2 rect = (x, top, x + bw, top + bh) placements.append((item, rotate, rect)) x += bw + gap y += row_h + gap for _, _, rect in placements: draw_crop_lines(page, rect) for item, rotate, rect in placements: face = load_page_image(item.path) bleed = make_bleed(face, bleed_px) if rotate: bleed = bleed.rotate(90, expand=True) cx = (rect[0] + rect[2]) // 2 cy = (rect[1] + rect[3]) // 2 page.paste(bleed, (cx - bleed.width // 2, cy - bleed.height // 2)) for item, rotate, rect in placements: face = load_page_image(item.path) if rotate: face = face.rotate(90, expand=True) face = face.resize((rect[2] - rect[0], rect[3] - rect[1]), Image.Resampling.LANCZOS) page.paste(face, (rect[0], rect[1])) out = PAGES_DIR / f"page_{start_index + page_offset:03d}.png" out.parent.mkdir(parents=True, exist_ok=True) save_temp_png(page, out, config.dpi) return out, len(page_items) with ThreadPoolExecutor(max_workers=worker_count(len(pages))) as executor: for out, count in executor.map(render_one_board_page, enumerate(pages)): print(f"Wrote {out.relative_to(ROOT)} ({count} boards)") return start_index + len(pages)