Improve board sizing and page packing

This commit is contained in:
ajp_anton
2026-05-31 19:57:08 +00:00
parent 4f9eb9ec60
commit 774de1c4f5
5 changed files with 766 additions and 193 deletions
+493 -126
View File
@@ -438,9 +438,8 @@ def fill_bottom_rows_with_spaced_characters(
card_h = mm_to_px(config.card_mm[1], config.dpi)
oriented_w, oriented_h = card_h, card_w
grid_w = cols * oriented_w
grid_h = rows * oriented_h
x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
y0 = margin + ((page_h - 2 * margin) - grid_h) // 2
y0 = margin
remaining = list(character_items)
for page in pages:
@@ -456,12 +455,10 @@ def fill_bottom_rows_with_spaced_characters(
if empty_rows <= 0:
continue
edge_margin = margin
top_slack = max(0, y0 - edge_margin)
area_left = margin
area_top = y0 + first_empty_row * oriented_h
area_w = page_w - 2 * margin
area_h = page_h - edge_margin - area_top + top_slack
area_h = page_h - margin - area_top
if first_empty_row > 0:
area_top += gap
area_h -= gap
@@ -568,35 +565,321 @@ def pack_spaced_items_in_free_space(
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 centered_shift(
rects: list[tuple[int, int, int, int]],
def center_rows_horizontally(
entries,
page_w: int,
page_h: int,
edge_margin: int,
) -> tuple[int, int]:
if not rects:
return 0, 0
left = min(rect[0] for rect in rects)
top = min(rect[1] for rect in rects)
right = max(rect[2] for rect in rects)
bottom = max(rect[3] for rect in rects)
bbox_w = right - left
bbox_h = bottom - top
target_left = (page_w - bbox_w) // 2
target_top = (page_h - bbox_h) // 2
min_left = edge_margin
max_left = page_w - edge_margin - bbox_w
min_top = edge_margin
max_top = page_h - edge_margin - bbox_h
if max_left >= min_left:
target_left = min(max(target_left, min_left), max_left)
if max_top >= min_top:
target_top = min(max(target_top, min_top), max_top)
return target_left - left, target_top - top
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(
@@ -607,17 +890,15 @@ def fill_free_space_with_spaced_characters(
if not character_items:
return character_items
cols, rows = grid_capacity(config)
cols, _ = grid_capacity(config)
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
card_h = mm_to_px(config.card_mm[1], config.dpi)
card_w = mm_to_px(config.card_mm[0], config.dpi)
oriented_w, oriented_h = card_h, card_w
grid_w = cols * oriented_w
grid_h = rows * oriented_h
x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
y0 = margin + ((page_h - 2 * margin) - grid_h) // 2
y0 = margin
remaining = list(character_items)
for page in pages:
@@ -652,7 +933,9 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card
for group in groups:
group_items = [item for item in standard_items if item.group == group]
pages.extend(pack_cropped_standard_group(group_items, cols, rows))
character_items = fill_bottom_rows_with_spaced_characters(pages, character_items, config)
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):
@@ -660,18 +943,9 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card
if i + per_page >= len(group_items):
page = page + [None] * (per_page - len(page))
pages.append(CardPage(page))
if character_items and pages:
last_page = pages[-1]
if not last_page.spaced:
filled_page, character_items = fill_page_with_characters(list(last_page.items), character_items, cols)
if not character_items:
pages[-1] = CardPage(filled_page)
else:
character_items = sorted((item for item in items if item.group.startswith("character:")), key=item_order_key)
character_items = fill_free_space_with_spaced_characters(pages, character_items, config)
if character_items:
pages.extend(pack_spaced_character_pages(character_items, config))
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
@@ -679,58 +953,19 @@ def pack_spaced_character_pages(items: list[PrintItem], config: BuildConfig) ->
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
gap = mm_to_px(5.0, config.dpi)
edge_margin = margin
card_w = mm_to_px(config.card_mm[0], config.dpi)
card_h = mm_to_px(config.card_mm[1], config.dpi)
oriented_w, oriented_h = card_h, card_w
remaining = list(items)
pages: list[CardPage] = []
while remaining:
occupied: list[tuple[int, int, int, int]] = []
extras: list[tuple[PrintItem, tuple[int, int, int, int], int]] = []
placed_count = 0
while placed_count < len(remaining):
best: tuple[int, int, tuple[int, int, int, int], int] | None = None
for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)):
xs = {edge_margin, page_w - edge_margin - item_w}
ys = {edge_margin, page_h - edge_margin - item_h}
for left, top, right, bottom in occupied:
xs.add(right + gap)
xs.add(left - gap - item_w)
ys.add(bottom + gap)
ys.add(top - gap - item_h)
for top in sorted(ys):
if top < edge_margin or top + item_h > page_h - edge_margin:
continue
for left in sorted(xs):
if left < edge_margin or left + item_w > page_w - edge_margin:
continue
rect = (left, top, left + item_w, top + item_h)
if rects_conflict(rect, occupied, gap):
continue
score = (top, left)
if best is None or score < best[:2]:
best = (top, left, rect, rotation)
break
if best is not None and best[0] == top:
break
if best is None:
break
_, _, rect, rotation = best
extras.append((remaining[placed_count], rect, rotation))
occupied.append(rect)
placed_count += 1
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 = (page_h - card_h) // 2
top = margin
extras.append((item, (left, top, left + card_w, top + card_h), 0))
placed_count = 1
@@ -777,6 +1012,85 @@ def noncropped_standard_item_order_key(item: PrintItem) -> tuple[int, str, str,
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
@@ -786,8 +1100,22 @@ def draw_crop_lines(page: Image.Image, rect: tuple[int, int, int, int]) -> None:
draw.line((0, bottom, page.width, bottom), fill=(0, 0, 0), width=1)
def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> int:
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)
@@ -823,9 +1151,8 @@ def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index:
page_cols = cols
page_rows = rows
grid_w = page_cols * oriented_w + max(0, page_cols - 1) * gap
grid_h = page_rows * oriented_h + max(0, page_rows - 1) * gap
x0 = margin + ((page_w - 2 * margin) - grid_w) // 2
y0 = margin + ((page_h - 2 * margin) - grid_h) // 2
y0 = margin
placements: list[tuple[PrintItem | None, tuple[int, int, int, int], int]] = []
for idx, item in enumerate(page_items):
row = idx // page_cols
@@ -835,12 +1162,17 @@ def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index:
rotate = item.layout_rotation if item is not None and item.layout_rotation is not None else (-90 if col % 2 else 90)
placements.append((item, (left, top, left + oriented_w, top + oriented_h), rotate))
occupied_rects = [rect for item, rect, _ in placements if item is not None]
occupied_rects.extend(rect for _, rect, _ in extras)
dx, dy = centered_shift(occupied_rects, page_w, page_h, margin)
if dx or dy:
placements = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in placements]
extras = [(item, shift_rect(rect, dx, dy), rotate) for item, rect, rotate in extras]
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:
@@ -878,10 +1210,11 @@ def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index:
save_temp_png(page, out, config.dpi)
return out, sum(1 for item in page_items if item is not None) + len(extras)
with ThreadPoolExecutor(max_workers=worker_count(len(pages))) as executor:
for out, count in executor.map(render_one_card_page, enumerate(pages)):
print(f"Wrote {out.relative_to(ROOT)} ({count} card images)")
return start_index + len(pages)
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]]]:
@@ -916,8 +1249,13 @@ def plan_board_rows(items: list[PrintItem], config: BuildConfig) -> list[list[tu
return pages
def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index: int) -> int:
if not items:
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)
@@ -926,17 +1264,9 @@ def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index:
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 render_one_board_page(page_offset_and_items: tuple[int, list[tuple[PrintItem, bool]]]) -> tuple[Path, int]:
page_offset, page_items = page_offset_and_items
page = Image.new("RGB", (page_w, page_h), "white")
image_cache: dict[Path, Image.Image] = {}
def load_page_image(path: Path) -> Image.Image:
if path not in image_cache:
image_cache[path] = Image.open(path).convert("RGB")
return image_cache[path]
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
@@ -953,9 +1283,8 @@ def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index:
if row:
rows.append(row)
total_h = sum(max(bh for _, _, _, bh in r) for r in rows) + gap * max(0, len(rows) - 1)
y = margin + ((page_h - 2 * margin) - total_h) // 2
placements: list[tuple[PrintItem, bool, tuple[int, int, int, int]]] = []
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)
@@ -963,21 +1292,57 @@ def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index:
for item, rotate, bw, bh in row:
top = y + (row_h - bh) // 2
rect = (x, top, x + bw, top + bh)
placements.append((item, rotate, rect))
board_placements.append((item, rect, 90 if rotate else 0))
x += bw + gap
y += row_h + gap
for _, _, rect in placements:
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, rotate, rect in placements:
for item, rect, rotate in placements:
face = load_page_image(item.path)
bleed = make_bleed(face, bleed_px)
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, rotate, rect in placements:
for item, rect, rotate in placements:
face = load_page_image(item.path)
if rotate:
face = face.rotate(90, expand=True)
@@ -987,9 +1352,11 @@ def render_board_pages(items: list[PrintItem], config: BuildConfig, start_index:
out = PAGES_DIR / f"page_{start_index + page_offset:03d}.png"
out.parent.mkdir(parents=True, exist_ok=True)
save_temp_png(page, out, config.dpi)
return out, len(page_items)
return out, len(placements)
with ThreadPoolExecutor(max_workers=worker_count(len(pages))) as executor:
for out, count in executor.map(render_one_board_page, enumerate(pages)):
print(f"Wrote {out.relative_to(ROOT)} ({count} boards)")
return start_index + len(pages)
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)