Initial commit
This commit is contained in:
143
app/pdf.py
Normal file
143
app/pdf.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user