Improve print layout options and character packing

This commit is contained in:
ajp_anton
2026-05-31 12:32:49 +00:00
parent 47748f3897
commit 4f9eb9ec60
4 changed files with 228 additions and 65 deletions
+195 -61
View File
@@ -245,6 +245,42 @@ def character_item_bottom_left_corner(item: PrintItem, index: int, cols: int) ->
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:
@@ -259,16 +295,8 @@ def neighboring_slots(index: int, cols: int, total: int) -> list[tuple[int, str,
def can_place_item(page: list[PrintItem | None], index: int, item: PrintItem, cols: int) -> bool:
if is_character_item(item):
item_corner = character_item_bottom_left_corner(item, index, cols)
item_key = character_duplicate_key(item)
for placed_index, placed in enumerate(page):
if placed is None or not is_character_item(placed):
continue
if character_item_bottom_left_corner(placed, placed_index, cols) != item_corner:
continue
if character_duplicate_key(placed) != item_key:
return False
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]
@@ -428,10 +456,12 @@ 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 - min(margin, gap) - area_top
area_h = page_h - edge_margin - area_top + top_slack
if first_empty_row > 0:
area_top += gap
area_h -= gap
@@ -471,6 +501,73 @@ def rects_conflict(rect: tuple[int, int, int, int], occupied: list[tuple[int, in
return False
def pack_spaced_items_in_free_space(
occupied: list[tuple[int, int, int, int]],
items: list[PrintItem],
config: BuildConfig,
) -> list[tuple[PrintItem, tuple[int, int, int, int], int]]:
if not items:
return []
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
gap = mm_to_px(5.0, config.dpi)
card_w = mm_to_px(config.card_mm[0], config.dpi)
card_h = mm_to_px(config.card_mm[1], config.dpi)
oriented_w, oriented_h = card_h, card_w
orientations = ((oriented_w, oriented_h, 90), (card_w, card_h, 0))
best: list[tuple[tuple[int, int, int, int], int]] = []
seen: set[tuple[int, tuple[tuple[int, int, int, int], ...]]] = set()
def normalized(rects: list[tuple[int, int, int, int]]) -> tuple[tuple[int, int, int, int], ...]:
return tuple(sorted(rects))
def candidates(rects: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int, int]]:
xs = {margin}
ys = {margin}
for left, top, right, bottom in rects:
for item_w, item_h, _ in orientations:
xs.add(right + gap)
xs.add(left - gap - item_w)
ys.add(bottom + gap)
ys.add(top - gap - item_h)
out: list[tuple[int, int, int, int, int]] = []
for item_w, item_h, rotation in orientations:
for top in sorted(ys):
if top < margin or top + item_h > page_h - margin:
continue
for left in sorted(xs):
if left < margin or left + item_w > page_w - margin:
continue
rect = (left, top, left + item_w, top + item_h)
if not rects_conflict(rect, rects, gap):
out.append((*rect, rotation))
return sorted(out, key=lambda value: (value[1], value[0], value[4]))
def dfs(rects: list[tuple[int, int, int, int]], placements: list[tuple[tuple[int, int, int, int], int]]) -> bool:
nonlocal best
if len(placements) > len(best):
best = list(placements)
if len(best) == len(items):
return True
state = (len(placements), normalized(rects))
if state in seen:
return False
seen.add(state)
for left, top, right, bottom, rotation in candidates(rects):
rect = (left, top, right, bottom)
if dfs(rects + [rect], placements + [(rect, rotation)]):
return True
return False
dfs(list(occupied), [])
return [(items[index], rect, rotation) for index, (rect, rotation) in enumerate(best)]
def shift_rect(rect: tuple[int, int, int, int], dx: int, dy: int) -> tuple[int, int, int, int]:
return rect[0] + dx, rect[1] + dy, rect[2] + dx, rect[3] + dy
@@ -514,10 +611,8 @@ def fill_free_space_with_spaced_characters(
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
edge_margin = min(margin, mm_to_px(5.0, config.dpi))
gap = mm_to_px(5.0, config.dpi)
card_w = mm_to_px(config.card_mm[0], config.dpi)
card_h = mm_to_px(config.card_mm[1], config.dpi)
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
@@ -538,41 +633,10 @@ def fill_free_space_with_spaced_characters(
occupied.append((left, top, left + oriented_w, top + oriented_h))
occupied.extend(rect for _, rect, _ in (page.extras or []))
extras = list(page.extras or [])
placed_count = 0
while placed_count < len(remaining):
placed = False
for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)):
xs = {edge_margin, page_w - edge_margin - item_w}
ys = {edge_margin, page_h - edge_margin - item_h}
for left, top, right, bottom in occupied:
xs.add(right + gap)
xs.add(left - gap - item_w)
ys.add(bottom + gap)
ys.add(top - gap - item_h)
candidates = []
for y in sorted(ys):
if y < edge_margin or y + item_h > page_h - edge_margin:
continue
for x in sorted(xs):
if x < edge_margin or x + item_w > page_w - edge_margin:
continue
candidates.append((x, y, x + item_w, y + item_h))
for rect in sorted(candidates, key=lambda value: (value[1], value[0])):
if rects_conflict(rect, occupied, gap):
continue
extras.append((remaining[placed_count], rect, rotation))
occupied.append(rect)
placed_count += 1
placed = True
break
if placed:
break
if not placed:
break
if placed_count:
page.extras = extras
remaining = remaining[placed_count:]
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
@@ -590,7 +654,7 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card
pages.extend(pack_cropped_standard_group(group_items, cols, rows))
character_items = fill_bottom_rows_with_spaced_characters(pages, character_items, config)
else:
group_items = sorted(standard_items, key=item_order_key)
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):
@@ -600,28 +664,80 @@ def grouped_card_pages(items: list[PrintItem], config: BuildConfig) -> list[Card
last_page = pages[-1]
if not last_page.spaced:
filled_page, character_items = fill_page_with_characters(list(last_page.items), character_items, cols)
pages[-1] = CardPage(filled_page)
if 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:
for chunk in chunk_spaced_cards(character_items, config):
pages.append(CardPage(chunk, True))
pages.extend(pack_spaced_character_pages(character_items, config))
return pages
def chunk_spaced_cards(items: list[PrintItem], config: BuildConfig) -> list[list[PrintItem]]:
def pack_spaced_character_pages(items: list[PrintItem], config: BuildConfig) -> list[CardPage]:
page_w = mm_to_px(config.paper_mm[0], config.dpi)
page_h = mm_to_px(config.paper_mm[1], config.dpi)
margin = mm_to_px(config.margin_mm, config.dpi)
gap = mm_to_px(5.0, config.dpi)
edge_margin = margin
card_w = mm_to_px(config.card_mm[0], config.dpi)
card_h = mm_to_px(config.card_mm[1], config.dpi)
oriented_w, oriented_h = card_h, card_w
usable_w = page_w - 2 * margin
usable_h = page_h - 2 * margin
cols = max(1, (usable_w + gap) // (oriented_w + gap))
rows = max(1, (usable_h + gap) // (oriented_h + gap))
per_page = max(1, cols * rows)
return [items[i : i + per_page] for i in range(0, len(items), per_page)]
remaining = list(items)
pages: list[CardPage] = []
while remaining:
occupied: list[tuple[int, int, int, int]] = []
extras: list[tuple[PrintItem, tuple[int, int, int, int], int]] = []
placed_count = 0
while placed_count < len(remaining):
best: tuple[int, int, tuple[int, int, int, int], int] | None = None
for item_w, item_h, rotation in ((oriented_w, oriented_h, 90), (card_w, card_h, 0)):
xs = {edge_margin, page_w - edge_margin - item_w}
ys = {edge_margin, page_h - edge_margin - item_h}
for left, top, right, bottom in occupied:
xs.add(right + gap)
xs.add(left - gap - item_w)
ys.add(bottom + gap)
ys.add(top - gap - item_h)
for top in sorted(ys):
if top < edge_margin or top + item_h > page_h - edge_margin:
continue
for left in sorted(xs):
if left < edge_margin or left + item_w > page_w - edge_margin:
continue
rect = (left, top, left + item_w, top + item_h)
if rects_conflict(rect, occupied, gap):
continue
score = (top, left)
if best is None or score < best[:2]:
best = (top, left, rect, rotation)
break
if best is not None and best[0] == top:
break
if best is None:
break
_, _, rect, rotation = best
extras.append((remaining[placed_count], rect, rotation))
occupied.append(rect)
placed_count += 1
if placed_count == 0:
item = remaining[0]
left = (page_w - card_w) // 2
top = (page_h - card_h) // 2
extras.append((item, (left, top, left + card_w, top + card_h), 0))
placed_count = 1
pages.append(CardPage([], True, extras))
remaining = remaining[placed_count:]
return pages
def group_order_key(group: str) -> tuple[int, str]:
@@ -643,6 +759,24 @@ def item_order_key(item: PrintItem) -> tuple[int, str, str, str]:
return phase, item.sort_label or item.label.lower(), item.image_type, str(item.path)
def noncropped_standard_item_order_key(item: PrintItem) -> tuple[int, str, str, str]:
if item.image_type == "back":
phase = {"encounter": 5, "office": 6, "basement": 7}.get(item.group, 99)
elif item.group == "encounter":
phase = 0
elif item.group == "office" and item.is_starter:
phase = 1
elif item.group == "office":
phase = 2
elif item.group == "basement" and item.is_starter:
phase = 3
elif item.group == "basement":
phase = 4
else:
phase = 99
return phase, item.sort_label or item.label.lower(), item.image_type, str(item.path)
def draw_crop_lines(page: Image.Image, rect: tuple[int, int, int, int]) -> None:
draw = ImageDraw.Draw(page)
left, top, right, bottom = rect
@@ -703,7 +837,7 @@ def render_card_pages(items: list[PrintItem], config: BuildConfig, start_index:
occupied_rects = [rect for item, rect, _ in placements if item is not None]
occupied_rects.extend(rect for _, rect, _ in extras)
dx, dy = centered_shift(occupied_rects, page_w, page_h, min(margin, gap or margin))
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]