"""PDF rendering of a generated puzzle (A4 portrait, single page).""" from __future__ import annotations import io from typing import Iterable from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.pdfbase.pdfmetrics import stringWidth from reportlab.pdfgen import canvas from .generator import Puzzle PAGE_W, PAGE_H = A4 MARGIN = 18 * mm TITLE_FONT = "Helvetica-Bold" TITLE_SIZE = 24 OPTIONS_FONT = "Helvetica" OPTIONS_SIZE = 10 GRID_FONT = "Courier-Bold" LIST_HEADING_FONT = "Helvetica-Bold" LIST_HEADING_SIZE = 14 LIST_ENTRY_FONT_BOLD = "Helvetica-Bold" LIST_ENTRY_FONT_LIGHT = "Helvetica" LIST_ENTRY_SIZE = 11 def _column_count(n: int) -> int: if n <= 8: return 2 if n <= 18: return 3 return 4 def _wrap_columns(items: list[tuple], cols: int) -> list[list[tuple]]: """Lay items out top-to-bottom, then left-to-right, in `cols` columns.""" rows = (len(items) + cols - 1) // cols columns: list[list[tuple]] = [[] for _ in range(cols)] for i, item in enumerate(items): columns[i // rows].append(item) return columns def _grid_geometry(grid_size: int, available_w: float, available_h: float ) -> tuple[float, float]: """Choose (font_size, cell_size) so a `grid_size` x `grid_size` grid fits comfortably in the available box.""" # Cell size driven by whichever dimension is tighter, with padding. target_cell = min(available_w, available_h) / grid_size cell = min(target_cell, 14 * mm) # cap so 5x5 isn't comically huge cell = max(cell, 5 * mm) font_size = cell * 0.7 return font_size, cell def render_puzzle(puzzle: Puzzle, title: str) -> bytes: buf = io.BytesIO() c = canvas.Canvas(buf, pagesize=A4) # Title c.setFont(TITLE_FONT, TITLE_SIZE) title_y = PAGE_H - MARGIN - TITLE_SIZE c.drawCentredString(PAGE_W / 2, title_y, title) # Active option labels under the title (only when extras are enabled). options_y = title_y labels = puzzle.option_labels if labels: options_y = title_y - TITLE_SIZE * 0.7 c.setFont(OPTIONS_FONT, OPTIONS_SIZE) c.setFillGray(0.35) c.drawCentredString(PAGE_W / 2, options_y, " ยท ".join(s.upper() for s in labels)) c.setFillGray(0) # Reserve space for word list at the bottom; estimate from entry count. placed = puzzle.placements cols = _column_count(len(placed)) if placed else 2 rows_per_col = (len(placed) + cols - 1) // cols if placed else 0 list_line_height = LIST_ENTRY_SIZE * 1.5 list_block_h = LIST_HEADING_SIZE * 2 + rows_per_col * list_line_height list_block_h = max(list_block_h, 30 * mm) grid_top = (options_y if labels else title_y) - 8 * mm grid_bottom = MARGIN + list_block_h + 6 * mm available_h = grid_top - grid_bottom available_w = PAGE_W - 2 * MARGIN grid_size = puzzle.size font_size, cell = _grid_geometry(grid_size, available_w, available_h) grid_w = grid_size * cell grid_h = grid_size * cell origin_x = (PAGE_W - grid_w) / 2 origin_y = grid_top - grid_h # top of grid sits at grid_top c.setFont(GRID_FONT, font_size) for r in range(grid_size): for col_idx in range(grid_size): ch = puzzle.grid[r][col_idx] x = origin_x + col_idx * cell + cell / 2 # Top row in the grid array should print at the top of the grid box. y = origin_y + (grid_size - 1 - r) * cell + (cell - font_size) / 2 c.drawCentredString(x, y, ch) # Word list list_top = grid_bottom - 2 * mm c.setFont(LIST_HEADING_FONT, LIST_HEADING_SIZE) c.drawCentredString(PAGE_W / 2, list_top, "Find these words") entries: list[tuple[str, str]] = [] for p in placed: prefix_text = "" if p.word.stripped_prefixes: prefix_text = " (" + " ".join(p.word.stripped_prefixes) + ")" entries.append((p.word.grid, prefix_text)) if entries: column_lists = _wrap_columns(entries, cols) col_w = available_w / cols list_y_start = list_top - LIST_HEADING_SIZE - 4 * mm for col_idx, items in enumerate(column_lists): x = MARGIN + col_idx * col_w for i, (grid_form, prefix) in enumerate(items): y = list_y_start - i * list_line_height c.setFont(LIST_ENTRY_FONT_BOLD, LIST_ENTRY_SIZE) c.drawString(x, y, grid_form) if prefix: bold_w = stringWidth(grid_form, LIST_ENTRY_FONT_BOLD, LIST_ENTRY_SIZE) c.setFont(LIST_ENTRY_FONT_LIGHT, LIST_ENTRY_SIZE) c.setFillGray(0.4) c.drawString(x + bold_w, y, prefix) c.setFillGray(0) c.showPage() c.save() return buf.getvalue()