385 lines
12 KiB
Python
385 lines
12 KiB
Python
"""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"<i>{title}</i>", 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(
|
||
"<b>Across</b> clues are answered by writing letters "
|
||
"from left to right.", style),
|
||
Paragraph(
|
||
"<b>Down</b> 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
|