import math
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, replace
from pathlib import Path

from PIL import Image, ImageDraw

from .assets import save_temp_png
from .cards import make_bleed
from .settings import BuildConfig, PAGES_DIR, PrintItem, ROOT, mm_to_px, worker_count


@dataclass
class CardPage:
    items: list[PrintItem | None]
    spaced: bool = False
    extras: list[tuple[PrintItem, tuple[int, int, int, int], int]] | None = None


def grid_capacity(config: BuildConfig) -> tuple[int, int]:
    page_w = mm_to_px(config.paper_mm[0], config.dpi)
    page_h = mm_to_px(config.paper_mm[1], config.dpi)
    margin = mm_to_px(config.margin_mm, config.dpi)
    card_w = mm_to_px(config.card_mm[0], config.dpi)
    card_h = mm_to_px(config.card_mm[1], config.dpi)
    oriented_w = card_h
    oriented_h = card_w
    cols = max(1, (page_w - 2 * margin) // oriented_w)
    rows = max(1, (page_h - 2 * margin) // oriented_h)
    return int(cols), int(rows)


def columns_to_pages(column_items: list[list[PrintItem]], cols_per_page: int, rows: int) -> list[list[PrintItem | None]]:
    pages: list[list[PrintItem | None]] = []
    for i in range(0, len(column_items), cols_per_page):
        page_columns = column_items[i : i + cols_per_page]
        page: list[PrintItem | None] = [None] * (cols_per_page * rows)
        column_groups: dict[str, list[tuple[int, list[PrintItem]]]] = {}
        group_order: list[str] = []

        for col_index, column in enumerate(page_columns):
            if not column:
                continue
            key = column[0].image_type
            if key not in column_groups:
                column_groups[key] = []
                group_order.append(key)
            column_groups[key].append((col_index, column))

        for key in group_order:
            columns = column_groups[key]
            sorted_items = [item for _, column in columns for item in column]
            row_major_slots = [
                row_index * cols_per_page + col_index
                for row_index in range(rows)
                for col_index, column in columns
                if row_index < len(column)
            ]
            for slot, item in zip(row_major_slots, sorted_items):
                page[slot] = item
        pages.append(page)
    return pages


def interleave_columns(*column_groups: list[list[PrintItem]]) -> list[list[PrintItem]]:
    columns: list[list[PrintItem]] = []
    max_len = max((len(group) for group in column_groups), default=0)
    for index in range(max_len):
        for group in column_groups:
            if index < len(group):
                columns.append(group[index])
    return columns


def balanced_columns(items: list[PrintItem], column_count: int) -> list[list[PrintItem]]:
    if column_count <= 0 or not items:
        return []
    columns: list[list[PrintItem]] = [[] for _ in range(column_count)]
    for index, item in enumerate(items):
        columns[index % len(columns)].append(item)
    return columns


def column_height(column: list[PrintItem | None]) -> int:
    for index in range(len(column) - 1, -1, -1):
        if column[index] is not None:
            return index + 1
    return 0


def column_type(column: list[PrintItem | None]) -> str | None:
    for item in column:
        if item is not None:
            return item.image_type
    return None


def optimize_mixed_columns(columns: list[list[PrintItem | None]], rows: int) -> list[list[PrintItem | None]]:
    columns = [list(column) for column in columns]
    while True:
        heights = [column_height(column) for column in columns]
        if not heights:
            return columns
        current_max = max(heights)
        best_move = None
        best_height = current_max
        for donor_index, donor in enumerate(columns):
            donor_type = column_type(donor)
            donor_height = heights[donor_index]
            if donor_type is None or donor_height <= 1 or donor_height < current_max:
                continue
            moved_item = donor[donor_height - 1]
            if moved_item is None:
                continue
            for receiver_index, receiver in enumerate(columns):
                if receiver_index == donor_index:
                    continue
                receiver_type = column_type(receiver)
                receiver_height = heights[receiver_index]
                if receiver_type is None or receiver_type == donor_type:
                    continue
                insert_index = receiver_height + 1
                if insert_index >= rows:
                    continue
                candidate_heights = list(heights)
                candidate_heights[donor_index] = donor_height - 1
                candidate_heights[receiver_index] = insert_index + 1
                candidate_max = max(candidate_heights)
                if candidate_max < best_height:
                    best_height = candidate_max
                    best_move = (donor_index, receiver_index, donor_height - 1, insert_index, moved_item)
        if best_move is None:
            return columns
        donor_index, receiver_index, donor_slot, receiver_slot, moved_item = best_move
        columns[donor_index][donor_slot] = None
        while len(columns[receiver_index]) <= receiver_slot:
            columns[receiver_index].append(None)
        columns[receiver_index][receiver_slot] = moved_item


def page_from_columns(columns: list[list[PrintItem | None]], cols_per_page: int, rows: int) -> list[PrintItem | None]:
    page: list[PrintItem | None] = [None] * (cols_per_page * rows)
    for col_index, column in enumerate(columns[:cols_per_page]):
        for row_index, item in enumerate(column[:rows]):
            if item is not None:
                page[row_index * cols_per_page + col_index] = item
    return page


def full_pages_then_remainder(items: list[PrintItem], cols: int, rows: int) -> tuple[list[CardPage], list[PrintItem]]:
    per_page = cols * rows
    pages: list[CardPage] = []
    full_count = len(items) // per_page
    for page_index in range(full_count):
        start = page_index * per_page
        pages.append(CardPage(list(items[start : start + per_page])))
    return pages, items[full_count * per_page :]


def best_mixed_column_counts(front_count: int, back_count: int, cols: int, rows: int) -> tuple[int, int]:
    best_score: tuple[int, int, int] | None = None
    best_counts = (0, 0)
    for front_cols in range(cols + 1):
        back_cols = cols - front_cols
        if front_count and front_cols == 0:
            continue
        if back_count and back_cols == 0:
            continue
        if not front_count and front_cols:
            continue
        if not back_count and back_cols:
            continue
        front_placed = min(front_count, front_cols * rows) if front_cols else 0
        back_placed = min(back_count, back_cols * rows) if back_cols else 0
        if front_placed + back_placed <= 0:
            continue
        front_h = math.ceil(front_placed / front_cols) if front_cols else 0
        back_h = math.ceil(back_placed / back_cols) if back_cols else 0
        used_cols = (front_cols if front_placed else 0) + (back_cols if back_placed else 0)
        score = (front_placed + back_placed, -max(front_h, back_h), -used_cols)
        if best_score is None or score > best_score:
            best_score = score
            best_counts = (front_cols if front_placed else 0, back_cols if back_placed else 0)
    return best_counts


def pack_remainder_pages(front_items: list[PrintItem], back_items: list[PrintItem], cols: int, rows: int) -> list[CardPage]:
    pages: list[CardPage] = []
    fronts = list(front_items)
    backs = list(back_items)
    while fronts or backs:
        front_cols, back_cols = best_mixed_column_counts(len(fronts), len(backs), cols, rows)
        if front_cols + back_cols <= 0:
            break
        front_take = min(len(fronts), front_cols * rows)
        back_take = min(len(backs), back_cols * rows)
        page_columns = balanced_columns(fronts[:front_take], front_cols) + balanced_columns(backs[:back_take], back_cols)
        page_columns = optimize_mixed_columns(page_columns, rows)
        pages.append(CardPage(page_from_columns(page_columns, cols, rows)))
        fronts = fronts[front_take:]
        backs = backs[back_take:]
    return pages


def pack_cropped_standard_group(group_items: list[PrintItem], cols: int, rows: int) -> list[CardPage]:
    fronts = sorted((item for item in group_items if item.image_type != "back"), key=item_order_key)
    backs = sorted((item for item in group_items if item.image_type == "back"), key=item_order_key)
    front_pages, front_remainder = full_pages_then_remainder(fronts, cols, rows)
    back_pages, back_remainder = full_pages_then_remainder(backs, cols, rows)
    return front_pages + back_pages + pack_remainder_pages(front_remainder, back_remainder, cols, rows)


def is_character_item(item: PrintItem) -> bool:
    return item.group.startswith("character:")


def character_edge_compatible(rotation: int, side: str) -> bool:
    if rotation == 90:
        edge_by_side = {"left": "top", "right": "bottom", "top": "right", "bottom": "left"}
    else:
        edge_by_side = {"left": "bottom", "right": "top", "top": "left", "bottom": "right"}
    return edge_by_side[side] in {"top", "right"}


def item_edge_compatible(item: PrintItem, side: str, cols: int, index: int) -> bool:
    if not is_character_item(item):
        return True
    rotation = item.layout_rotation if item.layout_rotation is not None else (-90 if index % cols else 90)
    return character_edge_compatible(rotation, side)


def character_duplicate_key(item: PrintItem) -> str:
    return item.sort_label or item.label.lower()


def character_bottom_left_corner(index: int, rotation: int, cols: int) -> tuple[int, int]:
    row, col = divmod(index, cols)
    if rotation == 90:
        return row + 1, col + 1
    return row, col


def character_item_bottom_left_corner(item: PrintItem, index: int, cols: int) -> tuple[int, int]:
    rotation = item.layout_rotation if item.layout_rotation is not None else (-90 if index % cols else 90)
    return character_bottom_left_corner(index, rotation, cols)


def slot_corners(index: int, cols: int) -> set[tuple[int, int]]:
    row, col = divmod(index, cols)
    return {(row, col), (row, col + 1), (row + 1, col), (row + 1, col + 1)}


def character_bottom_left_corner_allowed(
    page: list[PrintItem | None],
    index: int,
    item: PrintItem,
    cols: int,
) -> bool:
    item_corners = slot_corners(index, cols)
    item_is_character = is_character_item(item)
    item_bl = character_item_bottom_left_corner(item, index, cols) if item_is_character else None
    item_key = character_duplicate_key(item) if item_is_character else None

    for placed_index, placed in enumerate(page):
        if placed is None:
            continue

        placed_corners = slot_corners(placed_index, cols)
        placed_is_character = is_character_item(placed)
        placed_bl = character_item_bottom_left_corner(placed, placed_index, cols) if placed_is_character else None
        placed_key = character_duplicate_key(placed) if placed_is_character else None

        if item_is_character and item_bl in placed_corners:
            if not (placed_is_character and placed_bl == item_bl and placed_key == item_key):
                return False

        if placed_is_character and placed_bl in item_corners:
            if not (item_is_character and item_bl == placed_bl and item_key == placed_key):
                return False

    return True


def neighboring_slots(index: int, cols: int, total: int) -> list[tuple[int, str, str]]:
    neighbors: list[tuple[int, str, str]] = []
    if index % cols:
        neighbors.append((index - 1, "left", "right"))
    if index % cols != cols - 1 and index + 1 < total:
        neighbors.append((index + 1, "right", "left"))
    if index >= cols:
        neighbors.append((index - cols, "top", "bottom"))
    if index + cols < total:
        neighbors.append((index + cols, "bottom", "top"))
    return neighbors


def can_place_item(page: list[PrintItem | None], index: int, item: PrintItem, cols: int) -> bool:
    if not character_bottom_left_corner_allowed(page, index, item, cols):
        return False

    for neighbor_index, item_side, neighbor_side in neighboring_slots(index, cols, len(page)):
        neighbor = page[neighbor_index]
        if neighbor is None:
            continue
        if not item_edge_compatible(item, item_side, cols, index):
            return False
        if not item_edge_compatible(neighbor, neighbor_side, cols, neighbor_index):
            return False
    return True


def character_pairs(items: list[PrintItem]) -> tuple[list[tuple[PrintItem, PrintItem]], list[PrintItem]]:
    by_key: dict[str, list[PrintItem]] = {}
    for item in items:
        by_key.setdefault(character_duplicate_key(item), []).append(item)

    pairs: list[tuple[PrintItem, PrintItem]] = []
    leftovers: list[PrintItem] = []
    for key in sorted(by_key):
        group = by_key[key]
        for i in range(0, len(group) - 1, 2):
            pairs.append((group[i], group[i + 1]))
        if len(group) % 2:
            leftovers.append(group[-1])
    return pairs, leftovers


def shared_character_corner_slots(page: list[PrintItem | None], cols: int) -> list[tuple[int, int]]:
    slots: list[tuple[int, int]] = []
    for nw_index, item in enumerate(page):
        if item is not None or nw_index % cols == cols - 1:
            continue
        se_index = nw_index + cols + 1
        if se_index >= len(page) or page[se_index] is not None:
            continue
        slots.append((nw_index, se_index))
    return slots


def fill_single_characters(
    page: list[PrintItem | None],
    character_items: list[PrintItem],
    cols: int,
) -> tuple[list[PrintItem | None], list[PrintItem]]:
    empty_indices = [index for index, item in enumerate(page) if item is None]
    best_page = list(page)
    best_count = 0

    def dfs(slot_pos: int, char_pos: int) -> None:
        nonlocal best_count, best_page
        if char_pos > best_count:
            best_count = char_pos
            best_page = list(page)
        if char_pos == len(character_items) or slot_pos == len(empty_indices):
            return
        if char_pos + (len(empty_indices) - slot_pos) <= best_count:
            return

        index = empty_indices[slot_pos]
        for rotation in (90, -90):
            item = replace(character_items[char_pos], layout_rotation=rotation)
            if can_place_item(page, index, item, cols):
                page[index] = item
                dfs(slot_pos + 1, char_pos + 1)
                page[index] = None
        dfs(slot_pos + 1, char_pos)

    dfs(0, 0)
    return best_page, character_items[best_count:]


def fill_page_with_characters(
    page: list[PrintItem | None],
    character_items: list[PrintItem],
    cols: int,
) -> tuple[list[PrintItem | None], list[PrintItem]]:
    pairs, leftovers = character_pairs(character_items)
    if not pairs:
        return fill_single_characters(page, character_items, cols)

    best_page = list(page)
    best_remaining = list(character_items)
    best_score = (-1, -1)

    def dfs(pair_pos: int, placed_pairs: int, unplaced: list[PrintItem]) -> None:
        nonlocal best_page, best_remaining, best_score
        remaining_pairs = len(pairs) - pair_pos
        max_possible_total = len(character_items)
        if max_possible_total < best_score[0]:
            return
        if max_possible_total == best_score[0] and placed_pairs + remaining_pairs <= best_score[1]:
            return

        if pair_pos == len(pairs):
            remaining_for_single_fill = sorted(leftovers + unplaced, key=item_order_key)
            candidate_page, candidate_remaining = fill_single_characters(list(page), remaining_for_single_fill, cols)
            total_placed = len(character_items) - len(candidate_remaining)
            score = (total_placed, placed_pairs)
            if score > best_score:
                best_score = score
                best_page = candidate_page
                best_remaining = candidate_remaining
            return

        first, second = pairs[pair_pos]
        for nw_index, se_index in shared_character_corner_slots(page, cols):
            nw_item = replace(first, layout_rotation=90)
            se_item = replace(second, layout_rotation=-90)
            if not can_place_item(page, nw_index, nw_item, cols):
                continue
            page[nw_index] = nw_item
            if can_place_item(page, se_index, se_item, cols):
                page[se_index] = se_item
                dfs(pair_pos + 1, placed_pairs + 1, unplaced)
                page[se_index] = None
            page[nw_index] = None

        dfs(pair_pos + 1, placed_pairs, unplaced + [first, second])

    dfs(0, 0, [])
    return best_page, best_remaining


def fill_bottom_rows_with_spaced_characters(
    pages: list[CardPage],
    character_items: list[PrintItem],
    config: BuildConfig,
) -> list[PrintItem]:
    if not character_items:
        return character_items

    cols, rows = grid_capacity(config)
    page_w = mm_to_px(config.paper_mm[0], config.dpi)
    page_h = mm_to_px(config.paper_mm[1], config.dpi)
    margin = mm_to_px(config.margin_mm, config.dpi)
    gap = mm_to_px(5.0, config.dpi)
    card_w = mm_to_px(config.card_mm[0], config.dpi)
    card_h = mm_to_px(config.card_mm[1], config.dpi)
    oriented_w, oriented_h = card_h, card_w
    grid_w = cols * oriented_w
    x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
    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

        used_cols = min(fit_cols, capacity)
        used_rows = math.ceil(capacity / fit_cols)
        placement_w = used_cols * item_w + max(0, used_cols - 1) * gap
        placement_h = used_rows * item_h + max(0, used_rows - 1) * gap
        place_x0 = area_left + (area_w - placement_w) // 2
        place_y0 = area_top
        extras = list(page.extras or [])
        for index in range(capacity):
            row, col = divmod(index, fit_cols)
            left = place_x0 + col * (item_w + gap)
            top = place_y0 + row * (item_h + gap)
            extras.append((remaining[index], (left, top, left + item_w, top + item_h), rotation))
        page.extras = extras
        remaining = remaining[capacity:]

    return fill_free_space_with_spaced_characters(pages, remaining, config)


def rects_conflict(rect: tuple[int, int, int, int], occupied: list[tuple[int, int, int, int]], gap: int) -> bool:
    left, top, right, bottom = rect
    for other_left, other_top, other_right, other_bottom in occupied:
        if left < other_right + gap and right > other_left - gap and top < other_bottom + gap and bottom > other_top - gap:
            return True
    return False


def pack_spaced_items_in_free_space(
    occupied: list[tuple[int, int, int, int]],
    items: list[PrintItem],
    config: BuildConfig,
) -> list[tuple[PrintItem, tuple[int, int, int, int], int]]:
    if not items:
        return []

    page_w = mm_to_px(config.paper_mm[0], config.dpi)
    page_h = mm_to_px(config.paper_mm[1], config.dpi)
    margin = mm_to_px(config.margin_mm, config.dpi)
    gap = mm_to_px(5.0, config.dpi)
    card_w = mm_to_px(config.card_mm[0], config.dpi)
    card_h = mm_to_px(config.card_mm[1], config.dpi)
    oriented_w, oriented_h = card_h, card_w
    orientations = ((oriented_w, oriented_h, 90), (card_w, card_h, 0))
    best: list[tuple[tuple[int, int, int, int], int]] = []
    seen: set[tuple[int, tuple[tuple[int, int, int, int], ...]]] = set()

    def normalized(rects: list[tuple[int, int, int, int]]) -> tuple[tuple[int, int, int, int], ...]:
        return tuple(sorted(rects))

    def candidates(rects: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int, int]]:
        xs = {margin}
        ys = {margin}
        for left, top, right, bottom in rects:
            for item_w, item_h, _ in orientations:
                xs.add(right + gap)
                xs.add(left - gap - item_w)
                ys.add(bottom + gap)
                ys.add(top - gap - item_h)

        out: list[tuple[int, int, int, int, int]] = []
        for item_w, item_h, rotation in orientations:
            for top in sorted(ys):
                if top < margin or top + item_h > page_h - margin:
                    continue
                for left in sorted(xs):
                    if left < margin or left + item_w > page_w - margin:
                        continue
                    rect = (left, top, left + item_w, top + item_h)
                    if not rects_conflict(rect, rects, gap):
                        out.append((*rect, rotation))
        return sorted(out, key=lambda value: (value[1], value[0], value[4]))

    def dfs(rects: list[tuple[int, int, int, int]], placements: list[tuple[tuple[int, int, int, int], int]]) -> bool:
        nonlocal best
        if len(placements) > len(best):
            best = list(placements)
            if len(best) == len(items):
                return True

        state = (len(placements), normalized(rects))
        if state in seen:
            return False
        seen.add(state)

        for left, top, right, bottom, rotation in candidates(rects):
            rect = (left, top, right, bottom)
            if dfs(rects + [rect], placements + [(rect, rotation)]):
                return True
        return False

    dfs(list(occupied), [])
    return [(items[index], rect, rotation) for index, (rect, rotation) in enumerate(best)]


def standard_grid_rects(page: CardPage, config: BuildConfig) -> list[tuple[int, int, int, int]]:
    cols, _ = grid_capacity(config)
    page_w = mm_to_px(config.paper_mm[0], config.dpi)
    margin = mm_to_px(config.margin_mm, config.dpi)
    card_w = mm_to_px(config.card_mm[0], config.dpi)
    card_h = mm_to_px(config.card_mm[1], config.dpi)
    oriented_w, oriented_h = card_h, card_w
    grid_w = cols * oriented_w
    x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
    y0 = margin
    rects: list[tuple[int, int, int, int]] = []
    for index, item in enumerate(page.items):
        if item is None:
            continue
        row, col = divmod(index, cols)
        left = x0 + col * oriented_w
        top = y0 + row * oriented_h
        rects.append((left, top, left + oriented_w, top + oriented_h))
    rects.extend(rect for _, rect, _ in (page.extras or []))
    return rects


def standard_groups_on_page(page: CardPage) -> set[str]:
    return {item.group for item in page.items if item is not None and not is_character_item(item)}


def standard_move_is_compatible(receiver: CardPage, moved: list[PrintItem], config: BuildConfig) -> bool:
    if not moved:
        return False
    if not config.crop_black_borders:
        return True
    receiver_groups = standard_groups_on_page(receiver)
    moved_groups = {item.group for item in moved}
    return len(moved_groups) == 1 and (not receiver_groups or moved_groups == receiver_groups)


def remove_last_standard_items(page: CardPage, count: int) -> tuple[CardPage, list[PrintItem]]:
    items = list(page.items)
    moved_with_indices: list[tuple[int, PrintItem]] = []
    for index in range(len(items) - 1, -1, -1):
        item = items[index]
        if item is None or is_character_item(item):
            continue
        moved_with_indices.append((index, item))
        items[index] = None
        if len(moved_with_indices) == count:
            break
    moved_with_indices.reverse()
    return CardPage(items, page.spaced, list(page.extras or [])), [item for _, item in moved_with_indices]


def add_standard_items_to_empty_slots(page: CardPage, moved: list[PrintItem]) -> CardPage:
    items = list(page.items)
    moved_index = 0
    for index, item in enumerate(items):
        if item is None and moved_index < len(moved):
            items[index] = moved[moved_index]
            moved_index += 1
    return CardPage(items, page.spaced, None)


def page_accepts_standard_item(page: CardPage, item: PrintItem, config: BuildConfig) -> bool:
    if config.crop_black_borders:
        groups = standard_groups_on_page(page)
        return not groups or groups == {item.group}
    return True


def distribute_standard_overflow(
    pages: list[CardPage],
    overflow: list[PrintItem],
    config: BuildConfig,
) -> None:
    cols, rows = grid_capacity(config)
    per_page = cols * rows
    remaining = list(overflow)

    for page_index, page in enumerate(pages):
        if not remaining or page.extras:
            continue
        items = list(page.items)
        changed = False
        for slot_index, existing in enumerate(items):
            if existing is not None:
                continue
            for item_index, item in enumerate(remaining):
                if page_accepts_standard_item(CardPage(items), item, config):
                    items[slot_index] = item
                    del remaining[item_index]
                    changed = True
                    break
            if not remaining:
                break
        if changed:
            pages[page_index] = CardPage(items, page.spaced, page.extras)

    while remaining:
        group = remaining[0].group if config.crop_black_borders else None
        chunk: list[PrintItem] = []
        next_remaining: list[PrintItem] = []
        for item in remaining:
            if len(chunk) < per_page and (group is None or item.group == group):
                chunk.append(item)
            else:
                next_remaining.append(item)
        pages.append(CardPage(chunk + [None] * (per_page - len(chunk))))
        remaining = next_remaining


def reserve_character_rows_by_evicting_standard(
    pages: list[CardPage],
    character_items: list[PrintItem],
    config: BuildConfig,
) -> list[PrintItem]:
    cols, rows = grid_capacity(config)
    if cols < 2 or rows < 2 or len(character_items) < 2:
        return character_items

    remaining = list(character_items)
    overflow: list[PrintItem] = []
    for page_index, page in enumerate(pages):
        if len(remaining) < 2 or page.spaced or page.extras:
            continue
        standard_count = sum(1 for item in page.items if item is not None and not is_character_item(item))
        if standard_count != cols * rows:
            continue
        trial_page, moved = remove_last_standard_items(page, cols)
        if len(moved) != cols:
            continue
        placed = pack_spaced_items_in_free_space(standard_grid_rects(trial_page, config), remaining[:2], config)
        if len(placed) != 2:
            continue
        pages[page_index] = CardPage(trial_page.items, trial_page.spaced, placed)
        overflow.extend(moved)
        remaining = remaining[2:]

    if overflow:
        distribute_standard_overflow(pages, overflow, config)
    return remaining


def swap_standard_space_for_characters(
    pages: list[CardPage],
    character_items: list[PrintItem],
    config: BuildConfig,
) -> list[PrintItem]:
    remaining = list(character_items)
    if not remaining:
        return remaining

    while remaining:
        best = None
        best_score = 0

        for receiver_index, receiver in enumerate(pages):
            receiver_extras = list(receiver.extras or [])
            if not receiver_extras:
                continue
            empty_slots = [index for index, item in enumerate(receiver.items) if item is None]
            if not empty_slots:
                continue

            for donor_index, donor in enumerate(pages):
                if donor_index == receiver_index or donor.extras:
                    continue
                donor_standard_count = sum(1 for item in donor.items if item is not None and not is_character_item(item))
                max_move = min(len(empty_slots), donor_standard_count)
                for move_count in range(max_move, 0, -1):
                    donor_trial, moved = remove_last_standard_items(donor, move_count)
                    if len(moved) != move_count or not standard_move_is_compatible(receiver, moved, config):
                        continue

                    receiver_trial = add_standard_items_to_empty_slots(receiver, moved)
                    char_pool = [item for item, _, _ in receiver_extras] + remaining
                    placed = pack_spaced_items_in_free_space(standard_grid_rects(donor_trial, config), char_pool, config)
                    net_gain = len(placed) - len(receiver_extras)
                    if net_gain > best_score:
                        best_score = net_gain
                        best = (receiver_index, donor_index, receiver_trial, donor_trial, placed, char_pool)
                    if best_score >= len(remaining):
                        break
                if best_score >= len(remaining):
                    break
            if best_score >= len(remaining):
                break

        if best is None:
            return remaining

        receiver_index, donor_index, receiver_trial, donor_trial, placed, char_pool = best
        pages[receiver_index] = receiver_trial
        donor_trial.extras = placed
        pages[donor_index] = donor_trial
        remaining = char_pool[len(placed) :]

    return remaining


def normalize_standard_order(pages: list[CardPage], config: BuildConfig) -> None:
    standard_items = [
        item
        for page in pages
        for item in page.items
        if item is not None and not is_character_item(item)
    ]
    if not standard_items:
        return

    if config.crop_black_borders:
        standard_items = sorted(standard_items, key=lambda item: (group_order_key(item.group), item_order_key(item)))
    else:
        standard_items = sorted(standard_items, key=noncropped_standard_item_order_key)

    if config.crop_black_borders:
        buckets: dict[tuple[str, str], list[PrintItem]] = {}
        for item in standard_items:
            buckets.setdefault((item.group, item.image_type), []).append(item)

        for page in pages:
            items = list(page.items)
            for index, item in enumerate(items):
                if item is None or is_character_item(item):
                    continue
                key = (item.group, item.image_type)
                items[index] = buckets[key].pop(0) if buckets.get(key) else item
            page.items = items
        return

    gap = mm_to_px(5.0, config.dpi)
    slot_rects = standard_page_slot_rects(config)
    slots: list[tuple[CardPage, int]] = []
    for page in pages:
        if page.spaced:
            continue
        extra_rects = [rect for _, rect, _ in (page.extras or [])]
        for index, item in enumerate(page.items):
            if item is not None and is_character_item(item):
                continue
            if index >= len(slot_rects):
                continue
            if extra_rects and rects_conflict(slot_rects[index], extra_rects, gap):
                continue
            slots.append((page, index))

    item_iter = iter(standard_items)
    filled: set[tuple[int, int]] = set()
    for page, index in slots:
        try:
            item = next(item_iter)
        except StopIteration:
            break
        page.items[index] = item
        filled.add((id(page), index))

    for page in pages:
        for index, item in enumerate(page.items):
            if item is not None and not is_character_item(item) and (id(page), index) not in filled:
                page.items[index] = None


def standard_page_slot_rects(config: BuildConfig) -> list[tuple[int, int, int, int]]:
    cols, rows = grid_capacity(config)
    page_w = mm_to_px(config.paper_mm[0], config.dpi)
    margin = mm_to_px(config.margin_mm, config.dpi)
    card_w = mm_to_px(config.card_mm[0], config.dpi)
    card_h = mm_to_px(config.card_mm[1], config.dpi)
    oriented_w, oriented_h = card_h, card_w
    grid_w = cols * oriented_w
    x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
    y0 = margin
    rects: list[tuple[int, int, int, int]] = []
    for index in range(cols * rows):
        row, col = divmod(index, cols)
        left = x0 + col * oriented_w
        top = y0 + row * oriented_h
        rects.append((left, top, left + oriented_w, top + oriented_h))
    return rects


def shift_rect(rect: tuple[int, int, int, int], dx: int, dy: int) -> tuple[int, int, int, int]:
    return rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy


def center_rows_horizontally(
    entries,
    page_w: int,
    margin: int,
):
    rows: list[list[int]] = []
    for index, entry in enumerate(entries):
        rect = entry[1]
        placed = False
        for row in rows:
            row_top = min(entries[i][1][1] for i in row)
            row_bottom = max(entries[i][1][3] for i in row)
            if rect[1] < row_bottom and rect[3] > row_top:
                row.append(index)
                placed = True
                break
        if not placed:
            rows.append([index])

    out = list(entries)
    usable_w = page_w - 2 * margin
    for row in rows:
        left = min(out[i][1][0] for i in row)
        right = max(out[i][1][2] for i in row)
        row_w = right - left
        dx = margin + (usable_w - row_w) // 2 - left
        if dx:
            for index in row:
                values = list(out[index])
                values[1] = shift_rect(values[1], dx, 0)
                out[index] = tuple(values)
    return out


def fill_free_space_with_spaced_characters(
    pages: list[CardPage],
    character_items: list[PrintItem],
    config: BuildConfig,
) -> list[PrintItem]:
    if not character_items:
        return character_items

    cols, _ = grid_capacity(config)
    page_w = mm_to_px(config.paper_mm[0], config.dpi)
    margin = mm_to_px(config.margin_mm, config.dpi)
    card_h = mm_to_px(config.card_mm[1], config.dpi)
    card_w = mm_to_px(config.card_mm[0], config.dpi)
    oriented_w, oriented_h = card_h, card_w
    grid_w = cols * oriented_w
    x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
    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=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)
        reserve_first = fill_noncropped_character_pages(pages, character_items, config, reserve_first=True)
        pages = min((existing_first, reserve_first), key=character_page_score)
    return pages


