262 lines
9.8 KiB
Python
262 lines
9.8 KiB
Python
"""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()
|