Files
blockminers-printer/scripts/layout.py
T
2026-05-30 16:54:49 +00:00

862 lines
36 KiB
Python

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 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 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
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
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
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 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)
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)
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 []))
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:]
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=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)
pages[-1] = CardPage(filled_page)
if character_items:
for chunk in chunk_spaced_cards(character_items, config):
pages.append(CardPage(chunk, True))
return pages
def chunk_spaced_cards(items: list[PrintItem], config: BuildConfig) -> list[list[PrintItem]]:
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
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)]
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 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, min(margin, gap or 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)