def pack_spaced_character_pages(items: list[PrintItem], config: BuildConfig) -> list[CardPage]:
    page_w = mm_to_px(config.paper_mm[0], config.dpi)
    page_h = mm_to_px(config.paper_mm[1], config.dpi)
    margin = mm_to_px(config.margin_mm, config.dpi)
    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 = (page_w - card_w) // 2
            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]:
    character_indices: list[int] = []
    character_only_pages = 0
    for page_index, page in enumerate(pages):
        page_items = [item for item in page.items if item is not None]
        page_items.extend(item for item, _, _ in (page.extras or []))
        has_character = any(is_character_item(item) for item in page_items)
        has_standard = any(not is_character_item(item) for item in page_items)
        if has_character:
            character_indices.append(page_index)
        if has_character and not has_standard:
            character_only_pages += 1
    last_character_page = max(character_indices) if character_indices else -1
    return len(pages), last_character_page, character_only_pages


def fill_cropped_character_pages(
    base_pages: list[CardPage],
    character_items: list[PrintItem],
    config: BuildConfig,
    reserve_first: bool,
) -> list[CardPage]:
    pages = clone_pages(base_pages)
    remaining = list(character_items)

    if reserve_first:
        remaining = reserve_character_rows_by_evicting_standard(pages, remaining, config)
    remaining = fill_bottom_rows_with_spaced_characters(pages, remaining, config)
    remaining = swap_standard_space_for_characters(pages, remaining, config)
    if not reserve_first:
        remaining = reserve_character_rows_by_evicting_standard(pages, remaining, config)
        remaining = fill_bottom_rows_with_spaced_characters(pages, remaining, config)
        remaining = swap_standard_space_for_characters(pages, remaining, config)

    normalize_standard_order(pages, config)
    if remaining:
        pages.extend(pack_spaced_character_pages(remaining, config))
    return pages


