"""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"{cl.number}. {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"{cl.number}. {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()