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