144 lines
4.8 KiB
Python
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()
|