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
|