first commit
This commit is contained in:
+261
@@ -0,0 +1,261 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user