#!/usr/bin/env python3
"""Parametric pattern generator for a lined, boxed, zippered book pouch.

Takes the book's outside dimensions, emits the cut list, a 1:1 SVG, and a
print-ready PDF tiled across US Letter pages with registration marks and a
1 inch calibration square on every page.

    python3 generate_pattern.py --book-height 7.3 --book-width 4.75 \
        --book-depth 3.0 --name "Breviary" --outdir output

All dimensions are inches. Standard library only.


================================================================================
GEOMETRY, DERIVED AND CHECKED
================================================================================

Names
    W_f, H_f, D    finished interior width, height, depth of the pouch
    SA             seam allowance, same on every seam
    S              side of the square cut out of each bottom corner
    W_cut, H_cut   cut size of one flat panel (four identical panels are cut:
                   2 outer, 2 lining)

The pouch is two flat panels. Their top edges are sewn to the two sides of a
zipper. Their side and bottom edges are sewn to each other. A square of side S
is removed from each bottom corner; the side seam is then matched to the bottom
seam across that opening and stitched, which turns the flat tube into a box.

1. Depth from the corner square
   The notch, opened flat and folded so the side seam lies on the bottom seam,
   presents a raw edge 2S long. Stitching it at SA consumes SA at each end.

       D = 2S - 2*SA          so       S = D/2 + SA

2. Width
   The stitched width across a panel is W_cut - 2*SA. Boxing each corner folds
   D/2 of that width into a side face, one at each end.

       W_f = W_cut - 2*SA - D        so   W_cut = W_f + 2*SA + D

3. Height
   Measured from the bottom of the pouch up to the zipper seam. The panel loses
   SA at the top (zipper seam), SA at the bottom (bottom seam), and S - SA =
   D/2 more at the bottom, which the boxing turns into half the bottom face.

       H_f = H_cut - 2*SA - D/2      so   H_cut = H_f + 2*SA + D/2

Worked check at the target size, SA = 1/2 in:
    W_f = 7.5, H_f = 5.25, D = 3.25
    S     = 3.25/2 + 0.5              = 2.125    (2 1/8 in)
    W_cut = 7.5  + 1.0 + 3.25         = 11.75    (11 3/4 in)
    H_cut = 5.25 + 1.0 + 1.625        = 7.875    (7 7/8 in)

Reverse, from the cut numbers back to the finished pouch:
    D   = 2*2.125 - 2*0.5             = 3.25     matches
    W_f = 11.75 - 1.0 - 3.25          = 7.5      matches
    H_f = 7.875 - 1.0 - 1.625         = 5.25     matches

Independent third check, using a quantity neither formula was solved for. The
stitched bottom seam is W_cut - 2*SA long. After boxing, that same seam is the
centre line of the bottom face plus the two half-depths folded up into the
sides, so it must equal W_f + D:
    W_cut - 2*SA = 11.75 - 1.0        = 10.75
    W_f + D      = 7.5 + 3.25         = 10.75    matches

The same three checks run at every size in validate(), and the test at the
bottom of this file runs them over a sweep of book sizes.
================================================================================
"""

from __future__ import annotations

import argparse
import math
import os
import sys
from dataclasses import dataclass, field

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import minipdf  # noqa: E402

# Zipper lengths actually stocked by the yard-goods trade, in inches.
STOCK_ZIPPER_LENGTHS = [7, 9, 12, 14, 16, 18, 20, 22, 24, 30, 36]

# Colours, as RGB 0..1, shared by the SVG and PDF renderers.
INK = (0.10, 0.10, 0.12)        # cut lines, titles
SEAM = (0.12, 0.35, 0.72)       # stitching lines
WARN = (0.72, 0.18, 0.10)       # things that ruin the piece if ignored
GHOST = (0.55, 0.55, 0.58)      # dimensions, notes, hatching


# ---------------------------------------------------------------------------
# Formatting
# ---------------------------------------------------------------------------

def frac(value: float, denom: int = 16) -> str:
    """Format inches the way a ruler reads them: 7.875 -> '7 7/8'."""
    sign = "-" if value < 0 else ""
    value = abs(value)
    whole = int(value)
    num = int(round((value - whole) * denom))
    if num == denom:
        whole += 1
        num = 0
    if num == 0:
        return f"{sign}{whole}"
    while num % 2 == 0 and denom % 2 == 0:
        num //= 2
        denom //= 2
    if whole == 0:
        return f"{sign}{num}/{denom}"
    return f"{sign}{whole} {num}/{denom}"