def fill_noncropped_character_pages(
    base_pages: list[CardPage],
    character_items: list[PrintItem],
    config: BuildConfig,
    reserve_first: bool,
) -> list[CardPage]:
    cols, _ = grid_capacity(config)
    pages = clone_pages(base_pages)
    remaining = list(character_items)

    if reserve_first:
        remaining = reserve_character_rows_by_evicting_standard(pages, remaining, config)

    if remaining and pages:
        last_page = pages[-1]
        if not last_page.spaced:
            filled_page, filled_remaining = fill_page_with_characters(list(last_page.items), remaining, cols)
            if not filled_remaining:
                pages[-1] = CardPage(filled_page)
                remaining = []

    remaining = fill_free_space_with_spaced_characters(pages, remaining, config)
    remaining = swap_standard_space_for_characters(pages, remaining, config)

    if not reserve_first:
        remaining = reserve_character_rows_by_evicting_standard(pages, remaining, config)
        remaining = fill_free_space_with_spaced_characters(pages, remaining, config)
        remaining = swap_standard_space_for_characters(pages, remaining, config)

    normalize_standard_order(pages, config)
    if remaining:
        pages.extend(pack_spaced_character_pages(remaining, config))
    return pages


def draw_crop_lines(page: Image.Image, rect: tuple[int, int, int, int]) -> None:
    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 split_trailing_character_only_pages(pages: list[CardPage]) -> tuple[list[CardPage], list[PrintItem]]:
    deferred: list[PrintItem] = []
    kept = list(pages)
    while kept:
        page = kept[-1]
        extras = page.extras or []
        if page.items or not extras or any(not is_character_item(item) for item, _, _ in extras):
            break
        deferred = [item for item, _, _ in extras] + deferred
        kept.pop()
    return kept, deferred


