Files
wordsearch/app/pdf.py
2026-05-04 09:45:17 +01:00

144 lines
4.8 KiB
Python

"""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()