def inch(value: float) -> str:
    return f'{frac(value)} in'


def dec(value: float) -> str:
    """Decimal inches, for numbers the user measured rather than cut."""
    return f"{value:g} in"


# ---------------------------------------------------------------------------
# The pattern
# ---------------------------------------------------------------------------

@dataclass
class Pouch:
    """Every number the tutorial and the drawings need, derived from a book."""

    book_height: float
    book_width: float
    book_depth: float
    seam_allowance: float = 0.5
    ease_length: float = 0.20     # along the book's height, the pouch width
    ease_opening: float = 0.50    # along the book's width, the pouch height
    ease_depth: float = 0.25      # around the book's thickness
    name: str = "Book Pouch"

    # Derived, filled in by __post_init__.
    finished_w: float = field(init=False)
    finished_h: float = field(init=False)
    finished_d: float = field(init=False)
    corner_square: float = field(init=False)
    panel_w: float = field(init=False)
    panel_h: float = field(init=False)
    turning_gap: float = field(init=False)
    zipper_length: int = field(init=False)

    def __post_init__(self) -> None:
        # The book lies flat in the pouch: its height runs across the pouch's
        # width, its width becomes the pouch's height, its thickness the depth.
        self.finished_w = self.book_height + self.ease_length
        self.finished_h = self.book_width + self.ease_opening
        self.finished_d = self.book_depth + self.ease_depth

        sa = self.seam_allowance
        self.corner_square = self.finished_d / 2.0 + sa
        self.panel_w = self.finished_w + 2 * sa + self.finished_d
        self.panel_h = self.finished_h + 2 * sa + self.finished_d / 2.0

        # Big enough to drag the outer pouch through, but it has to fit on the
        # bottom seam between the two corner squares with room to sew. Rounded
        # down to 1/4 in so it never eats into that clearance.
        room = self.panel_w - 2 * self.corner_square - 1.5
        gap = min(max(4.0, 0.6 * self.finished_w), room)
        self.turning_gap = math.floor(gap * 4) / 4.0

        # The zipper must overhang both side seams so the pull can park clear
        # of the needle. Buy the next stocked length at or above panel + 2 in.
        wanted = self.panel_w + 2.0
        self.zipper_length = next(
            (z for z in STOCK_ZIPPER_LENGTHS if z >= wanted),
            STOCK_ZIPPER_LENGTHS[-1],
        )

    # --- checks --------------------------------------------------------

    def validate(self) -> list[str]:
        """Re-derive the finished pouch from the cut numbers. Returns notes."""
        sa = self.seam_allowance
        notes = []
        tol = 1e-9

        d_check = 2 * self.corner_square - 2 * sa
        w_check = self.panel_w - 2 * sa - self.finished_d
        h_check = self.panel_h - 2 * sa - self.finished_d / 2.0
        seam_check = self.panel_w - 2 * sa          # stitched bottom seam
        seam_expect = self.finished_w + self.finished_d

        for label, got, want in (
            ("depth", d_check, self.finished_d),
            ("width", w_check, self.finished_w),
            ("height", h_check, self.finished_h),
            ("bottom seam", seam_check, seam_expect),
        ):
            if abs(got - want) > tol:
                raise AssertionError(
                    f"geometry inconsistent: {label} re-derives to {got}, "
                    f"expected {want}"
                )

        if self.corner_square * 2 >= self.panel_w:
            raise ValueError(
                "corner squares meet in the middle: the pouch is deeper than "
                "it is wide. Reduce depth or increase width."
            )
        if self.corner_square >= self.panel_h - sa:
            raise ValueError(
                "corner square reaches the zipper seam. Reduce depth or "
                "increase height."
            )
        clear = self.panel_w - 2 * self.corner_square - self.turning_gap
        if clear < 1.0:
            raise ValueError(
                "turning gap runs into the corner squares. Reduce the gap."
            )
        if self.turning_gap < 2.5:
            raise ValueError(
                f"the bottom seam only leaves a {inch(self.turning_gap)} "
                "turning gap. Anything under 2 1/2 in will not pass a duck "
                "canvas pouch. Make the pouch shallower or wider."
            )
        notes.append(
            f"turning gap clears each corner square by "
            f"{inch(clear / 2)} on the bottom seam"
        )
        if self.seam_allowance < 0.25:
            notes.append(
                "WARNING: seam allowance under 1/4 in will pull out of duck "
                "canvas at the boxed corners"
            )
        return notes

    # --- fabric --------------------------------------------------------

    def yardage(self, fabric_width: float = 58.0, shrink: float = 1.08,
                waste: float = 1.20) -> float:
        """Yards to buy of one fabric, allowing for shrinkage and waste."""
        per_row = max(1, int((fabric_width - 2.0) // (self.panel_w + 1.0)))
        rows = math.ceil(2 / per_row)
        length = rows * (self.panel_h + 1.0) * shrink * waste
        return max(0.5, math.ceil(length / 36.0 * 4) / 4.0)

    # --- text output ---------------------------------------------------

    def cut_list(self) -> str:
        sa = self.seam_allowance
        lines = [
            f"{self.name} - boxed zippered pouch",
            "=" * 66,
            "",
            "BOOK",
            f"  height (spine)         {dec(self.book_height)}",
            f"  width (cover)          {dec(self.book_width)}",
            f"  depth (thickness)      {dec(self.book_depth)}",
            "",
            "EASE ADDED",
            f"  around the length      {inch(self.ease_length)}",
            f"  at the opening         {inch(self.ease_opening)}",
            f"  around the thickness   {inch(self.ease_depth)}",
            "",
            "FINISHED POUCH, INSIDE",
            f"  width                  {inch(self.finished_w)}",
            f"  height                 {inch(self.finished_h)}",
            f"  depth                  {inch(self.finished_d)}",
            "",
            "CUT LIST",
            f"  seam allowance         {inch(sa)} on every seam",
            f"  panel                  {inch(self.panel_w)} wide "
            f"x {inch(self.panel_h)} tall",
            "                           cut 2 from outer fabric",
            "                           cut 2 from lining fabric",
            "                           grain runs parallel to the height",
            f"  corner squares         {inch(self.corner_square)} square, "
            f"one at each bottom corner",
            "                           (8 squares total, 2 per panel)",
            f"  turning gap            {inch(self.turning_gap)}, centred on "
            f"the bottom edge",
            "                           of ONE lining panel only",
            "",
            "NOTIONS",
            f"  zipper                 {self.zipper_length} in nylon coil, "
            f"size #4.5 or #5",
            f"                           panel is {inch(self.panel_w)} wide, "
            f"so the zipper",
            f"                           overhangs by "
            f"{inch((self.zipper_length - self.panel_w) / 2)} at each end",
            "  thread                 all-purpose polyester, 1 spool",
            "",
            "FABRIC TO BUY (58 in wide, prewashed allowance included)",
            f"  outer, 10 oz duck      {self.yardage():.2f} yd",
            f"  lining, quilting cotton {self.yardage(44.0):.2f} yd",
            "",
            "ARITHMETIC CHECK",
            f"  D   = 2*S - 2*SA        = 2*{self.corner_square:g} - "
            f"2*{sa:g} = {2 * self.corner_square - 2 * sa:g}",
            f"  W_f = W_cut - 2*SA - D  = {self.panel_w:g} - {2 * sa:g} - "
            f"{self.finished_d:g} = {self.panel_w - 2 * sa - self.finished_d:g}",
            f"  H_f = H_cut - 2*SA - D/2 = {self.panel_h:g} - {2 * sa:g} - "
            f"{self.finished_d / 2:g} = "
            f"{self.panel_h - 2 * sa - self.finished_d / 2:g}",
            f"  bottom seam W_cut-2*SA  = {self.panel_w - 2 * sa:g}, "
            f"and W_f + D = {self.finished_w + self.finished_d:g}",
        ]
        for note in self.validate():
            lines.append(f"  note: {note}")
        return "\n".join(lines)


# ---------------------------------------------------------------------------
# Drawing model
#
# One list of primitives in inches, origin at the top-left of the drawing,
# y increasing downward. The SVG renderer writes them out directly; the PDF
# renderer flips y and slices the drawing across pages.
# ---------------------------------------------------------------------------

class Drawing:
    def __init__(self, width: float, height: float) -> None:
        self.width = width
        self.height = height
        self.items: list[tuple] = []

    def line(self, x1, y1, x2, y2, style="cut"):
        self.items.append(("line", x1, y1, x2, y2, style))

    def rect(self, x, y, w, h, style="cut"):
        self.line(x, y, x + w, y, style)
        self.line(x + w, y, x + w, y + h, style)
        self.line(x + w, y + h, x, y + h, style)
        self.line(x, y + h, x, y, style)

    def text(self, x, y, s, size=9, bold=False, style="ink", align="left",
             rotate=False):
        self.items.append(("text", x, y, s, size, bold, style, align, rotate))

    def arrow(self, x1, y1, x2, y2, style="dim", head=0.09):
        """Double-headed dimension arrow."""
        self.line(x1, y1, x2, y2, style)
        dx, dy = x2 - x1, y2 - y1
        length = math.hypot(dx, dy) or 1.0
        ux, uy = dx / length, dy / length
        px, py = -uy, ux
        for (bx, by, sign) in ((x1, y1, 1), (x2, y2, -1)):
            tx, ty = bx + sign * ux * head * 2, by + sign * uy * head * 2
            self.line(bx, by, tx + px * head, ty + py * head, style)
            self.line(bx, by, tx - px * head, ty - py * head, style)

    def hatch(self, x, y, w, h, step=0.28, style="ghost"):
        n = int((w + h) / step)
        for i in range(1, n):
            d = i * step
            x1, y1 = x + max(0.0, d - h), y + min(d, h)
            x2, y2 = x + min(d, w), y + max(0.0, d - w)
            if x1 < x + w and y1 > y:
                self.line(x1, y1, x2, y2, style)


STYLE_RGB = {
    "cut": INK, "seam": SEAM, "warn": WARN, "ghost": GHOST, "dim": GHOST,
    "ink": INK, "gap": WARN, "grain": INK,
}
STYLE_WIDTH = {"cut": 1.6, "seam": 1.0, "warn": 1.8, "ghost": 0.5,
               "dim": 0.7, "grain": 1.1, "gap": 2.6}
STYLE_DASH = {"seam": "4 3", "ghost": "", "dim": "", "cut": "", "warn": "6 3",
              "grain": "", "gap": ""}


def build_drawing(p: Pouch) -> Drawing:
    """Lay out the single pattern piece, at 1:1, with everything marked."""
    m = 0.2                        # margin around the piece
    W, H = p.panel_w, p.panel_h
    sa = p.seam_allowance
    S = p.corner_square
    d = Drawing(W + 2 * m, H + 2 * m)
    x0, y0 = m, m                  # top-left of the panel

    def X(u):  # panel-relative to drawing coordinates
        return x0 + u

    def Y(v):
        return y0 + v

    # --- cutting line, notched at both bottom corners ------------------
    d.line(X(0), Y(0), X(W), Y(0))                       # top
    d.line(X(W), Y(0), X(W), Y(H - S))                   # right, to notch
    d.line(X(W), Y(H - S), X(W - S), Y(H - S))           # notch, in
    d.line(X(W - S), Y(H - S), X(W - S), Y(H))           # notch, down
    d.line(X(W - S), Y(H), X(S), Y(H))                   # bottom
    d.line(X(S), Y(H), X(S), Y(H - S))                   # notch, up
    d.line(X(S), Y(H - S), X(0), Y(H - S))               # notch, out
    d.line(X(0), Y(H - S), X(0), Y(0))                   # left

    # --- corner squares, hatched as waste ------------------------------
    for cx in (0.0, W - S):
        d.rect(X(cx), Y(H - S), S, S, "ghost")
        d.hatch(X(cx), Y(H - S), S, S)
        d.text(X(cx + S / 2), Y(H - S + S / 2 - 0.12), "CUT AWAY", 8.5,
               True, "warn", "center")
        d.text(X(cx + S / 2), Y(H - S + S / 2 + 0.10), f"{frac(S)} sq", 8,
               False, "warn", "center")

    # --- stitching lines ------------------------------------------------
    d.line(X(sa), Y(sa), X(W - sa), Y(sa), "seam")               # zipper seam
    d.line(X(sa), Y(sa), X(sa), Y(H - S), "seam")                # left side
    d.line(X(W - sa), Y(sa), X(W - sa), Y(H - S), "seam")        # right side
    d.line(X(S), Y(H - sa), X(W - S), Y(H - sa), "seam")         # bottom

    d.text(X(W / 2), Y(sa - 0.10), f"ZIPPER SEAM  {frac(sa)} in", 8, True,
           "seam", "center")
    d.text(X(sa + 0.10), Y((sa + H - S) / 2), f"SIDE SEAM  {frac(sa)} in", 8,
           True, "seam", "center", rotate=True)

    # --- turning gap, on the lining panel only --------------------------
    gap = p.turning_gap
    gx1, gx2 = X(W / 2 - gap / 2), X(W / 2 + gap / 2)
    d.line(gx1, Y(H - sa), gx2, Y(H - sa), "gap")
    for gx in (gx1, gx2):
        d.line(gx, Y(H - sa - 0.18), gx, Y(H - sa + 0.18), "warn")
    d.text(X(W / 2), Y(H - sa - 0.30), f"LINING PANEL ONLY: LEAVE THIS "
           f"{frac(gap)} in OPEN", 8.5, True, "warn", "center")
    d.text(X(W / 2), Y(H - sa - 0.48), "turning gap - sew the rest of the "
           "bottom seam normally", 7.5, False, "warn", "center")

    # --- dimensions ------------------------------------------------------
    dim_y = min(0.34, sa * 0.65)
    d.arrow(X(0), Y(dim_y), X(W), Y(dim_y))
    d.text(X(W / 2), Y(dim_y - 0.09), f"CUT WIDTH  {frac(W)} in", 8.5, True,
           "dim", "center")
    dim_x = min(0.32, sa * 0.6)
    d.arrow(X(dim_x), Y(0), X(dim_x), Y(H))
    d.text(X(dim_x - 0.09), Y(H / 2), f"CUT HEIGHT  {frac(H)} in", 8.5, True,
           "dim", "center", rotate=True)

    # --- titles ----------------------------------------------------------
    tx, ty = X(0.055 * W + sa), Y(0.16 * H)
    d.text(tx, ty, "PANEL", 20, True, "ink")
    d.text(tx, ty + 0.26, "cut 2 outer  +  cut 2 lining", 11, True, "ink")
    d.text(tx, ty + 0.50, f"{p.name}", 9.5, False, "ghost")
    d.text(tx, ty + 0.68, f"finished inside {frac(p.finished_w)} x "
           f"{frac(p.finished_h)} x {frac(p.finished_d)} in", 9.5, False,
           "ghost")
    d.text(tx, ty + 0.86, f"seam allowance {frac(sa)} in throughout", 9.5,
           False, "ghost")

    # --- calibration square ---------------------------------------------
    cal_x, cal_y = tx, Y(0.42 * H)
    d.rect(cal_x, cal_y, 1.0, 1.0, "warn")
    d.text(cal_x + 0.5, cal_y + 0.55, "1 in", 10, True, "warn", "center")
    d.text(cal_x, cal_y + 1.18, "MEASURE THIS SQUARE BEFORE CUTTING.", 8, True,
           "warn")
    d.text(cal_x, cal_y + 1.34, "If it is not 1 inch, reprint at 100% scale,",
           8, False, "warn")
    d.text(cal_x, cal_y + 1.48, "page scaling off, fit-to-page off.", 8, False,
           "warn")

    # --- grain -----------------------------------------------------------
    gxc = X(W * 0.36)
    d.arrow(gxc, Y(0.40 * H), gxc, Y(0.68 * H), "grain", head=0.12)
    d.text(gxc + 0.12, Y(0.54 * H), "GRAIN - parallel to the selvedge", 9,
           True, "ink", "center", rotate=True)

    # --- notes -----------------------------------------------------------
    nx, ny = X(W * 0.50), Y(0.42 * H)
    notes = [
        ("Cut all four panels the full rectangle.", True),
        ("Mark the corner squares now. Do not cut", False),
        ("them out until the zipper is in.", False),
        ("", False),
        (f"Zipper: {p.zipper_length} in nylon coil, #4.5 or #5.", True),
        (f"It overhangs {frac((p.zipper_length - p.panel_w) / 2)} in past each",
         False),
        ("side. Trim it flush after the side seams.", False),
        ("", False),
        ("Boxed corner: cut the square away, open", True),
        ("the corner, lay the side seam on top of", False),
        (f"the bottom seam, stitch across at {frac(sa)} in.", False),
        (f"That gives {frac(p.finished_d)} in of depth.", False),
    ]
    for i, (line, bold) in enumerate(notes):
        d.text(nx, ny + i * 0.19, line, 8.5, bold, "ink" if bold else "ghost")

    return d


# ---------------------------------------------------------------------------
# SVG
# ---------------------------------------------------------------------------

def render_svg(d: Drawing, path: str) -> None:
    px = 96.0  # CSS pixels per inch, so browsers print this 1:1

    def rgb(style):
        r, g, b = STYLE_RGB[style]
        return f"rgb({r * 255:.0f},{g * 255:.0f},{b * 255:.0f})"

    out = [
        f'<svg xmlns="http://www.w3.org/2000/svg" '
        f'width="{d.width}in" height="{d.height}in" '
        f'viewBox="0 0 {d.width * px:.2f} {d.height * px:.2f}">',
        '<rect width="100%" height="100%" fill="#ffffff"/>',
        '<g stroke-linecap="round">',
    ]
    for item in d.items:
        if item[0] == "line":
            _, x1, y1, x2, y2, style = item
            dash = STYLE_DASH[style]
            dash_attr = (
                f' stroke-dasharray="{" ".join(f"{float(v) * px / 72:.2f}" for v in dash.split())}"'
                if dash else ""
            )
            out.append(
                f'<line x1="{x1 * px:.2f}" y1="{y1 * px:.2f}" '
                f'x2="{x2 * px:.2f}" y2="{y2 * px:.2f}" stroke="{rgb(style)}" '
                f'stroke-width="{STYLE_WIDTH[style] * px / 72:.2f}"{dash_attr}/>'
            )
        else:
            _, x, y, s, size, bold, style, align, rotate = item
            if not s:
                continue
            anchor = {"left": "start", "center": "middle",
                      "right": "end"}[align]
            transform = (f' transform="rotate(-90 {x * px:.2f} {y * px:.2f})"'
                         if rotate else "")
            out.append(
                f'<text x="{x * px:.2f}" y="{y * px:.2f}" fill="{rgb(style)}" '
                f'font-family="Helvetica, Arial, sans-serif" '
                f'font-size="{size * px / 72:.2f}" '
                f'font-weight="{"700" if bold else "400"}" '
                f'text-anchor="{anchor}"{transform}>'
                f'{s.replace("&", "&amp;").replace("<", "&lt;")}</text>'
            )
    out.append("</g></svg>\n")
    with open(path, "w", encoding="utf-8") as handle:
        handle.write("\n".join(out))


# ---------------------------------------------------------------------------
# Tiled PDF
# ---------------------------------------------------------------------------

PAGE_MARGIN = 0.5      # inches, unprintable border allowance
HEADER = 1.6           # inches reserved at the top of every page


def render_pdf(d: Drawing, p: Pouch, path: str) -> int:
    doc = minipdf.Document(minipdf.LETTER)
    page_w_in = minipdf.LETTER[0] / 72.0
    page_h_in = minipdf.LETTER[1] / 72.0
    tile_w = page_w_in - 2 * PAGE_MARGIN
    tile_h = page_h_in - 2 * PAGE_MARGIN - HEADER
    cols = max(1, math.ceil(round(d.width / tile_w, 6)))
    rows = max(1, math.ceil(round(d.height / tile_h, 6)))
    # Split the drawing evenly rather than filling pages and leaving a sliver
    # on the last one, so every sheet carries a comparable amount of pattern.
    tile_w = d.width / cols
    tile_h = d.height / rows
    total = rows * cols + 1

    for r in range(rows):
        for c in range(cols):
            page = doc.new_page()
            n = r * cols + c + 1
            _pdf_header(page, p, n, total, r + 1, c + 1, rows, cols)
            _pdf_tile(page, d, r, c, tile_w, tile_h, rows, cols)
    _pdf_cutlist_page(doc.new_page(), p, total)
    doc.save(path)
    return total


def _pt(v):  # inches to points
    return v * 72.0


def _pdf_header(page, p: Pouch, n, total, row, col, rows, cols) -> None:
    top = _pt(page.height / 72.0 - PAGE_MARGIN)
    left = _pt(PAGE_MARGIN)
    page.text(left, top - 12, f"{p.name} - boxed zippered pouch", 13, True)
    page.text(left, top - 26,
              f"Panel {frac(p.panel_w)} x {frac(p.panel_h)} in, cut 2 outer "
              f"and 2 lining. Seam allowance {frac(p.seam_allowance)} in.",
              8.5, rgb=GHOST)
    page.text(left, top - 38,
              f"Page {n} of {total}   .   tile row {row} of {rows}, "
              f"column {col} of {cols}   .   print at 100%, no page scaling",
              8.5, rgb=GHOST)

    # Calibration square, top right of the header band.
    size = _pt(1.0)
    cx = _pt(page.width / 72.0 - PAGE_MARGIN) - size
    cy = top - _pt(1.45)
    page.stroke(*WARN)
    page.linewidth(1.4)
    page.dash("")
    page.rect(cx, cy, size, size)
    page.text(cx + size / 2, cy + size / 2 - 3, "1 in", 10, True, WARN,
              align="center")
    page.text(cx - 6, cy + size - 8, "CHECK ME:", 8, True, WARN, align="right")
    page.text(cx - 6, cy + size - 18, "exactly 1 inch,", 8, rgb=WARN,
              align="right")
    page.text(cx - 6, cy + size - 28, "or reprint at 100%.", 8, rgb=WARN,
              align="right")


def _pdf_tile(page, d: Drawing, r, c, tile_w, tile_h, rows, cols) -> None:
    ox = _pt(PAGE_MARGIN)                                   # tile left, points
    oy = _pt(page.height / 72.0 - PAGE_MARGIN - HEADER - tile_h)  # tile bottom

    def X(u):
        return ox + _pt(u - c * tile_w)

    def Y(v):
        return oy + _pt(tile_h) - _pt(v - r * tile_h)

    # Trim box and registration marks come first, unclipped.
    page.stroke(*GHOST)
    page.linewidth(0.6)
    page.dash("3 3")
    page.rect(ox, oy, _pt(tile_w), _pt(tile_h))
    page.dash("")
    page.linewidth(1.0)
    page.stroke(*INK)
    arm = 10.0
    for (mx, my) in ((ox, oy), (ox + _pt(tile_w), oy),
                     (ox, oy + _pt(tile_h)),
                     (ox + _pt(tile_w), oy + _pt(tile_h))):
        page.line(mx - arm, my, mx + arm, my)
        page.line(mx, my - arm, mx, my + arm)
        page.circle(mx, my, 4.0)

    label = f"R{r + 1}C{c + 1}"
    page.text(ox + 4, oy + 5, label, 8, True, GHOST)
    if c + 1 < cols:
        page.text(ox + _pt(tile_w) - 4, oy + _pt(tile_h) / 2,
                  f"joins R{r + 1}C{c + 2}  >", 8, True, GHOST, align="right")
    if c > 0:
        page.text(ox + 4, oy + _pt(tile_h) / 2, f"<  joins R{r + 1}C{c}", 8,
                  True, GHOST)
    if r + 1 < rows:
        page.text(ox + _pt(tile_w) / 2, oy + 5,
                  f"joins R{r + 2}C{c + 1} below", 8, True, GHOST,
                  align="center")

    page.clip(ox, oy, _pt(tile_w), _pt(tile_h))
    for item in d.items:
        if item[0] == "line":
            _, x1, y1, x2, y2, style = item
            page.stroke(*STYLE_RGB[style])
            page.linewidth(STYLE_WIDTH[style])
            page.dash(STYLE_DASH[style])
            page.line(X(x1), Y(y1), X(x2), Y(y2))
        else:
            _, x, y, s, size, bold, style, align, rotate = item
            if not s:
                continue
            page.text(X(x), Y(y), s, size, bold, STYLE_RGB[style], align,
                      rotate)
    page.end_clip()


def _pdf_cutlist_page(page, p: Pouch, total) -> None:
    top = _pt(page.height / 72.0 - PAGE_MARGIN)
    left = _pt(PAGE_MARGIN)
    page.text(left, top - 14, "Cut list and numbers", 15, True)
    page.text(left, top - 28, f"Page {total} of {total}", 8.5, rgb=GHOST)
    y = top - 50
    for line in p.cut_list().splitlines():
        bold = line and not line.startswith(" ") and "=" not in line
        page.text(left, y, line.rstrip() or " ", 9.2, bold,
                  INK if bold else (0.25, 0.25, 0.28))
        y -= 11.4
        if y < _pt(PAGE_MARGIN):
            break


# ---------------------------------------------------------------------------
# Self-test, run with --selftest
# ---------------------------------------------------------------------------

def selftest() -> None:
    cases = [
        (7.3, 4.75, 3.0, 0.5),
        (8.5, 6.0, 2.25, 0.5),
        (4.0, 3.0, 1.0, 0.25),
        (11.0, 8.5, 1.5, 0.5),
        (6.0, 4.0, 2.0, 0.375),
    ]
    for bh, bw, bd, sa in cases:
        p = Pouch(bh, bw, bd, seam_allowance=sa)
        p.validate()  # raises if any of the three identities fails
        # Redundant, spelled out, so a reader can see the check rather than
        # trust validate():
        assert abs((2 * p.corner_square - 2 * sa) - p.finished_d) < 1e-9
        assert abs((p.panel_w - 2 * sa - p.finished_d) - p.finished_w) < 1e-9
        assert abs((p.panel_h - 2 * sa - p.finished_d / 2) - p.finished_h) < 1e-9
        assert abs((p.panel_w - 2 * sa) - (p.finished_w + p.finished_d)) < 1e-9
        assert p.zipper_length >= p.panel_w + 2.0
        print(f"ok  book {bh}x{bw}x{bd} sa {sa}  ->  panel "
              f"{frac(p.panel_w)} x {frac(p.panel_h)}, square "
              f"{frac(p.corner_square)}, zip {p.zipper_length} in")
    print("all geometry checks passed")


# ---------------------------------------------------------------------------

def main(argv=None) -> int:
    ap = argparse.ArgumentParser(
        description="Generate a boxed zippered book pouch pattern.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    ap.add_argument("--book-height", type=float, default=7.3,
                    help="book height, spine top to bottom, inches")
    ap.add_argument("--book-width", type=float, default=4.75,
                    help="book width, spine to fore-edge, inches")
    ap.add_argument("--book-depth", type=float, default=3.0,
                    help="book thickness, inches")
    ap.add_argument("--seam-allowance", type=float, default=0.5)
    ap.add_argument("--ease-length", type=float, default=0.20,
                    help="slack added across the pouch width")
    ap.add_argument("--ease-opening", type=float, default=0.50,
                    help="slack added to the pouch height, at the zipper")
    ap.add_argument("--ease-depth", type=float, default=0.25,
                    help="slack added around the book's thickness")
    ap.add_argument("--name", default="Breviary Pouch")
    ap.add_argument("--outdir", default=os.path.join(
        os.path.dirname(os.path.abspath(__file__)), "output"))
    ap.add_argument("--slug", default=None,
                    help="output filename stem, defaults to the name")
    ap.add_argument("--selftest", action="store_true",
                    help="run the geometry checks and exit")
    args = ap.parse_args(argv)

    if args.selftest:
        selftest()
        return 0

    pouch = Pouch(
        book_height=args.book_height,
        book_width=args.book_width,
        book_depth=args.book_depth,
        seam_allowance=args.seam_allowance,
        ease_length=args.ease_length,
        ease_opening=args.ease_opening,
        ease_depth=args.ease_depth,
        name=args.name,
    )
    pouch.validate()

    os.makedirs(args.outdir, exist_ok=True)
    slug = args.slug or "".join(
        ch.lower() if ch.isalnum() else "-" for ch in args.name
    ).strip("-").replace("--", "-")

    drawing = build_drawing(pouch)
    svg_path = os.path.join(args.outdir, f"{slug}.svg")
    pdf_path = os.path.join(args.outdir, f"{slug}.pdf")
    txt_path = os.path.join(args.outdir, f"{slug}-cutlist.txt")

    render_svg(drawing, svg_path)
    pages = render_pdf(drawing, pouch, pdf_path)
    with open(txt_path, "w", encoding="utf-8") as handle:
        handle.write(pouch.cut_list() + "\n")

    print(pouch.cut_list())
    print()
    print(f"wrote {svg_path}")
    print(f"wrote {pdf_path}  ({pages} pages)")
    print(f"wrote {txt_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
