Files
2026-03-24 19:36:04 +02:00

262 lines
9.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()