"""KDP-specific formatting: margins, front matter, page numbering, TOC. Assembles a complete print-ready PDF book that meets Amazon KDP requirements for trim size, margins (including gutter), page count, and includes professional front matter. """ import datetime import logging from typing import Optional 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, PuzzleBook, TrimSize from renderer import render_puzzle_page, render_solution_page logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # KDP constraints # --------------------------------------------------------------------------- KDP_MIN_PAGES = 24 KDP_MAX_PAGES = 828 _OUTSIDE_MARGIN = 0.5 # inches — top, bottom, outside edge _TOP_MARGIN = 0.5 _BOTTOM_MARGIN = 0.6 # slightly more room for the page number _PAGE_NUM_FONT = "Helvetica" _PAGE_NUM_SIZE = 9 # --------------------------------------------------------------------------- # Margin helpers # --------------------------------------------------------------------------- def _gutter_margin(page_count: int) -> float: """KDP minimum inside (gutter) margin in inches, based on page count.""" if page_count <= 150: return 0.375 if page_count <= 300: return 0.5 if page_count <= 500: return 0.625 if page_count <= 700: return 0.75 return 0.875 def _content_rect(trim: TrimSize, abs_page: int, gutter: float) -> tuple[float, float, float, float]: """Return (content_x, content_top_y, content_w, content_h) in points. Odd pages (recto) have the gutter on the *left*; even pages (verso) have the gutter on the *right*. """ pw = trim.width * inch ph = trim.height * inch outside = _OUTSIDE_MARGIN * inch gut = gutter * inch top = _TOP_MARGIN * inch bot = _BOTTOM_MARGIN * inch if abs_page % 2 == 1: # recto — gutter left content_x = gut else: # verso — gutter right content_x = outside content_w = pw - gut - outside content_top_y = ph - top content_h = ph - top - bot return content_x, content_top_y, content_w, content_h # --------------------------------------------------------------------------- # Page-count estimation # --------------------------------------------------------------------------- def _estimate_page_count(puzzle_count: int, solutions_at_end: bool, include_toc: bool, include_instructions: bool) -> int: """Pre-calculate total pages so the gutter margin can be determined.""" front = 2 # title + copyright if include_toc: front += 1 if include_instructions: front += 1 if front % 2 != 0: # pad so puzzles start recto front += 1 content = puzzle_count * 2 # puzzle page + solution page return front + content # --------------------------------------------------------------------------- # Front-matter pages # --------------------------------------------------------------------------- def _draw_title_page(cv: Canvas, title: str, author: str, pw: float, ph: float) -> None: y_title = ph * 0.58 cv.setFont("Helvetica-Bold", 30) cv.setFillColorRGB(0, 0, 0) tw = cv.stringWidth(title, "Helvetica-Bold", 30) cv.drawString((pw - tw) / 2, y_title, title) # Decorative rule rule_w = min(pw * 0.35, 180) cv.setStrokeColorRGB(0.35, 0.35, 0.35) cv.setLineWidth(0.75) rule_y = y_title - 18 cv.line((pw - rule_w) / 2, rule_y, (pw + rule_w) / 2, rule_y) if author: cv.setFont("Helvetica", 16) cv.setFillColorRGB(0.25, 0.25, 0.25) aw = cv.stringWidth(author, "Helvetica", 16) cv.drawString((pw - aw) / 2, rule_y - 28, author) def _draw_copyright_page(cv: Canvas, title: str, author: str, pw: float, ph: float) -> None: year = datetime.date.today().year style = ParagraphStyle( "Copyright", fontName="Helvetica", fontSize=9, leading=13, spaceBefore=3, spaceAfter=3, ) items: list[Paragraph] = [ Paragraph(f"{title}", style), ] if author: items.append(Paragraph(f"Copyright © {year} {author}", style)) items.extend([ Paragraph("All rights reserved.", style), Paragraph( "No part of this publication may be reproduced, distributed, " "or transmitted in any form without prior written permission.", style), Paragraph(f"First published {year}.", style), ]) margin = 0.75 * inch Frame(margin, margin, pw - 2 * margin, ph * 0.25, leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0).addFromList(items, cv) def _draw_instructions_page(cv: Canvas, pw: float, ph: float, solutions_at_end: bool) -> None: margin = 0.75 * inch content_w = pw - 2 * margin cv.setFont("Helvetica-Bold", 20) cv.setFillColorRGB(0, 0, 0) heading = "How to Solve" hw = cv.stringWidth(heading, "Helvetica-Bold", 20) heading_y = ph - margin - 20 cv.drawString((pw - hw) / 2, heading_y, heading) style = ParagraphStyle( "Instructions", fontName="Helvetica", fontSize=11, leading=16, spaceBefore=6, spaceAfter=6, ) if solutions_at_end: sol_note = ("Solutions for every puzzle can be found at the back " "of this book.") else: sol_note = ("The solution for each puzzle appears on the page " "immediately following it.") paras = [ Paragraph( "Each puzzle consists of a grid of white and black squares " "and a set of numbered clues. Solve the clues and write one " "letter per white square.", style), Paragraph( "Across clues are answered by writing letters " "from left to right.", style), Paragraph( "Down clues are answered by writing letters " "from top to bottom.", style), Paragraph( "Where an across answer and a down answer share a square, " "the letter must be the same for both words — these " "intersections are your best starting points!", style), Paragraph(f"{sol_note} Good luck and have fun!", style), ] frame_top = heading_y - 16 Frame(margin, margin, content_w, frame_top - margin, leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0).addFromList(paras, cv) # --------------------------------------------------------------------------- # Table of contents # --------------------------------------------------------------------------- def _draw_toc_page(cv: Canvas, entries: list[tuple[str, int]], pw: float, ph: float) -> None: margin = 0.75 * inch content_w = pw - 2 * margin cv.setFont("Helvetica-Bold", 20) cv.setFillColorRGB(0, 0, 0) heading = "Contents" hw = cv.stringWidth(heading, "Helvetica-Bold", 20) cv.drawString((pw - hw) / 2, ph - margin - 20, heading) y = ph - margin - 54 entry_font = "Helvetica" entry_size = 11 dot_spacing = 4.5 for label, page_num in entries: if y < margin + 20: break cv.setFont(entry_font, entry_size) cv.setFillColorRGB(0, 0, 0) cv.drawString(margin, y, label) pn_text = str(page_num) pn_w = cv.stringWidth(pn_text, entry_font, entry_size) cv.drawRightString(pw - margin, y, pn_text) # Dotted leader label_end = margin + cv.stringWidth(label, entry_font, entry_size) + 8 dots_end = pw - margin - pn_w - 8 cv.setFillColorRGB(0.5, 0.5, 0.5) x = label_end while x < dots_end: cv.drawString(x, y, ".") x += dot_spacing y -= 22 cv.setFillColorRGB(0, 0, 0) # --------------------------------------------------------------------------- # Page number # --------------------------------------------------------------------------- def _draw_page_number(cv: Canvas, display_num: int, pw: float, ph: float) -> None: cv.setFont(_PAGE_NUM_FONT, _PAGE_NUM_SIZE) cv.setFillColorRGB(0.4, 0.4, 0.4) text = str(display_num) tw = cv.stringWidth(text, _PAGE_NUM_FONT, _PAGE_NUM_SIZE) cv.drawString((pw - tw) / 2, 0.32 * inch, text) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def render_kdp_book( book: PuzzleBook, output_path: str, include_toc: bool = True, include_instructions: bool = True, ) -> int: """Render a complete KDP-ready PDF book. Returns the total page count. Raises ValueError if the resulting page count falls outside KDP's allowed range (24–828). """ trim = book.trim_size pw = trim.width * inch ph = trim.height * inch n = len(book.puzzles) if n == 0: raise ValueError("No puzzles to render.") # --- page layout -------------------------------------------------------- total_pages = _estimate_page_count( n, book.solutions_at_end, include_toc, include_instructions) if total_pages < KDP_MIN_PAGES: logger.warning( "Book has %d pages, below KDP minimum of %d. " "Consider adding more puzzles.", total_pages, KDP_MIN_PAGES) if total_pages > KDP_MAX_PAGES: raise ValueError( f"Book has {total_pages} pages, exceeding KDP maximum of " f"{KDP_MAX_PAGES}.") gutter = _gutter_margin(total_pages) logger.info("Book layout: %d pages, trim %s, gutter %.3f\".", total_pages, trim, gutter) # --- TOC entries (display page numbers) --------------------------------- toc_entries: list[tuple[str, int]] = [] if include_toc: if book.solutions_at_end: for i in range(n): toc_entries.append((f"Puzzle {i + 1}", i + 1)) toc_entries.append(("Solutions", n + 1)) else: for i in range(n): toc_entries.append((f"Puzzle {i + 1}", i * 2 + 1)) # --- render ------------------------------------------------------------- cv = Canvas(output_path, pagesize=(pw, ph)) abs_page = 0 # -- front matter (no page numbers) -- abs_page += 1 _draw_title_page(cv, book.title, book.author, pw, ph) cv.showPage() abs_page += 1 _draw_copyright_page(cv, book.title, book.author, pw, ph) cv.showPage() if include_toc: abs_page += 1 _draw_toc_page(cv, toc_entries, pw, ph) cv.showPage() if include_instructions: abs_page += 1 _draw_instructions_page(cv, pw, ph, book.solutions_at_end) cv.showPage() # Pad so puzzles start on a recto (odd absolute page) while abs_page % 2 != 0: abs_page += 1 cv.showPage() # -- content pages ------------------------------------------------------- display = 0 if book.solutions_at_end: for i, puzzle in enumerate(book.puzzles, 1): abs_page += 1 display += 1 cx, cy, cw, ch = _content_rect(trim, abs_page, gutter) render_puzzle_page(cv, puzzle, i, cx, cy, cw, ch) _draw_page_number(cv, display, pw, ph) cv.showPage() for i, puzzle in enumerate(book.puzzles, 1): abs_page += 1 display += 1 cx, cy, cw, ch = _content_rect(trim, abs_page, gutter) render_solution_page(cv, puzzle, i, cx, cy, cw, ch) _draw_page_number(cv, display, pw, ph) cv.showPage() else: for i, puzzle in enumerate(book.puzzles, 1): abs_page += 1 display += 1 cx, cy, cw, ch = _content_rect(trim, abs_page, gutter) render_puzzle_page(cv, puzzle, i, cx, cy, cw, ch) _draw_page_number(cv, display, pw, ph) cv.showPage() abs_page += 1 display += 1 cx, cy, cw, ch = _content_rect(trim, abs_page, gutter) render_solution_page(cv, puzzle, i, cx, cy, cw, ch) _draw_page_number(cv, display, pw, ph) cv.showPage() cv.save() logger.info("Wrote %d-page KDP book to %s.", abs_page, output_path) return abs_page