def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> tuple[int, list[PrintItem]]:
    pages = grouped_card_pages(items, config)
    pages, deferred_character_items = split_trailing_character_only_pages(pages)
    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
        x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
        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))

        visible_entries = [(item, rect, rotate) for item, rect, rotate in placements if item is not None] + list(extras)
        centered_entries = center_rows_horizontally(visible_entries, page_w, margin)
        centered_by_identity = {(id(item), rect, rotate): centered for (item, rect, rotate), centered in zip(visible_entries, centered_entries)}
        placements = [
            centered_by_identity.get((id(item), rect, rotate), (item, rect, rotate))
            for item, rect, rotate in placements
        ]
        extras = [
            centered_by_identity[(id(item), rect, rotate)]
            for item, rect, rotate in extras
        ]

        for item, rect, _ in placements:
            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)

    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


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)
    bleed_px = mm_to_px(config.bleed_mm, 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_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)
                board_placements.append((item, rect, 90 if rotate else 0))
                x += bw + gap
            y += row_h + gap

        char_placements = pack_spaced_items_in_free_space(
            [rect for _, rect, _ in board_placements],
            remaining_characters,
            config,
        )
        centered = center_rows_horizontally(board_placements + char_placements, page_w, margin)
        board_count = len(board_placements)
        return centered[:board_count], centered[board_count:]

    planned_pages = []
    for page_items in pages:
        board_placements, char_placements = plan_board_placements(page_items)
        if char_placements:
            remaining_characters = remaining_characters[len(char_placements) :]
        planned_pages.append((board_placements, char_placements))

    for page in pack_spaced_character_pages(remaining_characters, config):
        planned_pages.append(([], list(page.extras or [])))
    remaining_characters = []

    def render_one_board_page(page_offset_and_plan) -> tuple[Path, int]:
        page_offset, (board_placements, char_placements) = page_offset_and_plan
        page = Image.new("RGB", (page_w, page_h), "white")
        image_cache: dict[Path, Image.Image] = {}

        def load_page_image(path: Path) -> Image.Image:
            if path not in image_cache:
                image_cache[path] = Image.open(path).convert("RGB")
            return image_cache[path]

        def make_item_bleed(item: PrintItem, face: Image.Image) -> Image.Image:
            if item.bleed_path is not None and bleed_px > 0:
                return load_page_image(item.bleed_path)
            return make_bleed(face, bleed_px)

        placements = board_placements + char_placements
        for _, rect, _ in placements:
            draw_crop_lines(page, rect)
        for item, rect, rotate in placements:
            face = load_page_image(item.path)
            bleed = make_item_bleed(item, face)
            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, rect, rotate 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(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)
