1837 lines
72 KiB
Python
1837 lines
72 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 .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 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
|
|
y0 = margin
|
|
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 - margin - 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
|
|
|
|
place_x0 = area_left
|
|
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 row_band_compatible(rect: tuple[int, int, int, int], occupied: list[tuple[int, int, int, int]]) -> bool:
|
|
left, top, right, bottom = rect
|
|
width = right - left
|
|
height = bottom - top
|
|
for other_left, other_top, other_right, other_bottom in occupied:
|
|
if top >= other_bottom or bottom <= other_top:
|
|
continue
|
|
if top != other_top or bottom != other_bottom:
|
|
return False
|
|
if width != other_right - other_left or height != other_bottom - other_top:
|
|
return False
|
|
return True
|
|
|
|
|
|
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[int, 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 row_band_compatible(rect, rects) and 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 item_print_size(item: PrintItem, config: BuildConfig) -> tuple[int, int]:
|
|
if item.path.exists():
|
|
with Image.open(item.path) as img:
|
|
return img.size
|
|
return mm_to_px(config.card_mm[0], config.dpi), mm_to_px(config.card_mm[1], config.dpi)
|
|
|
|
|
|
def item_orientations(item: PrintItem, config: BuildConfig) -> list[tuple[int, int, int]]:
|
|
width, height = item_print_size(item, config)
|
|
if item.layout_rotation is not None:
|
|
rotation = item.layout_rotation
|
|
return [(height, width, rotation)] if abs(rotation) % 180 else [(width, height, rotation)]
|
|
if width == height:
|
|
return [(width, height, 0)]
|
|
return [(width, height, 0), (height, width, 90)]
|
|
|
|
|
|
def loose_items_can_touch(items: list[PrintItem]) -> bool:
|
|
if not items:
|
|
return False
|
|
if any(is_character_item(item) or item.group == "board" for item in items):
|
|
return False
|
|
groups = {item.group for item in items}
|
|
image_types = {item.image_type for item in items}
|
|
return len(groups) == 1 and len(image_types) == 1
|
|
|
|
|
|
def compatible_standard_items(left: PrintItem, right: PrintItem) -> bool:
|
|
return (
|
|
not is_character_item(left)
|
|
and not is_character_item(right)
|
|
and left.group != "board"
|
|
and right.group != "board"
|
|
and left.group == right.group
|
|
and left.image_type == right.image_type
|
|
)
|
|
|
|
|
|
def normalize_connected_standard_rotations(
|
|
placements: list[tuple[PrintItem, tuple[int, int, int, int], int]],
|
|
) -> list[tuple[PrintItem, tuple[int, int, int, int], int]]:
|
|
if not placements:
|
|
return placements
|
|
|
|
normalized = list(placements)
|
|
by_band: dict[tuple[int, int], list[int]] = {}
|
|
for index, (_, rect, _) in enumerate(normalized):
|
|
by_band.setdefault((rect[1], rect[3]), []).append(index)
|
|
|
|
for indices in by_band.values():
|
|
indices.sort(key=lambda index: normalized[index][1][0])
|
|
run: list[int] = []
|
|
|
|
def flush() -> None:
|
|
if len(run) < 2:
|
|
return
|
|
_, first_rect, _ = normalized[run[0]]
|
|
landscape = first_rect[2] - first_rect[0] > first_rect[3] - first_rect[1]
|
|
if not landscape:
|
|
for run_index in run:
|
|
item, rect, _ = normalized[run_index]
|
|
normalized[run_index] = (item, rect, 0)
|
|
return
|
|
for offset, run_index in enumerate(run):
|
|
item, rect, _ = normalized[run_index]
|
|
normalized[run_index] = (item, rect, 90 if offset % 2 == 0 else -90)
|
|
|
|
for index in indices:
|
|
if not run:
|
|
run = [index]
|
|
continue
|
|
previous_index = run[-1]
|
|
previous_item, previous_rect, _ = normalized[previous_index]
|
|
item, rect, _ = normalized[index]
|
|
touches = previous_rect[2] == rect[0]
|
|
if touches and compatible_standard_items(previous_item, item):
|
|
run.append(index)
|
|
else:
|
|
flush()
|
|
run = [index]
|
|
flush()
|
|
|
|
return normalized
|
|
|
|
|
|
def pack_loose_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)
|
|
internal_gap = 0 if loose_items_can_touch(items) else gap
|
|
orientations = [item_orientations(item, config) for item in items]
|
|
placed_start = len(occupied)
|
|
|
|
def gap_to_rect(rect_index: int) -> int:
|
|
return internal_gap if rect_index >= placed_start else gap
|
|
|
|
def rect_conflicts(rect: tuple[int, int, int, int], rects: list[tuple[int, int, int, int]]) -> bool:
|
|
left, top, right, bottom = rect
|
|
for index, other in enumerate(rects):
|
|
other_left, other_top, other_right, other_bottom = other
|
|
other_gap = gap_to_rect(index)
|
|
if left < other_right + other_gap and right > other_left - other_gap and top < other_bottom + other_gap and bottom > other_top - other_gap:
|
|
return True
|
|
return False
|
|
|
|
def candidates(rects: list[tuple[int, int, int, int]], item_index: int) -> list[tuple[int, int, int, int, int]]:
|
|
xs = {margin}
|
|
ys = {margin}
|
|
for rect_index, (left, top, right, bottom) in enumerate(rects):
|
|
rect_gap = gap_to_rect(rect_index)
|
|
for item_w, item_h, _ in orientations[item_index]:
|
|
xs.add(right + rect_gap)
|
|
xs.add(left - rect_gap - item_w)
|
|
ys.add(bottom + rect_gap)
|
|
ys.add(top - rect_gap - item_h)
|
|
|
|
out: list[tuple[int, int, int, int, int]] = []
|
|
for item_w, item_h, rotation in orientations[item_index]:
|
|
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 row_band_compatible(rect, rects) and not rect_conflicts(rect, rects):
|
|
out.append((*rect, rotation))
|
|
return sorted(out, key=lambda value: (value[1], value[0], value[4]))
|
|
|
|
rects = list(occupied)
|
|
placements: list[tuple[PrintItem, tuple[int, int, int, int], int]] = []
|
|
for index, item in enumerate(items):
|
|
candidate_rects = candidates(rects, index)
|
|
if not candidate_rects:
|
|
continue
|
|
left, top, right, bottom, rotation = candidate_rects[0]
|
|
rect = (left, top, right, bottom)
|
|
rects.append(rect)
|
|
placements.append((item, rect, rotation))
|
|
return normalize_connected_standard_rotations(placements)
|
|
|
|
|
|
def standard_grid_rects(page: CardPage, config: BuildConfig) -> list[tuple[int, int, int, int]]:
|
|
cols, _ = grid_capacity(config)
|
|
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
|
|
x0 = margin
|
|
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 page_loose_items(page: CardPage) -> list[PrintItem]:
|
|
return [item for item in page.items if item is not None] + [item for item, _, _ in (page.extras or [])]
|
|
|
|
|
|
def place_loose_items_on_pages(
|
|
pages: list[CardPage],
|
|
loose_items: list[PrintItem],
|
|
config: BuildConfig,
|
|
) -> list[PrintItem]:
|
|
remaining = list(loose_items)
|
|
for page in pages:
|
|
if not remaining or page.spaced:
|
|
continue
|
|
occupied = standard_grid_rects(page, config)
|
|
if loose_items_can_touch(remaining):
|
|
placements = pack_loose_items_in_free_space(occupied, remaining, config)
|
|
elif all(item_print_size(item, config) == (mm_to_px(config.card_mm[0], config.dpi), mm_to_px(config.card_mm[1], config.dpi)) for item in remaining):
|
|
placements = pack_spaced_items_in_free_space(occupied, remaining, config)
|
|
else:
|
|
placements = pack_loose_items_in_free_space(occupied, remaining, config)
|
|
if not placements:
|
|
continue
|
|
page.extras = list(page.extras or []) + placements
|
|
for placed_item, _, _ in placements:
|
|
remaining.remove(placed_item)
|
|
return remaining
|
|
|
|
|
|
def sparse_standard_upright_row(
|
|
standard_items: list[PrintItem],
|
|
config: BuildConfig,
|
|
) -> list[tuple[PrintItem, tuple[int, int, int, int], int]] | None:
|
|
if not standard_items:
|
|
return None
|
|
groups = {item.group for item in standard_items}
|
|
if len(groups) != 1:
|
|
return None
|
|
|
|
page_w = mm_to_px(config.paper_mm[0], 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)
|
|
ordered: list[PrintItem] = []
|
|
for image_type in ("front", "back"):
|
|
ordered.extend(item for item in standard_items if item.image_type == image_type)
|
|
if len(ordered) != len(standard_items):
|
|
return None
|
|
|
|
row_w = len(ordered) * card_w
|
|
if any(item.image_type != ordered[0].image_type for item in ordered):
|
|
row_w += gap
|
|
if row_w > page_w - 2 * margin:
|
|
return None
|
|
|
|
placements: list[tuple[PrintItem, tuple[int, int, int, int], int]] = []
|
|
x = margin
|
|
previous_type: str | None = None
|
|
for item in ordered:
|
|
if previous_type is not None and item.image_type != previous_type:
|
|
x += gap
|
|
rect = (x, margin, x + card_w, margin + card_h)
|
|
placements.append((item, rect, 0))
|
|
x += card_w
|
|
previous_type = item.image_type
|
|
return placements
|
|
|
|
|
|
def pack_landscape_card_rows_below(
|
|
occupied: list[tuple[int, int, int, int]],
|
|
items: list[PrintItem],
|
|
config: BuildConfig,
|
|
internal_gap: int | None = None,
|
|
) -> 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)
|
|
row_gap = gap if occupied else 0
|
|
item_gap = gap if internal_gap is None else internal_gap
|
|
card_w = mm_to_px(config.card_mm[0], config.dpi)
|
|
card_h = mm_to_px(config.card_mm[1], config.dpi)
|
|
item_w, item_h = card_h, card_w
|
|
usable_w = page_w - 2 * margin
|
|
cols = max(1, (usable_w + item_gap) // (item_w + item_gap))
|
|
top = max((rect[3] for rect in occupied), default=margin) + row_gap
|
|
placements: list[tuple[PrintItem, tuple[int, int, int, int], int]] = []
|
|
|
|
for index, item in enumerate(items):
|
|
row, col = divmod(index, cols)
|
|
left = margin + col * (item_w + item_gap)
|
|
y = top + row * (item_h + gap)
|
|
rect = (left, y, left + item_w, y + item_h)
|
|
if rect[3] > page_h - margin:
|
|
break
|
|
rotation = 90 if col % 2 == 0 else -90
|
|
placements.append((item, rect, rotation))
|
|
return normalize_connected_standard_rotations(placements)
|
|
|
|
|
|
def reflow_sparse_standard_page_for_trailing(
|
|
page: CardPage,
|
|
trailing: list[PrintItem],
|
|
config: BuildConfig,
|
|
) -> tuple[CardPage, list[PrintItem]] | None:
|
|
if page.spaced or not page.extras:
|
|
return None
|
|
cols, _ = grid_capacity(config)
|
|
standard_items = [item for item in page.items if item is not None and not is_character_item(item)]
|
|
if not standard_items or len(standard_items) > 2 * cols:
|
|
return None
|
|
if any(is_character_item(item) or item.group == "board" for item in trailing):
|
|
return None
|
|
|
|
top_row = sparse_standard_upright_row(standard_items, config)
|
|
if top_row is None:
|
|
return None
|
|
|
|
extras = [item for item, _, _ in (page.extras or [])]
|
|
occupied = [rect for _, rect, _ in top_row]
|
|
if extras and all(is_character_item(item) for item in extras):
|
|
extra_placements = pack_landscape_card_rows_below(occupied, extras, config)
|
|
else:
|
|
extra_placements = pack_spaced_items_in_free_space(occupied, extras, config)
|
|
if len(extra_placements) != len(extras):
|
|
return None
|
|
|
|
occupied.extend(rect for _, rect, _ in extra_placements)
|
|
trailing_placements = pack_landscape_card_rows_below(occupied, trailing, config, mm_to_px(5.0, config.dpi))
|
|
placed_trailing = [item for item, _, _ in trailing_placements]
|
|
remaining = list(trailing)
|
|
for item in placed_trailing:
|
|
remaining.remove(item)
|
|
if len(remaining) == len(trailing):
|
|
return None
|
|
|
|
_, rows = grid_capacity(config)
|
|
blank_items = [None] * (cols * rows)
|
|
return CardPage(blank_items, False, top_row + extra_placements + trailing_placements), remaining
|
|
|
|
|
|
def absorb_remaining_by_deferring_small_extras(
|
|
pages: list[CardPage],
|
|
remaining_items: list[PrintItem],
|
|
config: BuildConfig,
|
|
) -> tuple[list[CardPage], list[PrintItem], list[PrintItem]] | None:
|
|
if not remaining_items or any(is_character_item(item) for item in remaining_items):
|
|
return None
|
|
|
|
trial_pages = clone_pages(pages)
|
|
remaining = list(remaining_items)
|
|
deferred: list[PrintItem] = []
|
|
changed = False
|
|
|
|
for page in trial_pages:
|
|
if not remaining or page.spaced or not page.extras:
|
|
continue
|
|
displaced = [item for item, _, _ in page.extras]
|
|
if len(displaced) > 2:
|
|
continue
|
|
|
|
empty_page = CardPage(list(page.items), page.spaced, None)
|
|
placements = pack_loose_items_in_free_space(standard_grid_rects(empty_page, config), remaining, config)
|
|
if len(placements) <= len(displaced):
|
|
continue
|
|
|
|
page.extras = placements
|
|
for placed_item, _, _ in placements:
|
|
remaining.remove(placed_item)
|
|
deferred.extend(displaced)
|
|
changed = True
|
|
|
|
if not changed:
|
|
return None
|
|
return trial_pages, remaining, deferred
|
|
|
|
|
|
def reflow_sparse_pages_for_trailing(
|
|
pages: list[CardPage],
|
|
trailing: list[PrintItem],
|
|
config: BuildConfig,
|
|
) -> tuple[list[CardPage], list[PrintItem]] | None:
|
|
for page_index, page in enumerate(pages):
|
|
result = reflow_sparse_standard_page_for_trailing(page, trailing, config)
|
|
if result is None:
|
|
continue
|
|
new_page, remaining = result
|
|
trial_pages = clone_pages(pages)
|
|
trial_pages[page_index] = new_page
|
|
return trial_pages, remaining
|
|
return None
|
|
|
|
|
|
def compact_trailing_pages(pages: list[CardPage], config: BuildConfig) -> tuple[list[CardPage], list[PrintItem]]:
|
|
pages = clone_pages(pages)
|
|
deferred: list[PrintItem] = []
|
|
while len(pages) > 1:
|
|
trailing = page_loose_items(pages[-1])
|
|
if not trailing:
|
|
pages.pop()
|
|
continue
|
|
|
|
trial_pages = clone_pages(pages[:-1])
|
|
remaining = place_loose_items_on_pages(trial_pages, trailing, config)
|
|
if remaining:
|
|
reflowed = reflow_sparse_pages_for_trailing(pages[:-1], trailing, config)
|
|
if reflowed is not None:
|
|
trial_pages, remaining = reflowed
|
|
if remaining:
|
|
absorbed = absorb_remaining_by_deferring_small_extras(trial_pages, remaining, config)
|
|
if absorbed is not None:
|
|
trial_pages, remaining, newly_deferred = absorbed
|
|
deferred.extend(newly_deferred)
|
|
if not remaining:
|
|
pages = trial_pages
|
|
break
|
|
if len(remaining) < len(trailing):
|
|
pages = trial_pages
|
|
deferred.extend(remaining)
|
|
break
|
|
swapped = absorb_trailing_by_evicting_extras(pages[:-1], trailing, config)
|
|
if swapped is None:
|
|
swapped = absorb_trailing_by_evicting_extras_and_standards(pages[:-1], trailing, config)
|
|
if swapped is None:
|
|
deferred_swap = absorb_trailing_by_deferring_extras(pages[:-1], trailing, config)
|
|
if deferred_swap is not None:
|
|
swapped, newly_deferred = deferred_swap
|
|
deferred.extend(newly_deferred)
|
|
if swapped is None:
|
|
if len(remaining) < len(trailing):
|
|
pages = trial_pages
|
|
deferred.extend(remaining)
|
|
break
|
|
pages = swapped
|
|
break
|
|
else:
|
|
pages = trial_pages
|
|
break
|
|
return pages, deferred
|
|
|
|
|
|
def absorb_trailing_by_evicting_extras(
|
|
base_pages: list[CardPage],
|
|
trailing: list[PrintItem],
|
|
config: BuildConfig,
|
|
) -> list[CardPage] | None:
|
|
if not trailing or any(is_character_item(item) for item in trailing):
|
|
return None
|
|
|
|
for target_index, page in enumerate(base_pages):
|
|
if page.spaced or not page.extras or len(page.extras) > 2:
|
|
continue
|
|
trial_pages = clone_pages(base_pages)
|
|
target = trial_pages[target_index]
|
|
displaced = [item for item, _, _ in (target.extras or [])]
|
|
target.extras = None
|
|
trial_pages[target_index], remaining_trailing = add_standard_items_to_empty_slots_with_remaining(target, trailing, config)
|
|
if remaining_trailing:
|
|
continue
|
|
remaining_displaced = place_loose_items_on_pages(trial_pages, displaced, config)
|
|
if not remaining_displaced:
|
|
return trial_pages
|
|
|
|
return None
|
|
|
|
|
|
def absorb_trailing_by_evicting_extras_and_standards(
|
|
base_pages: list[CardPage],
|
|
trailing: list[PrintItem],
|
|
config: BuildConfig,
|
|
) -> list[CardPage] | None:
|
|
if not trailing or any(is_character_item(item) for item in trailing):
|
|
return None
|
|
|
|
for target_index, page in enumerate(base_pages):
|
|
if page.spaced or not page.extras or len(page.extras) > 2:
|
|
continue
|
|
|
|
displaced = [item for item, _, _ in (page.extras or [])]
|
|
target_without_extras = CardPage(list(page.items), page.spaced, None)
|
|
target_with_trailing, remaining_trailing = add_standard_items_to_empty_slots_with_remaining(target_without_extras, trailing, config)
|
|
if remaining_trailing:
|
|
continue
|
|
|
|
empty_slots = sum(1 for item in target_with_trailing.items if item is None)
|
|
if empty_slots <= 0:
|
|
continue
|
|
|
|
for donor_index, donor in enumerate(base_pages):
|
|
if donor_index == target_index or donor.spaced 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))
|
|
for move_count in range(min(empty_slots, donor_standard_count), 0, -1):
|
|
donor_trial, moved = remove_last_standard_items(donor, move_count)
|
|
if len(moved) != move_count:
|
|
continue
|
|
target_filled, remaining_moved = add_standard_items_to_empty_slots_with_remaining(target_with_trailing, moved, config)
|
|
if remaining_moved:
|
|
continue
|
|
|
|
trial_pages = clone_pages(base_pages)
|
|
trial_pages[target_index] = target_filled
|
|
trial_pages[donor_index] = donor_trial
|
|
remaining_displaced = place_loose_items_on_pages(trial_pages, displaced, config)
|
|
if not remaining_displaced:
|
|
return trial_pages
|
|
|
|
return None
|
|
|
|
|
|
def absorb_trailing_by_deferring_extras(
|
|
base_pages: list[CardPage],
|
|
trailing: list[PrintItem],
|
|
config: BuildConfig,
|
|
) -> tuple[list[CardPage], list[PrintItem]] | None:
|
|
if not trailing or any(is_character_item(item) for item in trailing):
|
|
return None
|
|
|
|
for target_index, page in enumerate(base_pages):
|
|
if page.spaced or not page.extras or len(page.extras) > 2:
|
|
continue
|
|
|
|
trial_pages = clone_pages(base_pages)
|
|
target = trial_pages[target_index]
|
|
displaced = [item for item, _, _ in (target.extras or [])]
|
|
target.extras = None
|
|
placements = pack_loose_items_in_free_space(standard_grid_rects(target, config), trailing, config)
|
|
if len(placements) != len(trailing):
|
|
continue
|
|
target.extras = placements
|
|
return trial_pages, displaced
|
|
|
|
return None
|
|
|
|
|
|
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
|
|
cols, _ = grid_capacity(config)
|
|
items = list(receiver.items)
|
|
moved_index = 0
|
|
for index, existing in enumerate(items):
|
|
if existing is not None or moved_index >= len(moved):
|
|
continue
|
|
item = moved[moved_index]
|
|
if not standard_slot_accepts_item(items, index, item, cols, config):
|
|
return False
|
|
items[index] = item
|
|
moved_index += 1
|
|
return moved_index == len(moved)
|
|
|
|
|
|
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_with_remaining(
|
|
page: CardPage,
|
|
moved: list[PrintItem],
|
|
config: BuildConfig,
|
|
) -> tuple[CardPage, list[PrintItem]]:
|
|
cols, _ = grid_capacity(config)
|
|
items = list(page.items)
|
|
remaining = list(moved)
|
|
for index, item in enumerate(items):
|
|
if item is None and remaining:
|
|
moved_item = remaining[0]
|
|
if standard_slot_accepts_item(items, index, moved_item, cols, config):
|
|
items[index] = moved_item
|
|
remaining.pop(0)
|
|
return CardPage(items, page.spaced, None), remaining
|
|
|
|
|
|
def add_standard_items_to_empty_slots(page: CardPage, moved: list[PrintItem], config: BuildConfig) -> CardPage:
|
|
return add_standard_items_to_empty_slots_with_remaining(page, moved, config)[0]
|
|
|
|
|
|
def standard_slot_accepts_item(
|
|
items: list[PrintItem | None],
|
|
index: int,
|
|
item: PrintItem,
|
|
cols: int,
|
|
config: BuildConfig,
|
|
) -> bool:
|
|
if not config.crop_black_borders:
|
|
return True
|
|
groups = {placed.group for placed in items if placed is not None and not is_character_item(placed)}
|
|
if groups and groups != {item.group}:
|
|
return False
|
|
total = len(items)
|
|
for neighbor_index in (index - cols, index + cols):
|
|
if neighbor_index < 0 or neighbor_index >= total:
|
|
continue
|
|
neighbor = items[neighbor_index]
|
|
if neighbor is None or is_character_item(neighbor):
|
|
continue
|
|
if neighbor.group != item.group or neighbor.image_type != item.image_type:
|
|
return False
|
|
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 standard_slot_accepts_item(items, slot_index, item, cols, 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:
|
|
page_items: list[PrintItem | None] = [None] * per_page
|
|
next_remaining: list[PrintItem] = []
|
|
page_group = remaining[0].group if config.crop_black_borders else None
|
|
for item in remaining:
|
|
if page_group is not None and item.group != page_group:
|
|
next_remaining.append(item)
|
|
continue
|
|
placed = False
|
|
for slot_index, existing in enumerate(page_items):
|
|
if existing is None and standard_slot_accepts_item(page_items, slot_index, item, cols, config):
|
|
page_items[slot_index] = item
|
|
placed = True
|
|
break
|
|
if not placed:
|
|
next_remaining.append(item)
|
|
pages.append(CardPage(page_items))
|
|
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 place_characters_in_tight_grid_corners(
|
|
pages: list[CardPage],
|
|
character_items: list[PrintItem],
|
|
config: BuildConfig,
|
|
) -> list[PrintItem]:
|
|
cols, rows = grid_capacity(config)
|
|
if cols < 2 or rows < 2 or not character_items:
|
|
return character_items
|
|
|
|
corner_slots = [(0, -90), (cols * rows - 1, 90)]
|
|
remaining = list(character_items)
|
|
overflow: list[PrintItem] = []
|
|
|
|
for page in pages:
|
|
if not remaining or page.spaced or page.extras:
|
|
continue
|
|
|
|
items = list(page.items)
|
|
for slot_index, rotation in corner_slots:
|
|
if not remaining:
|
|
break
|
|
current = items[slot_index]
|
|
if current is not None and is_character_item(current):
|
|
continue
|
|
|
|
item = replace(remaining[0], layout_rotation=rotation)
|
|
trial = list(items)
|
|
trial[slot_index] = None
|
|
if not can_place_item(trial, slot_index, item, cols):
|
|
continue
|
|
|
|
if current is not None:
|
|
overflow.append(current)
|
|
items[slot_index] = item
|
|
remaining = remaining[1:]
|
|
|
|
page.items = items
|
|
|
|
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, config)
|
|
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)
|
|
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
|
|
x0 = margin
|
|
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 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, _ = grid_capacity(config)
|
|
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
|
|
x0 = margin
|
|
y0 = margin
|
|
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))
|
|
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=grouped_character_page_score)
|
|
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))
|
|
existing_first = fill_noncropped_character_pages(pages, character_items, config, reserve_first=False, tight_grid_first=False)
|
|
reserve_first = fill_noncropped_character_pages(pages, character_items, config, reserve_first=True, tight_grid_first=False)
|
|
tight_grid_first = fill_noncropped_character_pages(pages, character_items, config, reserve_first=False, tight_grid_first=True)
|
|
pages = min((existing_first, reserve_first, tight_grid_first), key=character_page_score)
|
|
return pages
|
|
|
|
|
|
def pack_spaced_character_pages(items: list[PrintItem], config: BuildConfig) -> list[CardPage]:
|
|
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)
|
|
remaining = list(items)
|
|
pages: list[CardPage] = []
|
|
|
|
while remaining:
|
|
extras = pack_spaced_items_in_free_space([], remaining, config)
|
|
placed_count = len(extras)
|
|
|
|
if placed_count == 0:
|
|
item = remaining[0]
|
|
left = margin
|
|
top = margin
|
|
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 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, int]:
|
|
character_indices: list[int] = []
|
|
character_only_pages = 0
|
|
extra_character_count = 0
|
|
for page_index, page in enumerate(pages):
|
|
page_items = [item for item in page.items if item is not None]
|
|
extra_items = [item for item, _, _ in (page.extras or [])]
|
|
page_items.extend(extra_items)
|
|
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
|
|
extra_character_count += sum(1 for item in extra_items if is_character_item(item))
|
|
last_character_page = max(character_indices) if character_indices else -1
|
|
return len(pages), last_character_page, character_only_pages, extra_character_count
|
|
|
|
|
|
def grouped_character_page_score(pages: list[CardPage]) -> tuple[int, int, int, int, int]:
|
|
character_indices: list[int] = []
|
|
character_only_pages = 0
|
|
extra_character_count = 0
|
|
for page_index, page in enumerate(pages):
|
|
page_items = [item for item in page.items if item is not None]
|
|
extra_items = [item for item, _, _ in (page.extras or [])]
|
|
page_items.extend(extra_items)
|
|
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
|
|
extra_character_count += sum(1 for item in extra_items if is_character_item(item))
|
|
last_character_page = max(character_indices) if character_indices else -1
|
|
return len(pages), len(character_indices), character_only_pages, extra_character_count, last_character_page
|
|
|
|
|
|
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,
|
|
tight_grid_first: bool,
|
|
) -> list[CardPage]:
|
|
cols, _ = grid_capacity(config)
|
|
pages = clone_pages(base_pages)
|
|
remaining = list(character_items)
|
|
|
|
if tight_grid_first:
|
|
remaining = place_characters_in_tight_grid_corners(pages, remaining, config)
|
|
|
|
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:
|
|
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 load_page_image(cache: dict[Path, Image.Image], path: Path) -> Image.Image:
|
|
if path not in cache:
|
|
cache[path] = Image.open(path).convert("RGB")
|
|
return cache[path]
|
|
|
|
|
|
def rotated(img: Image.Image, degrees: int) -> Image.Image:
|
|
return img.rotate(degrees, expand=True) if degrees else img
|
|
|
|
|
|
def paste_print_placements(page: Image.Image, placements: list[tuple[PrintItem, tuple[int, int, int, int], int]]) -> None:
|
|
image_cache: dict[Path, Image.Image] = {}
|
|
|
|
for _, rect, _ in placements:
|
|
draw_crop_lines(page, rect)
|
|
|
|
for item, rect, rotation in placements:
|
|
face = load_page_image(image_cache, item.path)
|
|
bleed = load_page_image(image_cache, item.bleed_path) if item.bleed_path is not None else face
|
|
bleed = rotated(bleed, rotation)
|
|
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, rotation in placements:
|
|
face = rotated(load_page_image(image_cache, item.path), rotation)
|
|
size = (rect[2] - rect[0], rect[3] - rect[1])
|
|
if face.size != size:
|
|
face = face.resize(size, Image.Resampling.LANCZOS)
|
|
page.paste(face, (rect[0], rect[1]))
|
|
|
|
|
|
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,
|
|
board_items: list[PrintItem] | None = None,
|
|
) -> tuple[int, list[PrintItem], list[PrintItem]]:
|
|
pages = grouped_card_pages(items, config)
|
|
pages, compact_deferred_items = compact_trailing_pages(pages, config)
|
|
remaining_board_items = place_loose_items_on_pages(pages, list(board_items or []), config)
|
|
pages, deferred_character_items = split_trailing_character_only_pages(pages)
|
|
deferred_character_items = compact_deferred_items + deferred_character_items
|
|
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, oriented_h = card_h, card_w
|
|
cols, _ = 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")
|
|
|
|
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))
|
|
else:
|
|
page_cols = cols
|
|
x0 = margin
|
|
y0 = margin
|
|
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))
|
|
|
|
drawable = [(item, rect, rotate) for item, rect, rotate in placements if item is not None] + extras
|
|
paste_print_placements(page, drawable)
|
|
|
|
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)
|
|
|
|
if pages:
|
|
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), deferred_character_items, remaining_board_items
|
|
|
|
|
|
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,
|
|
character_items: list[PrintItem] | None = None,
|
|
) -> int:
|
|
if not items and not character_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)
|
|
usable_w = page_w - 2 * margin
|
|
remaining_characters = list(character_items or [])
|
|
|
|
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]]]:
|
|
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)
|
|
|
|
y = margin
|
|
board_placements: list[tuple[PrintItem, tuple[int, int, int, int], int]] = []
|
|
for row in rows:
|
|
row_h = max(bh for _, _, _, bh in row)
|
|
x = margin
|
|
for item, rotate, bw, bh in row:
|
|
top = y + (row_h - bh) // 2
|
|
rect = (x, top, x + bw, top + bh)
|
|
board_placements.append((item, rect, 90 if rotate else 0))
|
|
x += bw + gap
|
|
y += row_h + gap
|
|
|
|
occupied = [rect for _, rect, _ in board_placements]
|
|
standard_items = [item for item in remaining_characters if not is_character_item(item)]
|
|
character_items = [item for item in remaining_characters if is_character_item(item)]
|
|
standard_placements = pack_loose_items_in_free_space(occupied, standard_items, config)
|
|
occupied.extend(rect for _, rect, _ in standard_placements)
|
|
character_placements = pack_spaced_items_in_free_space(occupied, character_items, config)
|
|
return board_placements, standard_placements + character_placements
|
|
|
|
planned_pages = []
|
|
for page_items in pages:
|
|
board_placements, char_placements = plan_board_placements(page_items)
|
|
if char_placements:
|
|
for placed_item, _, _ in char_placements:
|
|
remaining_characters.remove(placed_item)
|
|
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")
|
|
|
|
placements = board_placements + char_placements
|
|
paste_print_placements(page, placements)
|
|
|
|
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(placements)
|
|
|
|
if planned_pages:
|
|
with ThreadPoolExecutor(max_workers=worker_count(len(planned_pages))) as executor:
|
|
for out, count in executor.map(render_one_board_page, enumerate(planned_pages)):
|
|
noun = "images" if count != 1 else "image"
|
|
print(f"Wrote {out.relative_to(ROOT)} ({count} {noun})")
|
|
return start_index + len(planned_pages)
|