Files
2026-03-24 19:36:04 +02:00

385 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 &copy; {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 &mdash; 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