Files

262 lines
9.8 KiB
Python
Raw Permalink Normal View History

2026-03-24 19:36:04 +02:00
"""PDF rendering for crossword puzzle grids and clues.
Draws crossword grids (empty for puzzle pages, filled for solution pages)
and lays out numbered clues in a two-column format. All drawing happens
within caller-supplied content rectangles so the KDP formatter can
control margins, page size, and front matter independently.
"""
from typing import Optional
from xml.sax.saxutils import escape as xml_escape
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import inch
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Frame, Paragraph
from models import Crossword
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_TITLE_FONT = "Helvetica-Bold"
_TITLE_SIZE = 18
_SOLUTION_TITLE_SIZE = 14
_NUMBER_FONT = "Helvetica"
_LETTER_FONT = "Helvetica-Bold"
# Cell sizes (points). ~0.39" for puzzle pages (room to write),
# ~0.28" for solution pages (just needs to be legible).
_MAX_PUZZLE_CELL = 28.0
_MAX_SOLUTION_CELL = 20.0
# Max fraction of the content height the grid is allowed to consume,
# leaving the rest for clues (puzzle) or whitespace (solution).
_PUZZLE_GRID_RATIO = 0.55
_SOLUTION_GRID_RATIO = 0.75
# Colours (RGB 0-1)
_BLACK_FILL = (0.12, 0.12, 0.12)
_GRID_LINE = (0.45, 0.45, 0.45)
_OUTER_BORDER = (0, 0, 0)
_SOLUTION_TITLE_CLR = (0.35, 0.35, 0.35)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _calc_cell_size(cols: int, rows: int,
avail_w: float, avail_h: float,
max_cell: float, h_ratio: float) -> float:
"""Largest cell size that fits *cols*×*rows* inside the available area."""
by_w = avail_w / cols
by_h = (avail_h * h_ratio) / rows
return min(by_w, by_h, max_cell)
def _draw_grid(cv: Canvas, crossword: Crossword,
grid_x: float, grid_top_y: float,
cell_size: float, show_letters: bool) -> float:
"""Render the crossword grid. Returns the y-coordinate of the bottom."""
for ri, row in enumerate(crossword.grid):
for ci, cell in enumerate(row):
x = grid_x + ci * cell_size
y = grid_top_y - (ri + 1) * cell_size # bottom-left of cell
if cell.is_empty:
cv.setFillColorRGB(*_BLACK_FILL)
cv.setStrokeColorRGB(*_BLACK_FILL)
cv.setLineWidth(0.5)
cv.rect(x, y, cell_size, cell_size, fill=1, stroke=1)
continue
# White cell
cv.setFillColorRGB(1, 1, 1)
cv.setStrokeColorRGB(*_GRID_LINE)
cv.setLineWidth(0.5)
cv.rect(x, y, cell_size, cell_size, fill=1, stroke=1)
# Clue number (small, top-left corner)
if cell.is_numbered:
n_size = max(5.0, cell_size * 0.25)
cv.setFont(_NUMBER_FONT, n_size)
cv.setFillColorRGB(0, 0, 0)
cv.drawString(x + 1.5, y + cell_size - n_size - 0.5,
str(cell.number))
# Letter (solution pages only)
if show_letters and cell.letter:
l_size = cell_size * 0.50
cv.setFont(_LETTER_FONT, l_size)
cv.setFillColorRGB(0, 0, 0)
lw = cv.stringWidth(cell.letter, _LETTER_FONT, l_size)
# Shift down a bit when a number is present
ly = y + cell_size * 0.08 if cell.is_numbered else y + cell_size * 0.18
cv.drawString(x + (cell_size - lw) / 2, ly, cell.letter)
# Thick outer border
grid_w = crossword.width * cell_size
grid_h = crossword.height * cell_size
cv.setStrokeColorRGB(*_OUTER_BORDER)
cv.setLineWidth(1.5)
cv.rect(grid_x, grid_top_y - grid_h, grid_w, grid_h, fill=0, stroke=1)
cv.setLineWidth(0.5) # reset
return grid_top_y - grid_h
def _draw_clues(cv: Canvas, crossword: Crossword,
x: float, y_bottom: float,
width: float, height: float) -> None:
"""Draw ACROSS / DOWN clues in two columns with automatic text wrap."""
clue_style = ParagraphStyle(
"Clue", fontName="Helvetica", fontSize=10, leading=12,
spaceBefore=1, spaceAfter=1,
)
header_style = ParagraphStyle(
"ClueHeader", fontName="Helvetica-Bold", fontSize=12,
leading=16, spaceBefore=0, spaceAfter=4,
)
gap = 18
col_w = (width - gap) / 2
# Across (left column)
across_items: list[Paragraph] = [Paragraph("ACROSS", header_style)]
for cl in crossword.across_clues:
across_items.append(
Paragraph(f"<b>{cl.number}.</b> {xml_escape(cl.clue)}",
clue_style))
Frame(x, y_bottom, col_w, height,
leftPadding=0, rightPadding=4,
topPadding=0, bottomPadding=0).addFromList(across_items, cv)
# Down (right column)
down_items: list[Paragraph] = [Paragraph("DOWN", header_style)]
for cl in crossword.down_clues:
down_items.append(
Paragraph(f"<b>{cl.number}.</b> {xml_escape(cl.clue)}",
clue_style))
Frame(x + col_w + gap, y_bottom, col_w, height,
leftPadding=0, rightPadding=4,
topPadding=0, bottomPadding=0).addFromList(down_items, cv)
# ---------------------------------------------------------------------------
# Public page-level functions
# ---------------------------------------------------------------------------
def render_puzzle_page(cv: Canvas, crossword: Crossword,
puzzle_num: int,
content_x: float, content_top_y: float,
content_w: float, content_h: float,
title: Optional[str] = None) -> None:
"""Draw a puzzle page (empty grid + clues) inside the content rectangle.
*content_top_y* is the top edge of the content area (ReportLab's
y-axis points upward).
"""
# -- title ---------------------------------------------------------------
label = title or f"Puzzle {puzzle_num}"
cv.setFont(_TITLE_FONT, _TITLE_SIZE)
cv.setFillColorRGB(0, 0, 0)
tw = cv.stringWidth(label, _TITLE_FONT, _TITLE_SIZE)
cv.drawString(content_x + (content_w - tw) / 2,
content_top_y - _TITLE_SIZE, label)
title_block = _TITLE_SIZE + 12 # title height + gap
# -- grid ----------------------------------------------------------------
cs = _calc_cell_size(crossword.width, crossword.height,
content_w, content_h - title_block,
_MAX_PUZZLE_CELL, _PUZZLE_GRID_RATIO)
grid_w = crossword.width * cs
grid_x = content_x + (content_w - grid_w) / 2
grid_top = content_top_y - title_block
grid_bottom = _draw_grid(cv, crossword, grid_x, grid_top, cs,
show_letters=False)
# -- clues ---------------------------------------------------------------
clue_gap = 14
clue_area_top = grid_bottom - clue_gap
clue_area_bottom = content_top_y - content_h # = page bottom margin
clue_h = clue_area_top - clue_area_bottom
if clue_h > 30:
_draw_clues(cv, crossword, content_x, clue_area_bottom,
content_w, clue_h)
def render_solution_page(cv: Canvas, crossword: Crossword,
puzzle_num: int,
content_x: float, content_top_y: float,
content_w: float, content_h: float) -> None:
"""Draw a solution page (filled grid, no clues)."""
label = f"Solution to Puzzle {puzzle_num}"
cv.setFont(_TITLE_FONT, _SOLUTION_TITLE_SIZE)
cv.setFillColorRGB(*_SOLUTION_TITLE_CLR)
tw = cv.stringWidth(label, _TITLE_FONT, _SOLUTION_TITLE_SIZE)
cv.drawString(content_x + (content_w - tw) / 2,
content_top_y - _SOLUTION_TITLE_SIZE, label)
title_block = _SOLUTION_TITLE_SIZE + 10
cs = _calc_cell_size(crossword.width, crossword.height,
content_w, content_h - title_block,
_MAX_SOLUTION_CELL, _SOLUTION_GRID_RATIO)
grid_w = crossword.width * cs
grid_x = content_x + (content_w - grid_w) / 2
_draw_grid(cv, crossword, grid_x, content_top_y - title_block, cs,
show_letters=True)
# ---------------------------------------------------------------------------
# Convenience: standalone PDF with basic margins
# ---------------------------------------------------------------------------
def render_puzzles_to_pdf(
puzzles: list[Crossword],
output_path: str,
page_width: float = 8.5 * inch,
page_height: float = 11.0 * inch,
margin: float = 0.75 * inch,
solutions_at_end: bool = False,
) -> None:
"""Render a list of puzzles to a PDF file.
Each puzzle gets a puzzle page followed by its solution page.
If *solutions_at_end* is True, all solution pages are grouped
at the back of the document instead.
This is a convenience wrapper; the KDP formatter provides full
control over layout, front matter, and page numbering.
"""
cv = Canvas(output_path, pagesize=(page_width, page_height))
cx = margin
cy = page_height - margin
cw = page_width - 2 * margin
ch = page_height - 2 * margin
if solutions_at_end:
for i, puzzle in enumerate(puzzles, 1):
render_puzzle_page(cv, puzzle, i, cx, cy, cw, ch)
cv.showPage()
for i, puzzle in enumerate(puzzles, 1):
render_solution_page(cv, puzzle, i, cx, cy, cw, ch)
cv.showPage()
else:
for i, puzzle in enumerate(puzzles, 1):
render_puzzle_page(cv, puzzle, i, cx, cy, cw, ch)
cv.showPage()
render_solution_page(cv, puzzle, i, cx, cy, cw, ch)
cv.showPage()
cv.save()