Files
cs249r_book/book/cli/commands/layout.py
Vijay Janapa Reddi c848f43990 layout: wire Purpose-overflow check into binder layout
Adds 'binder layout purpose <pdf> --vol1|--vol2' subcommand and folds the
check into the high-level 'binder layout --vol1/--vol2' planner so a Purpose
spilling past its opener page is caught with the other layout scans and fails
the layout pass. Delegates to book/tools/audit/check_purpose_overflow.py.
2026-06-17 09:07:09 -04:00

3742 lines
143 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""
``binder layout`` — PDF page-layout diagnostics.
Subcommands:
check — Scan a built PDF for pages with excessive bottom whitespace
in the main body column, and guess the likely cause (the
block at the top of the next page that probably forced the
break).
The planner is read-only by default: it inspects the PDF, prints a report, and
exits. It does not modify source or PDF. Fixes happen in the QMD source during
an explicit apply/rebuild/re-scan loop.
"""
import argparse
import csv
import json
import math
import re
import shutil
import subprocess
import sys
import unicodedata
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from rich.console import Console
from rich.panel import Panel
from rich.rule import Rule
from rich.table import Table
console = Console()
class _LayoutHelpFormatter(
argparse.RawDescriptionHelpFormatter,
):
"""Argparse formatter that preserves examples."""
def _clickable_source(rel_path: str, repo_root: Path) -> str:
"""Wrap a repo-relative ``source_file`` as an OSC 8 terminal hyperlink.
The visible text stays the relative path, but a cmd+click (Ghostty, iTerm2)
opens the file via its absolute ``file://`` URL. The escape is emitted only
when stdout is an interactive TTY, so a redirected/piped CSV stays plain and
grep-able (the raw relative path, no control characters). Ghostty needs an
absolute, schemed URL to resolve a path — a bare relative path is never
clickable — so the link target is always the resolved absolute path.
"""
if not rel_path or not sys.stdout.isatty():
return rel_path
try:
target = (repo_root / rel_path).resolve().as_uri()
except (OSError, ValueError):
return rel_path
esc = "\033"
return f"{esc}]8;;{target}{esc}\\{rel_path}{esc}]8;;{esc}\\"
# --- geometry constants ---------------------------------------------------
# These are heuristic bands for the book's Tufte-style layout (main column
# left, margin notes right). They are intentionally generous so the tool
# works without per-volume calibration; tune later if needed.
HEADER_FRAC = 0.06 # top 6% of page height treated as running header
FOOTER_FRAC = 0.06 # bottom 6% treated as running footer / page number
MAIN_COL_RIGHT_FRAC = 0.55 # main column ends ~55% across the page
LEFT_MARGIN_RIGHT_FRAC = 0.22
RIGHT_MARGIN_LEFT_FRAC = 0.78
# Margin material alternates sides in the bound PDF: verso pages use the left
# band and recto pages use the right band.
PDF_BODY_WIDTH_PT = 8.0 * 72.0 - (0.875 + 1.75) * 72.0
TABLE_MARKER_RE = re.compile(r"\b(?P<idx>\d{3})\s+(?P<label>tbl-[A-Za-z0-9_-]+)\b")
@dataclass
class PageReport:
sheet: int # 1-indexed PDF sheet number
label: str # printed page number ("59", "iii", etc.)
chapter: str # enclosing top-level outline title
whitespace_pts: float # vertical gap, in PDF points
whitespace_frac: float # gap / usable_text_height
body_bottom_y: float # y of last char in main column
usable_bottom_y: float # y of footer band top
cause: str # best-effort guess for next-page culprit
detail: str = "" # caption / heading / title of the next-page element
size_hint: str = "" # element size: "10 rows", "380pt tall", etc.
fix_hint: str = "" # one-word suggested fix
is_frontmatter: bool = False # roman-numbered page (likely intentional gap)
# ---- actionable fields ----
source_file: str = "" # QMD chapter file, relative to repo root
source_line: int = 0 # 1-indexed line where the detail text appears
section: str = "" # nearest preceding H2/H3 header text
next_page_starts_chapter: bool = False # next sheet is a chapter opener
klass: str = "" # A | B | C | D classification
action: str = "" # recommended action (try-move-up / accept-…)
layout_strategy: str = "" # machine route for auto-layout repair
@dataclass
class MarginFinding:
"""A margin figure/note layout finding."""
sheet: int # 1-indexed PDF sheet number
label: str # printed page number
chapter: str # enclosing chapter title
signal: str # "image", "caption/text", or both
over_pts: float # how far past the footer line (pts); + = below
off_page: bool # content extends beyond the physical page edge
snippet: str = "" # lowest margin-line text (for grepping source)
source_file: str = "" # QMD chapter file, relative to repo root
source_line: int = 0 # 1-indexed line of the matching caption
section: str = "" # nearest preceding H2/H3 header
issue: str = "overflow" # overflow | baseline-crowding | image-text-overlap
severity: str = "error" # error blocks the margin gate; warning is audit-only
related: str = "" # paired text/object snippet for overlap findings
@dataclass
class CollisionFinding:
"""A header/footer collision finding."""
sheet: int # 1-indexed PDF sheet number
label: str # printed page number
chapter: str # enclosing chapter title
band: str # "header" or "footer"
y: float # top coordinate of the colliding text line
snippet: str # colliding line text
@dataclass(frozen=True)
class CalloutBlock:
"""A source callout block and its likely manual page-break controls."""
start_line: int
end_line: int
fence: str
title: str
kind: str
existing_breaks: List[int]
@dataclass
class TableAuditEntry:
"""One source table included in a table-only PDF audit."""
index: int
label: str
caption: str
source_file: str
source_line: int
qmd_path: Path
table_markdown: str
caption_markdown: str
columns: int
rows: int
max_cell_chars: int
has_colwidths: bool
colwidths: str = ""
rendered_sheet: int = 0
rendered_width_pt: float = 0.0
rendered_width_frac: float = 0.0
warnings: List[str] = field(default_factory=list)
def to_report_dict(self) -> Dict[str, Any]:
return {
"index": self.index,
"label": self.label,
"source_file": self.source_file,
"source_line": self.source_line,
"caption": self.caption,
"columns": self.columns,
"rows": self.rows,
"max_cell_chars": self.max_cell_chars,
"has_colwidths": self.has_colwidths,
"colwidths": self.colwidths,
"rendered_sheet": self.rendered_sheet,
"rendered_width_pt": round(self.rendered_width_pt, 1),
"rendered_width_frac": round(self.rendered_width_frac, 3),
"warnings": self.warnings,
}
class LayoutCommand:
"""Diagnose PDF page-break whitespace issues."""
def __init__(self, config_manager, chapter_discovery):
self.config_manager = config_manager
self.chapter_discovery = chapter_discovery
# ------------------------------------------------------------------
# entry
# ------------------------------------------------------------------
def run(self, args: List[str]) -> bool:
parser = argparse.ArgumentParser(
prog="binder layout",
description=(
"PDF auto-layout planner and low-level diagnostics.\n\n"
"Default layout workflow: run `binder build pdf --vol1 "
"--layout` after a render, or `binder layout --vol1 --no-build` "
"to plan from an existing PDF. The high-level planner emits a "
"single strategy-routed queue. Main prose flow is always "
"phase 1; margin calibration is phase 2 and is deferred until "
"the prose flow is stable."
),
epilog=(
"Examples:\n"
" binder layout --vol1\n"
" Build Volume I PDF, then emit the auto-layout plan.\n"
" binder layout --vol1 --no-build --json /tmp/layout-plan.json\n"
" Reuse the existing PDF and write the machine-readable plan.\n"
" binder layout check book/quarto/_build/pdf-vol1/Machine-Learning-Systems-Vol1.pdf --csv\n"
" Low-level main-flow whitespace diagnostics.\n"
" binder layout margins book/quarto/_build/pdf-vol1/Machine-Learning-Systems-Vol1.pdf --csv\n"
" Low-level native margin-geometry diagnostics.\n\n"
"Layout repair contract:\n"
" 1. Prefer the high-level planner (`layout --volN` or build `--layout`).\n"
" 2. Route fixes by `channel` and `strategy`, not by prose wording.\n"
" 3. Clear phase 1 main-flow rows before applying phase 2 margin rows.\n"
" 4. Apply high-confidence ready rows, then rebuild and rescan.\n"
" 5. Use subcommands for debugging one scanner or one chapter."
),
formatter_class=_LayoutHelpFormatter,
add_help=True,
)
vol_group = parser.add_mutually_exclusive_group()
vol_group.add_argument(
"--vol1",
dest="volume",
action="store_const",
const="vol1",
help="Run the high-level auto-layout planner for Volume I.",
)
vol_group.add_argument(
"--vol2",
dest="volume",
action="store_const",
const="vol2",
help="Run the high-level auto-layout planner for Volume II.",
)
parser.add_argument(
"--no-build",
action="store_true",
help="For high-level layout planning, reuse the existing built PDF "
"instead of running `binder build pdf` first.",
)
parser.add_argument(
"--threshold",
type=float,
default=0.25,
help="High-level planner whitespace threshold (default 0.25).",
)
parser.add_argument(
"--limit",
type=int,
default=0,
help="High-level planner page limit (0 = all).",
)
parser.add_argument(
"--include-frontmatter",
action="store_true",
help="Include roman-numeral frontmatter in high-level whitespace "
"planning.",
)
parser.add_argument(
"--json",
dest="plan_json",
default="",
help="Write the high-level layout plan as JSON.",
)
sub = parser.add_subparsers(
dest="subcommand",
title="low-level diagnostic subcommands",
description=(
"Optional. Omit the subcommand when using --vol1/--vol2 for "
"the high-level auto-layout planner."
),
metavar="{collisions,check,margins,overlaps,tables}",
)
collisions = sub.add_parser(
"collisions",
help="Scan PDF for body content invading header / footer bands.",
formatter_class=_LayoutHelpFormatter,
)
collisions.add_argument("pdf", help="Path to PDF file to scan.")
collisions.add_argument(
"--chapter",
type=str,
default="",
help="Only scan outline chapters matching this comma-separated "
"title/slug/substring filter.",
)
collisions.add_argument(
"--csv",
action="store_true",
help="Emit one CSV row per collision: chapter,sheet,label,band,y,"
"snippet.",
)
check = sub.add_parser(
"check",
help="Scan PDF for whitespace gaps.",
description=(
"Low-level main-flow scanner. It flags pages with excessive "
"bottom whitespace and classifies the likely next-page culprit."
),
epilog=(
"Examples:\n"
" binder layout check <pdf> --skip-frontmatter\n"
" binder layout check <pdf> --chapter \"ML Systems\" --csv\n\n"
"CSV columns:\n"
" chapter,sheet,label,gap_pct,pts,culprit,detail,size_hint,\n"
" fix_hint,is_frontmatter,source_file,source_line,section,\n"
" next_page_starts_chapter,class,action,layout_strategy,\n"
" suggested_fix\n\n"
"For full volume layout work, prefer `binder layout --vol1 "
"--no-build` or `binder build pdf --vol1 --layout`."
),
formatter_class=_LayoutHelpFormatter,
)
check.add_argument("pdf", help="Path to PDF file to scan.")
check.add_argument(
"--chapter",
type=str,
default="",
help="Only scan outline chapters matching this comma-separated "
"title/slug/substring filter.",
)
check.add_argument(
"--threshold",
type=float,
default=0.25,
help="Flag pages where bottom whitespace exceeds this "
"fraction of usable text height (default 0.25).",
)
check.add_argument(
"--limit",
type=int,
default=0,
help="Only scan the first N pages (0 = all). For quick iteration.",
)
check.add_argument(
"--only",
type=str,
default="",
help="Comma-separated culprit classes to keep "
"(table,figure,heading,paragraph,callout/box,unknown,"
"end-of-document). Default: all.",
)
check.add_argument(
"--skip-frontmatter",
action="store_true",
help="Hide pages with roman-numeral labels (frontmatter / "
"intentional whitespace).",
)
check.add_argument(
"--csv",
action="store_true",
help="Emit one CSV row per flagged page to stdout instead of "
"the rich report. Includes source coordinates, "
"layout_strategy, and suggested_fix.",
)
margins = sub.add_parser(
"margins",
help="Strict rendered-geometry margin gate: overlaps plus footer/"
"header overflow (exits non-zero on any finding).",
description=(
"Low-level native margin-geometry scanner. It uses rendered "
"PDF boxes to find margin overlap plus footer/header overflow "
"and maps findings back to footnote offsets or .column-margin "
"spacing where possible."
),
epilog=(
"Examples:\n"
" binder layout margins <pdf> --csv\n"
" binder layout margins <pdf> --chapter \"Inference at Scale\"\n\n"
"Native CSV includes layout_strategy and suggested_fix. "
"Strategies `margin-offset` and `margin-vspace` are the "
"deterministic margin repair routes."
),
formatter_class=_LayoutHelpFormatter,
)
margins.add_argument("pdf", help="Path to PDF file to scan.")
margins.add_argument(
"--chapter",
type=str,
default="",
help="Only scan outline chapters matching this comma-separated "
"title/slug/substring filter.",
)
margins.add_argument(
"--tol",
type=float,
default=2.0,
help="Legacy mode only: points of slack below the footer line "
"before margin content counts as overflow (default 2.0).",
)
margins.add_argument(
"--limit",
type=int,
default=0,
help="Only scan the first N pages (0 = all). For quick iteration.",
)
margins.add_argument(
"--include-overlaps",
action="store_true",
help="Legacy mode only: also emit warning-level margin text "
"crowding and image/text overlap candidates.",
)
margins.add_argument(
"--legacy",
action="store_true",
help="Use the older pdfplumber footer-band heuristic with suggested "
"manual nudge recipes instead of the native PyMuPDF geometry gate.",
)
margins.add_argument(
"--csv",
action="store_true",
help="Emit machine-readable findings. Native mode emits rendered "
"geometry rows; legacy mode emits the old suggested_fix schema.",
)
overlaps = sub.add_parser(
"overlaps",
help="True-geometry margin scan (PyMuPDF): element-on-element "
"overlaps and footer/header overflow from rendered bbox "
"geometry, which the anchor-based 'margins' scan is blind to. "
"Exits non-zero on any overlap.",
formatter_class=_LayoutHelpFormatter,
)
overlaps.add_argument("pdf", help="Path to PDF file to scan.")
overlaps.add_argument(
"--chapter",
type=str,
default="",
help="Only scan outline chapters matching this comma-separated "
"title/slug/substring filter.",
)
overlaps.add_argument(
"--limit",
type=int,
default=0,
help="Only scan the first N pages (0 = all). For quick iteration.",
)
overlaps.add_argument(
"--json",
dest="json_out",
type=str,
default="",
help="Write findings as JSON to this path.",
)
overlaps.add_argument(
"--csv",
action="store_true",
help="Emit one CSV row per finding: chapter,sheet,label,issue,side,"
"detail,source_file,source_line,section,snippet,"
"layout_strategy,suggested_fix.",
)
overlaps.add_argument(
"--strict",
action="store_true",
help="Also exit non-zero on overflow findings, not just overlaps. "
"Default treats overflow as reported-but-non-blocking (the "
"known residuals are in-callout figures).",
)
tables = sub.add_parser(
"tables",
help="Render a table-only PDF audit for a volume or chapter.",
formatter_class=_LayoutHelpFormatter,
)
vol_group = tables.add_mutually_exclusive_group(required=True)
vol_group.add_argument(
"--vol1",
dest="volume",
action="store_const",
const="vol1",
help="Audit Volume I tables.",
)
vol_group.add_argument(
"--vol2",
dest="volume",
action="store_const",
const="vol2",
help="Audit Volume II tables.",
)
tables.add_argument(
"--chapter",
type=str,
default="",
help="Only include these comma-separated chapter stems/slugs.",
)
tables.add_argument(
"--limit",
type=int,
default=0,
help="Only include the first N extracted tables (0 = all).",
)
tables.add_argument(
"--min-width",
type=float,
default=0.68,
help="Advisory warning threshold for rendered table width as a "
"fraction of the main text block (default 0.68).",
)
tables.add_argument(
"--fail-under",
type=float,
default=0.0,
help="Exit non-zero when any measured table renders below this "
"width fraction. Default 0 keeps the audit advisory.",
)
tables.add_argument(
"--no-render",
action="store_true",
help="Extract tables and write tables.qmd/json/csv without "
"running Quarto.",
)
tables.add_argument(
"--no-contact-sheets",
action="store_true",
help="Skip PNG contact sheet generation after rendering.",
)
tables.add_argument(
"--contact-cols",
type=int,
default=3,
help="Contact-sheet thumbnail columns (default 3).",
)
tables.add_argument(
"--contact-rows",
type=int,
default=4,
help="Contact-sheet thumbnail rows (default 4).",
)
tables.add_argument(
"--dpi",
type=int,
default=110,
help="PDF rasterization DPI for contact sheets (default 110).",
)
purpose = sub.add_parser(
"purpose",
help="Check that each chapter's Purpose fits on its opener page "
"(no overflow onto the next page).",
formatter_class=_LayoutHelpFormatter,
)
purpose.add_argument("pdf", help="Path to the built volume PDF to scan.")
pvol = purpose.add_mutually_exclusive_group(required=True)
pvol.add_argument(
"--vol1", dest="volume", action="store_const", const="vol1",
help="Check Volume I.",
)
pvol.add_argument(
"--vol2", dest="volume", action="store_const", const="vol2",
help="Check Volume II.",
)
if not args:
parser.print_help()
return False
opts = parser.parse_args(args)
if opts.subcommand is None:
if not opts.volume:
parser.print_help()
return False
return self._auto_layout(
opts.volume,
build_first=not opts.no_build,
threshold=opts.threshold,
limit=opts.limit,
skip_frontmatter=not opts.include_frontmatter,
json_out=opts.plan_json,
)
if opts.subcommand == "collisions":
return self._collisions(
Path(opts.pdf),
chapter_filter=self._parse_chapter_filter(opts.chapter),
csv=opts.csv,
)
if opts.subcommand == "margins":
return self._margins(
Path(opts.pdf),
tol=opts.tol,
limit=opts.limit,
csv=opts.csv,
chapter_filter=self._parse_chapter_filter(opts.chapter),
include_overlaps=opts.include_overlaps,
legacy=opts.legacy,
)
if opts.subcommand == "overlaps":
return self._overlaps(
Path(opts.pdf),
limit=opts.limit,
json_out=opts.json_out,
strict=opts.strict,
chapter_filter=self._parse_chapter_filter(opts.chapter),
csv=opts.csv,
)
if opts.subcommand == "check":
only = (
set(s.strip() for s in opts.only.split(",") if s.strip())
if opts.only
else set()
)
return self._check(
Path(opts.pdf),
opts.threshold,
opts.limit,
only=only,
skip_frontmatter=opts.skip_frontmatter,
csv=opts.csv,
chapter_filter=self._parse_chapter_filter(opts.chapter),
)
if opts.subcommand == "tables":
return self._tables(
opts.volume,
chapter_filter=self._parse_chapter_filter(opts.chapter),
limit=opts.limit,
min_width=opts.min_width,
fail_under=opts.fail_under,
render=not opts.no_render,
contact_sheets=not opts.no_contact_sheets,
contact_cols=max(1, opts.contact_cols),
contact_rows=max(1, opts.contact_rows),
dpi=max(36, opts.dpi),
)
if opts.subcommand == "purpose":
return self._purpose(Path(opts.pdf), opts.volume)
parser.print_help()
return False
# ------------------------------------------------------------------
# purpose-overflow
# ------------------------------------------------------------------
def _purpose(self, pdf_path: Path, volume: str) -> bool:
"""Gate: no chapter's Purpose section may overflow past its opener page.
Delegates to book/tools/audit/check_purpose_overflow.py so the logic is
testable standalone and identical to the preflight runner.
"""
script = self._repo_root() / "book" / "tools" / "audit" / "check_purpose_overflow.py"
if not script.exists():
console.print(f"[red]Purpose check missing:[/red] {script}")
return False
try:
proc = subprocess.run(
["python3", str(script), str(pdf_path), "--vol", volume],
cwd=self._repo_root(),
)
except OSError as exc:
console.print(f"[red]Failed to run Purpose check:[/red] {exc}")
return False
return proc.returncode == 0
# ------------------------------------------------------------------
# tables
# ------------------------------------------------------------------
def _tables(
self,
volume: str,
chapter_filter: Optional[List[str]] = None,
limit: int = 0,
min_width: float = 0.68,
fail_under: float = 0.0,
render: bool = True,
contact_sheets: bool = True,
contact_cols: int = 3,
contact_rows: int = 4,
dpi: int = 110,
) -> bool:
"""Build a table-only PDF audit using the production PDF geometry."""
repo_root = self._repo_root()
out_dir = repo_root / "book" / ".layout" / "tables" / volume
out_dir.mkdir(parents=True, exist_ok=True)
qmd_files = self._table_source_files(volume, chapter_filter)
if not qmd_files:
console.print(
f"[red]No QMD files found for {volume}"
f"{' / ' + ','.join(chapter_filter or []) if chapter_filter else ''}.[/red]"
)
return False
entries: List[TableAuditEntry] = []
for qmd in qmd_files:
entries.extend(self._extract_pipe_tables(qmd))
if limit > 0:
entries = entries[:limit]
for i, entry in enumerate(entries, start=1):
entry.index = i
if not entries:
console.print("[yellow]No captioned pipe tables found.[/yellow]")
return False
qmd_path = out_dir / "tables.qmd"
pdf_path = qmd_path.with_suffix(".pdf")
json_path = out_dir / "tables.json"
csv_path = out_dir / "tables.csv"
render_log = out_dir / "quarto-render.log"
self._write_table_audit_qmd(qmd_path, entries, volume)
console.print(
f"[bold blue]Extracted[/bold blue] {len(entries)} table"
f"{'s' if len(entries) != 1 else ''} from {len(qmd_files)} file"
f"{'s' if len(qmd_files) != 1 else ''}."
)
console.print(f"[dim]Audit source:[/dim] {qmd_path}")
render_ok = True
overfull_count = 0
if render:
render_ok = self._render_table_audit_pdf(qmd_path, render_log)
if not render_ok:
console.print(f"[red]Render failed.[/red] See {render_log}")
return False
overfull_count = self._count_overfull_hboxes(qmd_path.with_suffix(".log"))
if pdf_path.exists():
self._measure_table_pdf(pdf_path, entries, min_width=min_width)
if contact_sheets:
sheets = self._make_table_contact_sheets(
pdf_path,
out_dir / "contact-sheets",
cols=contact_cols,
rows=contact_rows,
dpi=dpi,
)
if sheets:
console.print("[dim]Contact sheets:[/dim]")
for sheet in sheets:
console.print(f" {sheet}")
else:
console.print(f"[red]Expected PDF not found:[/red] {pdf_path}")
return False
else:
console.print("[yellow]Skipped render (--no-render).[/yellow]")
self._write_table_reports(entries, json_path, csv_path)
self._render_table_summary(
entries,
pdf_path if pdf_path.exists() else None,
json_path,
csv_path,
min_width=min_width,
overfull_count=overfull_count,
rendered=render,
)
if fail_under > 0:
offenders = [
e for e in entries
if e.rendered_width_frac and e.rendered_width_frac < fail_under
]
if offenders:
console.print(
f"[red]✗ {len(offenders)} table"
f"{'s' if len(offenders) != 1 else ''} rendered below "
f"--fail-under {fail_under:.2f}[/red]"
)
return False
return render_ok
def _repo_root(self) -> Path:
root = Path(self.config_manager.root_dir).resolve()
if (root / "book" / "quarto").is_dir():
return root
if (root / "quarto").is_dir():
return root.parent
return root
def _table_source_files(
self,
volume: str,
chapter_filter: Optional[List[str]] = None,
) -> List[Path]:
if chapter_filter:
out = []
for raw in chapter_filter:
spec = raw if raw.startswith(("vol1/", "vol2/")) else f"{volume}/{raw}"
qmd = self.chapter_discovery.find_chapter_file(
spec,
allow_fuzzy=True,
)
if qmd is None:
console.print(f"[yellow]Could not resolve chapter:[/yellow] {raw}")
continue
out.append(qmd)
return self._dedupe_paths(out)
config_path = (
self.config_manager.book_dir
/ "config"
/ f"_quarto-pdf-{volume}.yml"
)
ordered: List[Path] = []
if config_path.exists():
try:
import yaml # type: ignore
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
book = raw.get("book", {})
for section in ("chapters", "appendices"):
for entry in book.get(section, []) or []:
path_str = entry.get("file", "") if isinstance(entry, dict) else entry
if not isinstance(path_str, str) or not path_str.endswith(".qmd"):
continue
path = (self.config_manager.book_dir / path_str).resolve()
if path.name in {"index.qmd", "references.qmd"}:
continue
if path.exists():
ordered.append(path)
except Exception as exc:
console.print(
f"[yellow]Could not parse {config_path.name}: {exc}[/yellow]"
)
# Include any source file under the volume that is not listed in the
# PDF config, so audit coverage remains complete during chapter moves.
volume_dir = self.config_manager.book_dir / "contents" / volume
if volume_dir.exists():
ordered.extend(sorted(volume_dir.rglob("*.qmd")))
return self._dedupe_paths(ordered)
@staticmethod
def _dedupe_paths(paths: List[Path]) -> List[Path]:
seen = set()
out = []
for path in paths:
resolved = path.resolve()
if resolved in seen:
continue
seen.add(resolved)
out.append(resolved)
return out
def _extract_pipe_tables(self, qmd_path: Path) -> List[TableAuditEntry]:
try:
lines = qmd_path.read_text(encoding="utf-8").splitlines()
except OSError:
return []
entries: List[TableAuditEntry] = []
rel = self._source_rel(qmd_path)
i = 0
while i < len(lines) - 1:
if not self._is_pipe_table_start(lines, i):
i += 1
continue
start = i
end = i
while end < len(lines) and lines[end].lstrip().startswith("|"):
end += 1
caption_idx = end
while caption_idx < len(lines) and not lines[caption_idx].strip():
caption_idx += 1
if caption_idx >= len(lines):
i = end
continue
caption_line = lines[caption_idx].strip()
if not caption_line.startswith(":") or "#tbl-" not in caption_line:
i = end
continue
table_lines = lines[start:end]
label = self._table_label_from_caption(caption_line)
if not label:
i = caption_idx + 1
continue
columns = self._pipe_column_count(table_lines[1])
rows = max(0, len(table_lines) - 2)
cells = self._pipe_table_cells(table_lines)
max_cell_chars = max((len(c) for c in cells), default=0)
colwidths = self._table_colwidths_from_caption(caption_line)
caption_text = self._caption_text(caption_line)
entries.append(TableAuditEntry(
index=0,
label=label,
caption=caption_text,
source_file=rel,
source_line=caption_idx + 1,
qmd_path=qmd_path,
table_markdown="\n".join(table_lines),
caption_markdown=caption_line,
columns=columns,
rows=rows,
max_cell_chars=max_cell_chars,
has_colwidths=bool(colwidths),
colwidths=colwidths,
warnings=self._static_table_warnings(
columns,
rows,
max_cell_chars,
bool(colwidths),
),
))
i = caption_idx + 1
return entries
def _source_rel(self, qmd_path: Path) -> str:
try:
return str(qmd_path.relative_to(self.config_manager.book_dir))
except ValueError:
try:
return str(qmd_path.relative_to(self._repo_root()))
except ValueError:
return str(qmd_path)
@classmethod
def _is_pipe_table_start(cls, lines: List[str], idx: int) -> bool:
return (
idx + 1 < len(lines)
and lines[idx].lstrip().startswith("|")
and cls._is_pipe_separator(lines[idx + 1])
)
@staticmethod
def _is_pipe_separator(line: str) -> bool:
stripped = line.strip()
if "|" not in stripped or "-" not in stripped:
return False
stripped = stripped.strip("|").strip()
if not stripped:
return False
cells = [cell.strip() for cell in stripped.split("|")]
return all(cell and set(cell) <= {"-", ":", " "} for cell in cells)
@staticmethod
def _pipe_column_count(separator_line: str) -> int:
return len(separator_line.strip().strip("|").split("|"))
@staticmethod
def _pipe_table_cells(table_lines: List[str]) -> List[str]:
cells: List[str] = []
for line in table_lines:
if LayoutCommand._is_pipe_separator(line):
continue
for cell in line.strip().strip("|").split("|"):
text = re.sub(r"\s+", " ", cell).strip()
text = re.sub(r"[*_`$\\{}[\]()<>&#]", "", text)
if text:
cells.append(text)
return cells
@staticmethod
def _table_label_from_caption(caption_line: str) -> str:
match = re.search(r"\{[^}]*#(tbl-[A-Za-z0-9_-]+)[^}]*\}", caption_line)
return match.group(1) if match else ""
@staticmethod
def _table_colwidths_from_caption(caption_line: str) -> str:
match = re.search(r'tbl-colwidths=(["\'])(.*?)\1', caption_line)
return match.group(2) if match else ""
@staticmethod
def _caption_text(caption_line: str) -> str:
text = caption_line.lstrip(":").strip()
text = re.sub(r"\s*\{[^}]*#tbl-[^}]*\}\s*$", "", text).strip()
text = re.sub(r"\s+", " ", text)
return text
@staticmethod
def _static_table_warnings(
columns: int,
rows: int,
max_cell_chars: int,
has_colwidths: bool,
) -> List[str]:
warnings: List[str] = []
if columns >= 4 and rows >= 4 and max_cell_chars >= 36 and not has_colwidths:
warnings.append("wide-dense-table-without-colwidths")
if columns <= 2 and rows <= 5 and has_colwidths:
warnings.append("simple-table-has-colwidths-review-necessity")
if columns >= 5 and not has_colwidths:
warnings.append("many-columns-without-colwidths")
return warnings
def _write_table_audit_qmd(
self,
qmd_path: Path,
entries: List[TableAuditEntry],
volume: str,
) -> None:
theme = (self.config_manager.book_dir / "tex" / f"theme-colors-{volume}.tex").resolve()
header = (self.config_manager.book_dir / "tex" / "header-includes.tex").resolve()
bib = (
self.config_manager.book_dir
/ "contents"
/ "references.bib"
).resolve()
lines = [
"---",
"format:",
" pdf:",
" documentclass: scrbook",
" classoption:",
" - twoside",
" pdf-engine: lualatex",
" keep-tex: true",
" number-sections: false",
" include-in-header:",
f' - file: "{theme}"',
f' - file: "{header}"',
"execute:",
" enabled: false",
"citation: true",
"suppress-bibliography: true",
]
if bib.exists():
lines.extend(["bibliography:", f' - "{bib}"'])
lines.extend([
"---",
"",
"\\pagestyle{empty}",
"",
f"`MLSysBook {volume.upper()} Table Layout Audit`",
"",
])
for entry in entries:
if entry.index > 1:
lines.extend(["\\clearpage", ""])
lines.extend([
f"`{entry.index:03d} {entry.label}`",
"",
f"`{entry.source_file}:{entry.source_line}`",
"",
entry.table_markdown,
"",
entry.caption_markdown,
"",
])
qmd_path.write_text("\n".join(lines), encoding="utf-8")
def _render_table_audit_pdf(self, qmd_path: Path, render_log: Path) -> bool:
if shutil.which("quarto") is None:
console.print("[red]quarto not found on PATH.[/red]")
return False
console.print("[bold blue]Rendering table-only PDF[/bold blue] ...")
proc = subprocess.run(
["quarto", "render", str(qmd_path), "--to", "pdf"],
cwd=str(self._repo_root()),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
render_log.write_text(proc.stdout or "", encoding="utf-8")
if proc.returncode != 0:
tail = "\n".join((proc.stdout or "").splitlines()[-20:])
if tail:
console.print(tail)
return False
return True
@staticmethod
def _count_overfull_hboxes(log_path: Path) -> int:
if not log_path.exists():
return 0
try:
text = log_path.read_text(encoding="utf-8", errors="ignore")
except OSError:
return 0
return len(re.findall(r"Overfull \\hbox", text))
def _measure_table_pdf(
self,
pdf_path: Path,
entries: List[TableAuditEntry],
min_width: float,
) -> None:
try:
import pdfplumber # type: ignore
except ImportError:
console.print("[yellow]pdfplumber unavailable; skipping width metrics.[/yellow]")
return
by_index = {entry.index: entry for entry in entries}
with pdfplumber.open(str(pdf_path)) as pdf:
for page_idx, page in enumerate(pdf.pages, start=1):
text = page.extract_text(x_tolerance=1, y_tolerance=3) or ""
marker = TABLE_MARKER_RE.search(text[:600])
if not marker:
continue
entry = by_index.get(int(marker.group("idx")))
if entry is None:
continue
entry.warnings = [
warning for warning in entry.warnings
if warning == "simple-table-has-colwidths-review-necessity"
]
width = self._page_table_rule_width(page)
if width <= 0:
width = self._page_table_text_width(page)
entry.rendered_sheet = page_idx
entry.rendered_width_pt = width
if width > 0:
entry.rendered_width_frac = width / PDF_BODY_WIDTH_PT
if entry.rendered_width_frac < min_width:
entry.warnings.append(
f"narrow-render:{entry.rendered_width_frac:.2f}"
)
if (
entry.has_colwidths
and (
(entry.columns <= 2 and entry.rows <= 5)
or (entry.columns <= 3 and entry.rows <= 3)
)
):
entry.warnings.append("colwidths-may-be-unnecessary")
@staticmethod
def _page_table_rule_width(page) -> float:
widths = []
for line in list(getattr(page, "lines", []) or []):
x0, x1 = line.get("x0", 0.0), line.get("x1", 0.0)
y0, y1 = line.get("y0", 0.0), line.get("y1", 0.0)
top = line.get("top", 0.0)
width = x1 - x0
if abs(y1 - y0) <= 0.5 and width >= 30 and top >= 95:
widths.append(width)
return max(widths, default=0.0)
@staticmethod
def _page_table_text_width(page) -> float:
chars = [
c for c in getattr(page, "chars", []) or []
if c.get("top", 0) >= 115 and c.get("bottom", 0) <= page.height * 0.94
]
if not chars:
return 0.0
return max(c["x1"] for c in chars) - min(c["x0"] for c in chars)
def _make_table_contact_sheets(
self,
pdf_path: Path,
out_dir: Path,
cols: int = 3,
rows: int = 4,
dpi: int = 110,
) -> List[Path]:
try:
import pypdfium2 as pdfium # type: ignore
from PIL import Image, ImageDraw # type: ignore
except ImportError:
console.print(
"[yellow]pypdfium2/Pillow unavailable; skipping contact sheets.[/yellow]"
)
return []
out_dir.mkdir(parents=True, exist_ok=True)
for old in out_dir.glob("sheet-*.png"):
old.unlink()
pdf = pdfium.PdfDocument(str(pdf_path))
per_sheet = cols * rows
cell_w, cell_h = 360, 470
pad = 16
label_h = 18
sheet_w = cols * cell_w + (cols + 1) * pad
sheet_h = rows * (cell_h + label_h) + (rows + 1) * pad
sheets: List[Path] = []
sheet_img = None
draw = None
for page_idx in range(len(pdf)):
slot = page_idx % per_sheet
if slot == 0:
sheet_img = Image.new("RGB", (sheet_w, sheet_h), "white")
draw = ImageDraw.Draw(sheet_img)
assert sheet_img is not None and draw is not None
page = pdf[page_idx]
bitmap = page.render(scale=dpi / 72.0)
thumb = bitmap.to_pil().convert("RGB")
thumb.thumbnail((cell_w, cell_h), Image.Resampling.LANCZOS)
col = slot % cols
row = slot // cols
x = pad + col * (cell_w + pad)
y = pad + row * (cell_h + label_h + pad)
draw.text((x, y), f"sheet {page_idx + 1}", fill=(80, 80, 80))
sheet_img.paste(thumb, (x, y + label_h))
page.close()
if slot == per_sheet - 1 or page_idx == len(pdf) - 1:
out = out_dir / f"sheet-{len(sheets) + 1:02d}.png"
sheet_img.save(out)
sheets.append(out)
pdf.close()
return sheets
def _write_table_reports(
self,
entries: List[TableAuditEntry],
json_path: Path,
csv_path: Path,
) -> None:
rows = [entry.to_report_dict() for entry in entries]
json_path.write_text(json.dumps(rows, indent=2), encoding="utf-8")
with csv_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(
f,
fieldnames=[
"index",
"label",
"source_file",
"source_line",
"columns",
"rows",
"max_cell_chars",
"has_colwidths",
"colwidths",
"rendered_sheet",
"rendered_width_pt",
"rendered_width_frac",
"warnings",
"caption",
],
)
writer.writeheader()
for row in rows:
row = dict(row)
row["warnings"] = ";".join(row["warnings"])
writer.writerow(row)
def _render_table_summary(
self,
entries: List[TableAuditEntry],
pdf_path: Optional[Path],
json_path: Path,
csv_path: Path,
min_width: float,
overfull_count: int,
rendered: bool,
) -> None:
warned = [e for e in entries if e.warnings]
narrow = [e for e in entries if any(w.startswith("narrow-render") for w in e.warnings)]
explicit = [e for e in entries if e.has_colwidths]
table = Table(show_header=True, header_style="bold")
table.add_column("metric")
table.add_column("value", justify="right")
table.add_row("tables", str(len(entries)))
table.add_row("explicit tbl-colwidths", str(len(explicit)))
table.add_row(f"below min-width {min_width:.2f}", str(len(narrow)))
table.add_row("tables with warnings", str(len(warned)))
if rendered:
table.add_row("overfull hbox warnings", str(overfull_count))
console.print(Panel(table, title="Table Layout Audit", border_style="cyan"))
if pdf_path is not None:
console.print(f"[dim]PDF:[/dim] {pdf_path}")
console.print(f"[dim]JSON:[/dim] {json_path}")
console.print(f"[dim]CSV:[/dim] {csv_path}")
if warned:
console.print("[bold yellow]Top warning candidates:[/bold yellow]")
for entry in warned[:20]:
width = (
f"{entry.rendered_width_frac:.2f}"
if entry.rendered_width_frac
else "?"
)
console.print(
f" {entry.index:03d} {entry.label} "
f"[dim]{entry.source_file}:{entry.source_line} "
f"width={width}[/dim] "
f"{', '.join(entry.warnings)}"
)
if len(warned) > 20:
console.print(f" [dim]...and {len(warned) - 20} more[/dim]")
# ------------------------------------------------------------------
# check
# ------------------------------------------------------------------
def _auto_layout(
self,
volume: str,
build_first: bool = True,
threshold: float = 0.25,
limit: int = 0,
skip_frontmatter: bool = True,
json_out: str = "",
) -> bool:
"""High-level volume layout planner.
This is the command-level contract for future automatic layout:
render or reuse a volume PDF, collect both main-flow whitespace and
margin-geometry findings, then emit one strategy-routed plan. Source
edits stay out of the default path so an apply loop can rebuild and
re-measure after each batch.
"""
if volume not in {"vol1", "vol2"}:
console.print("[red]Use --vol1 or --vol2.[/red]")
return False
volume_label = "Volume I" if volume == "vol1" else "Volume II"
if build_first:
console.print(
f"[bold blue]Auto layout[/bold blue] building {volume_label} PDF first"
)
if not self._build_volume_pdf(volume):
return False
pdf_path = self._default_volume_pdf(volume)
if not pdf_path.exists():
console.print(
f"[red]PDF not found:[/red] {pdf_path}\n"
f"[yellow]Run `./book/binder build pdf --{volume}` or omit "
"`--no-build`.[/yellow]"
)
return False
whitespace_result = self._collect_whitespace_reports(
pdf_path,
threshold=threshold,
limit=limit,
)
margin_result = self._collect_margin_geometry_rows(
pdf_path,
limit=limit,
)
if whitespace_result is None or margin_result is None:
return False
whitespace_rows, scanned = whitespace_result
if skip_frontmatter:
whitespace_rows = [r for r in whitespace_rows if not r.is_frontmatter]
margin_rows, margin_scanned, page_count = margin_result
plan = self._layout_plan_payload(
volume=volume,
pdf_path=pdf_path,
whitespace_rows=whitespace_rows,
margin_rows=margin_rows,
pages_scanned=max(scanned, margin_scanned),
page_count=page_count,
)
self._render_layout_plan(plan)
if json_out:
out = Path(json_out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(plan, indent=2), encoding="utf-8")
console.print(f"[dim]wrote {out}[/dim]")
# Purpose-overflow gate: every chapter Purpose must fit its opener page.
console.print("\n[bold]Purpose-overflow check[/bold]")
purpose_ok = self._purpose(pdf_path, volume)
return (not plan["items"]) and purpose_ok
def _build_volume_pdf(self, volume: str) -> bool:
binder = self._repo_root() / "book" / "binder"
if not binder.exists():
binder = self._repo_root() / "binder"
cmd = [str(binder), "build", "pdf", f"--{volume}"]
console.print(f"[blue]💻 Command: {' '.join(cmd)}[/blue]")
try:
proc = subprocess.run(cmd, cwd=self._repo_root())
except OSError as exc:
console.print(f"[red]Failed to run build:[/red] {exc}")
return False
return proc.returncode == 0
def _default_volume_pdf(self, volume: str) -> Path:
try:
from cli.commands._pdf_checks import default_pdf_path
except ImportError:
from book.cli.commands._pdf_checks import default_pdf_path
return default_pdf_path(self.config_manager.book_dir, volume)
def _layout_plan_payload(
self,
volume: str,
pdf_path: Path,
whitespace_rows: List[PageReport],
margin_rows: List[Dict[str, Any]],
pages_scanned: int,
page_count: int,
) -> Dict[str, Any]:
main_items: List[Dict[str, Any]] = []
for r in whitespace_rows:
strategy = r.layout_strategy or self._layout_strategy(r)
confidence = self._layout_strategy_confidence(strategy, r)
automatable = self._layout_strategy_automatable(strategy, r)
item = {
"phase": "1-main-flow",
"channel": "main-flow",
"strategy": strategy,
"confidence": confidence,
"automatable": automatable,
"deferred": False,
"ready": automatable and confidence == "high",
"chapter": r.chapter,
"sheet": r.sheet,
"label": r.label,
"gap_pct": int(r.whitespace_frac * 100),
"pts": int(r.whitespace_pts),
"culprit": r.cause,
"detail": r.detail,
"source_file": r.source_file,
"source_line": r.source_line,
"section": r.section,
"suggested_fix": self._suggest_whitespace_fix(r),
}
main_items.append(item)
active_main_flow = any(
item["channel"] == "main-flow"
and not str(item["strategy"]).startswith("accept-")
for item in main_items
)
margin_items: List[Dict[str, Any]] = []
for row in margin_rows:
f = row["finding"]
strategy = self._margin_geometry_strategy(row)
confidence = self._margin_strategy_confidence(strategy, row)
automatable = strategy in {"margin-offset", "margin-vspace"}
deferred = active_main_flow
margin_items.append({
"phase": "2-margin-calibration",
"channel": "margin-geometry",
"strategy": strategy,
"confidence": confidence,
"automatable": automatable,
"deferred": deferred,
"ready": automatable and confidence == "high" and not deferred,
"chapter": row["chapter"],
"sheet": f.page,
"label": row["label"],
"issue": f.issue,
"side": f.side,
"detail": f.detail,
"source_file": row["source_file"],
"source_line": row["source_line"],
"section": row["section"],
"snippet": f.snippet,
"suggested_fix": self._suggest_margin_geometry_fix(row),
})
items = main_items + margin_items
items.sort(key=lambda x: (
x["phase"],
x["chapter"],
-int(x["sheet"]),
x["channel"],
))
next_phase = "clean"
if active_main_flow:
next_phase = "1-main-flow"
elif margin_items:
next_phase = "2-margin-calibration"
return {
"volume": volume,
"pdf": str(pdf_path),
"pages_scanned": pages_scanned,
"page_count": page_count,
"workflow": {
"phase_order": [
"1-main-flow",
"2-margin-calibration",
],
"next_phase": next_phase,
"margin_deferred_until_main_flow_clean": active_main_flow,
},
"items": items,
"counts": {
"total": len(items),
"by_phase": dict(Counter(i["phase"] for i in items)),
"by_channel": dict(Counter(i["channel"] for i in items)),
"by_strategy": dict(Counter(i["strategy"] for i in items)),
"automatable": sum(1 for i in items if i["automatable"]),
"ready": sum(1 for i in items if i["ready"]),
"deferred": sum(1 for i in items if i["deferred"]),
},
}
def _render_layout_plan(self, plan: Dict[str, Any]) -> None:
items = plan["items"]
counts = plan["counts"]
if not items:
console.print(Panel(
f"No layout findings across {plan['pages_scanned']} scanned pages.",
title="✅ Auto Layout Plan Clean",
border_style="green",
))
return
strategy_summary = ", ".join(
f"{n}× {name}" for name, n in Counter(
item["strategy"] for item in items
).most_common()
)
channel_summary = ", ".join(
f"{n}× {name}" for name, n in Counter(
item["channel"] for item in items
).most_common()
)
phase_summary = ", ".join(
f"{n}× {name}" for name, n in Counter(
item["phase"] for item in items
).most_common()
)
console.print(Panel(
"\n".join([
f"PDF: {plan['pdf']}",
f"Findings: {counts['total']} "
f"({counts['ready']} ready now, {counts['deferred']} deferred)",
f"Next phase: {plan['workflow']['next_phase']}",
f"Phases: {phase_summary}",
f"Channels: {channel_summary}",
f"Strategies: {strategy_summary}",
]),
title="Auto Layout Plan",
border_style="yellow",
))
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
table.add_column("phase", style="white", width=20)
table.add_column("channel", style="cyan", width=15)
table.add_column("strategy", style="blue", width=24)
table.add_column("sheet", justify="right", width=6)
table.add_column("confidence", style="magenta", width=10)
table.add_column("state", style="yellow", width=9)
table.add_column("source / fix", overflow="fold")
for item in items[:80]:
src = item.get("source_file") or "?"
if item.get("source_line"):
src = f"{src}:{item['source_line']}"
fix = item.get("suggested_fix") or ""
if item["deferred"]:
state = "deferred"
elif item["ready"]:
state = "ready"
else:
state = "review"
table.add_row(
item["phase"],
item["channel"],
item["strategy"],
str(item["sheet"]),
item["confidence"],
state,
f"{src}\n[dim]{fix}[/dim]",
)
console.print(table)
if len(items) > 80:
console.print(f"[dim]...and {len(items) - 80} more findings[/dim]")
@staticmethod
def _layout_strategy_confidence(strategy: str, report: PageReport) -> str:
if strategy == "callout-tcbbreak" and report.source_file and report.source_line:
return "high"
if strategy in {
"table-source-flow",
"figure-source-flow",
"paragraph-source-flow",
"source-flow-callout-adjacent",
}:
return "medium"
if strategy.startswith("accept-"):
return "high"
return "low"
@staticmethod
def _layout_strategy_automatable(strategy: str, report: PageReport) -> bool:
return (
strategy == "callout-tcbbreak"
and bool(report.source_file)
and report.source_line > 0
)
@staticmethod
def _margin_strategy_confidence(
strategy: str,
row: Dict[str, Any],
) -> str:
if strategy in {"margin-offset", "margin-vspace"}:
return "high" if row.get("source_file") and row.get("source_line") else "medium"
if strategy == "margin-stack-solve":
return "medium"
return "low"
def _collect_whitespace_reports(
self,
pdf_path: Path,
threshold: float,
limit: int = 0,
chapter_filter: Optional[List[str]] = None,
) -> Optional[Tuple[List[PageReport], int]]:
if not pdf_path.exists():
console.print(f"[red]PDF not found:[/red] {pdf_path}")
return None
try:
import pdfplumber # type: ignore
except ImportError:
console.print(
"[red]pdfplumber not installed.[/red] "
"Run: [cyan]pip install pdfplumber[/cyan]"
)
return None
chapter_starts, labels = self._load_chapter_map(pdf_path)
source_map = self._build_source_map(pdf_path)
chapter_start_sheets = {start for start, _ in chapter_starts}
repo_root = self._repo_root_for(pdf_path)
flagged: List[PageReport] = []
scanned = 0
with pdfplumber.open(str(pdf_path)) as pdf:
n_pages = len(pdf.pages)
n_scan = n_pages if limit <= 0 else min(limit, n_pages)
for i in range(n_scan):
page = pdf.pages[i]
next_page = pdf.pages[i + 1] if i + 1 < n_pages else None
sheet = i + 1
label = labels[i] if i < len(labels) else str(sheet)
chapter = self._chapter_for(sheet, chapter_starts)
if not self._matches_chapter(chapter, chapter_filter):
continue
report = self._scan_page(page, next_page, sheet, label, chapter)
scanned += 1
if report and report.whitespace_frac >= threshold:
# Enrich with source-locator + class/action fields.
report.next_page_starts_chapter = (
(sheet + 1) in chapter_start_sheets
)
qmd_path = source_map.get(chapter)
if qmd_path is not None:
report.source_file = str(qmd_path)
if repo_root is not None:
abs_path = repo_root / qmd_path
line_num = self._find_source_line(
abs_path, report.detail, allow_short=False
)
report.source_line = line_num
if line_num > 0:
report.section = self._find_section(
abs_path, line_num
)
report.klass, report.action = self._classify(report)
report.layout_strategy = self._layout_strategy(report)
flagged.append(report)
return flagged, scanned
def _check(
self,
pdf_path: Path,
threshold: float,
limit: int,
only: Optional[set] = None,
skip_frontmatter: bool = False,
csv: bool = False,
chapter_filter: Optional[List[str]] = None,
) -> bool:
if not csv:
console.print(
f"[bold blue]Scanning[/bold blue] {pdf_path.name} "
f"[dim](threshold={int(threshold*100)}%, "
f"main col <{int(MAIN_COL_RIGHT_FRAC*100)}% page width)[/dim]"
)
collected = self._collect_whitespace_reports(
pdf_path,
threshold=threshold,
limit=limit,
chapter_filter=chapter_filter,
)
if collected is None:
return False
flagged, scanned = collected
# Apply filters AFTER collection so the unfiltered counts are
# available for the summary line.
total_flagged = len(flagged)
if skip_frontmatter:
flagged = [r for r in flagged if not r.is_frontmatter]
if only:
flagged = [r for r in flagged if r.cause in only]
if csv:
self._render_csv(flagged)
else:
self._render(
flagged,
scanned,
threshold,
pdf_path,
total_flagged=total_flagged,
)
return True
# ------------------------------------------------------------------
# collisions
# ------------------------------------------------------------------
def _collisions(
self,
pdf_path: Path,
chapter_filter: Optional[List[str]] = None,
csv: bool = False,
) -> bool:
"""Detect body content invading the header / footer band.
The header band (top 6%) is reserved for the running header and
page number; the footer band (bottom 6%) for the page number /
bottom rule. Any body text whose line lands inside either band
is a layout regression — most commonly caused by overly-tight
tcolorbox padding or margin overrides.
Chars sharing a y-baseline within 2pt are clustered as one
logical line, so running-header content (page number + chapter
title on slightly different baselines) is not double-counted.
"""
if not pdf_path.exists():
console.print(f"[red]PDF not found:[/red] {pdf_path}")
return False
try:
import pdfplumber # type: ignore
except ImportError:
console.print(
"[red]pdfplumber not installed.[/red] "
"Run: [cyan]pip install pdfplumber[/cyan]"
)
return False
header_collisions: List[CollisionFinding] = []
footer_collisions: List[CollisionFinding] = []
chapter_starts, labels = self._load_chapter_map(pdf_path)
scanned = 0
with pdfplumber.open(str(pdf_path)) as pdf:
for i, page in enumerate(pdf.pages):
sheet = i + 1
label = labels[i] if i < len(labels) else str(sheet)
chapter = self._chapter_for(sheet, chapter_starts)
if not self._matches_chapter(chapter, chapter_filter):
continue
scanned += 1
ph = page.height
pw = page.width
header_bottom = ph * HEADER_FRAC
footer_top = ph * (1.0 - FOOTER_FRAC)
# Cluster chars within 2pt of same y as one logical line.
lines: Dict[float, list] = {}
for c in page.chars:
if c["x0"] > pw * MAIN_COL_RIGHT_FRAC:
continue
placed = False
for ly in list(lines.keys()):
if abs(c["top"] - ly) < 2.0:
lines[ly].append(c)
placed = True
break
if not placed:
lines[c["top"]] = [c]
ys = sorted(lines.keys())
# Header collision: more than one logical line in header band.
hdr = [y for y in ys if y < header_bottom + 5]
if len(hdr) > 1:
snippet = self._line_text(lines[hdr[1]])
header_collisions.append(CollisionFinding(
sheet=sheet, label=label, chapter=chapter,
band="header", y=hdr[1], snippet=snippet,
))
# Footer collision: more than one logical line in footer band.
ftr = [y for y in ys if y > footer_top - 5]
if len(ftr) > 1:
snippet = self._line_text(lines[ftr[0]])
footer_collisions.append(CollisionFinding(
sheet=sheet, label=label, chapter=chapter,
band="footer", y=ftr[0], snippet=snippet,
))
if csv:
self._render_collisions_csv(header_collisions + footer_collisions)
return True
if not header_collisions and not footer_collisions:
console.print(
Panel(
f"No header or footer collisions across "
f"{scanned} "
f"pages.",
title="✅ Collision check clean",
border_style="green",
)
)
return True
console.print()
console.print(
f"[bold yellow]⚠ Header collisions: "
f"{len(header_collisions)}[/bold yellow]"
)
for item in header_collisions[:20]:
console.print(
f" p.{item.label} sheet {item.sheet} {item.chapter} "
f"y={item.y:.0f}: [dim]{item.snippet}[/dim]"
)
if len(header_collisions) > 20:
console.print(
f" [dim]…and {len(header_collisions) - 20} more[/dim]"
)
console.print(
f"[bold yellow]⚠ Footer collisions: "
f"{len(footer_collisions)}[/bold yellow]"
)
for item in footer_collisions[:20]:
console.print(
f" p.{item.label} sheet {item.sheet} {item.chapter} "
f"y={item.y:.0f}: [dim]{item.snippet}[/dim]"
)
return True
def _render_collisions_csv(
self, findings: List[CollisionFinding]
) -> None:
import csv as _csv
import sys
writer = _csv.writer(sys.stdout)
writer.writerow(["chapter", "sheet", "label", "band", "y", "snippet"])
for f in findings:
writer.writerow([
f.chapter,
f.sheet,
f.label,
f.band,
f"{f.y:.1f}",
f.snippet,
])
@staticmethod
def _line_text(line_chars: list) -> str:
line_chars = sorted(line_chars, key=lambda c: c["x0"])
return "".join(c.get("text", "") for c in line_chars).strip()[:60]
# ------------------------------------------------------------------
# margins — margin figure / note overflow off the page bottom
# ------------------------------------------------------------------
def _margins(
self,
pdf_path: Path,
tol: float = 2.0,
limit: int = 0,
csv: bool = False,
chapter_filter: Optional[List[str]] = None,
include_overlaps: bool = False,
legacy: bool = False,
) -> bool:
"""Strict margin layout gate.
By default this uses the Binder-native PyMuPDF geometry scanner, which
gates both element-on-element margin overlap and footer/header overflow.
``--legacy`` keeps the older pdfplumber footer-band heuristic available
for source nudge recipes.
"""
if not legacy:
return self._overlaps(
pdf_path,
limit=limit,
strict=True,
chapter_filter=chapter_filter,
csv=csv,
)
if not pdf_path.exists():
console.print(f"[red]PDF not found:[/red] {pdf_path}")
return False
try:
import pdfplumber # type: ignore # noqa: F401
except ImportError:
console.print(
"[red]pdfplumber not installed.[/red] "
"Run: [cyan]pip install pdfplumber[/cyan]"
)
return False
console.print(
f"[bold blue]Scanning margins[/bold blue] {pdf_path.name} "
f"[dim](margin col <{int(LEFT_MARGIN_RIGHT_FRAC*100)}% "
f"or >{int(RIGHT_MARGIN_LEFT_FRAC*100)}% width, "
f"overflow = below {int((1-FOOTER_FRAC)*100)}% page height "
f"+ {tol:.0f}pt)[/dim]"
)
findings, scanned = self._collect_margin_findings(
pdf_path,
tol,
limit,
chapter_filter=chapter_filter,
include_overlaps=include_overlaps,
)
if csv:
self._render_margins_csv(findings)
else:
self._render_margins(findings, scanned, pdf_path)
return not any(f.severity == "error" for f in findings)
# ------------------------------------------------------------------
# overlaps (true PyMuPDF geometry)
# ------------------------------------------------------------------
def _collect_margin_geometry_rows(
self,
pdf_path: Path,
limit: int = 0,
chapter_filter: Optional[List[str]] = None,
) -> Optional[Tuple[List[Dict[str, Any]], int, int]]:
if not pdf_path.exists():
console.print(f"[red]PDF not found:[/red] {pdf_path}")
return None
try:
try:
from cli.checks.margin_geometry import scan_pdf
except ImportError:
from book.cli.checks.margin_geometry import scan_pdf
except ImportError:
console.print(
"[red]PyMuPDF not installed.[/red] "
"Run: [cyan]pip install pymupdf[/cyan]"
)
return None
summary = scan_pdf(pdf_path, limit=limit)
chapter_starts, labels = self._load_chapter_map(pdf_path)
source_map = self._build_source_map(pdf_path)
repo_root = self._repo_root_for(pdf_path)
rows: List[Dict[str, Any]] = []
for f in summary.findings:
label = labels[f.page - 1] if f.page - 1 < len(labels) else str(f.page)
chapter = self._chapter_for(f.page, chapter_starts)
if not self._matches_chapter(chapter, chapter_filter):
continue
src_file, src_line, section = self._locate_margin_source(
repo_root, source_map, chapter, [f.snippet] if f.snippet else []
)
rows.append({
"finding": f,
"label": label,
"chapter": chapter,
"source_file": src_file,
"source_line": src_line,
"section": section,
})
return rows, summary.pages_scanned, summary.page_count
def _overlaps(
self,
pdf_path: Path,
limit: int = 0,
json_out: str = "",
strict: bool = False,
chapter_filter: Optional[List[str]] = None,
csv: bool = False,
) -> bool:
"""True-geometry margin scan via PyMuPDF. Detects element-on-element
overlaps (always bugs) and footer/header overflow from rendered bbox
geometry, which the anchor-based ``margins`` scan cannot see.
Returns False (gate) when any overlap is found; with ``--strict`` also
fails on overflow. Overflow alone is reported but non-blocking by
default, since the known residuals are in-callout figures.
"""
collected = self._collect_margin_geometry_rows(
pdf_path,
limit=limit,
chapter_filter=chapter_filter,
)
if collected is None:
return False
rows, pages_scanned, page_count = collected
by_issue = Counter(row["finding"].issue for row in rows)
overlaps = by_issue.get("overlap", 0)
ob = by_issue.get("overflow-bottom", 0)
ot = by_issue.get("overflow-top", 0)
if csv:
self._render_margin_geometry_csv(rows)
else:
self._render_margin_geometry(
pdf_path,
rows,
pages_scanned=pages_scanned,
page_count=page_count,
)
if json_out:
from dataclasses import asdict
payload = []
for row in rows:
item = asdict(row["finding"])
item.update({
"label": row["label"],
"chapter": row["chapter"],
"source_file": row["source_file"],
"source_line": row["source_line"],
"section": row["section"],
})
payload.append(item)
Path(json_out).write_text(json.dumps(payload, indent=2))
if not csv:
console.print(f"[dim]wrote {json_out}[/dim]")
if overlaps:
return False
if strict and rows:
return False
return True
def _render_margin_geometry(
self,
pdf_path: Path,
rows: List[Dict[str, Any]],
pages_scanned: int,
page_count: int,
) -> None:
by_issue = Counter(row["finding"].issue for row in rows)
overlaps = by_issue.get("overlap", 0)
ob = by_issue.get("overflow-bottom", 0)
ot = by_issue.get("overflow-top", 0)
console.print(
f"[bold blue]Margin geometry[/bold blue] {pdf_path.name} "
f"[dim]({pages_scanned} of {page_count} pages, PyMuPDF bbox)[/dim]"
)
if rows:
table = Table(show_header=True, header_style="bold")
table.add_column("page", justify="right")
table.add_column("chapter")
table.add_column("issue")
table.add_column("side")
table.add_column("detail")
table.add_column("strategy")
table.add_column("source", overflow="fold")
table.add_column("fix", overflow="fold")
for row in rows:
f = row["finding"]
colour = "red" if f.issue == "overlap" else "yellow"
src = row["source_file"]
if row["source_line"]:
src += f":{row['source_line']}"
if row["section"]:
src += f" [{row['section']}]"
table.add_row(
f"{row['label']} (sheet {f.page})",
row["chapter"],
f"[{colour}]{f.issue}[/{colour}]",
f.side,
f.detail,
self._margin_geometry_strategy(row),
src or "[dim]?[/dim]",
self._suggest_margin_geometry_fix(row),
)
console.print(table)
console.print(
f"overlaps [red]{overlaps}[/red] "
f"overflow-bottom [yellow]{ob}[/yellow] "
f"overflow-top [yellow]{ot}[/yellow] "
f"[dim](total {len(rows)})[/dim]"
)
def _render_margin_geometry_csv(self, rows: List[Dict[str, Any]]) -> None:
import csv as _csv
repo_root = self._repo_root()
writer = _csv.writer(sys.stdout)
writer.writerow([
"chapter", "sheet", "label", "issue", "side", "detail",
"source_file", "source_line", "section", "snippet",
"layout_strategy", "suggested_fix",
])
for row in rows:
f = row["finding"]
writer.writerow([
row["chapter"],
f.page,
row["label"],
f.issue,
f.side,
f.detail,
_clickable_source(row["source_file"], repo_root),
row["source_line"],
row["section"],
f.snippet,
self._margin_geometry_strategy(row),
self._suggest_margin_geometry_fix(row),
])
def _margin_geometry_strategy(self, row: Dict[str, Any]) -> str:
f = row["finding"]
context = self._source_layout_context(
row.get("source_file") or "",
row.get("source_line") or 0,
)
if context == "footnote":
return "margin-offset"
if context == "column-margin":
return "margin-vspace"
if f.issue == "overlap":
return "margin-stack-solve"
if f.issue in {"overflow-bottom", "overflow-top"}:
return "margin-geometry-review"
return "manual-review"
def _source_layout_context(self, source_file: str, source_line: int) -> str:
if not source_file or source_line <= 0:
return ""
try:
lines = (self._repo_root() / source_file).read_text(
encoding="utf-8"
).splitlines()
except OSError:
return ""
if source_line > len(lines):
return ""
idx = source_line - 1
raw = lines[idx].strip()
if raw.startswith("[^"):
return "footnote"
if ".column-margin" in raw:
return "column-margin"
open_margin_re = re.compile(r"^:{3,}\s*\{[^}]*\.column-margin\b")
close_div_re = re.compile(r"^:{3,}\s*$")
for i in range(idx, -1, -1):
text = lines[i].strip()
if open_margin_re.match(text):
return "column-margin"
if close_div_re.match(text):
return ""
return ""
def _suggest_margin_geometry_fix(self, row: Dict[str, Any]) -> str:
"""Actionable source-level recipe for a native margin-geometry row."""
f = row["finding"]
source = row.get("source_file") or ""
line = row.get("source_line") or 0
source_ref = f"{source}:{line}" if source and line else source
context = self._source_layout_context(source, line)
pts = 0.0
# Native details end with useful magnitudes such as ``= 124pt`` or
# ``45pt below text box``. Keep this loose so future wording changes
# still produce a conservative nudge.
m = re.search(r"(\d+(?:\.\d+)?)pt\s+below", f.detail)
if not m:
m = re.search(r"=\s*(\d+(?:\.\d+)?)pt\b", f.detail)
if m:
pts = float(m.group(1))
mm = math.ceil(pts * 0.35) + 2 if pts > 0 else 0
if f.issue == "overflow-bottom":
offset_hint = self._offset_change_hint(source, line, -mm)
if source_ref and mm:
if offset_hint:
return (
f"apply upward nudge near {source_ref}: "
f"{offset_hint}"
)
if context == "footnote":
return (
f"apply upward nudge near {source_ref}: "
f"try [offset=-{mm}mm] on the footnote/sidenote"
)
if context == "column-margin":
return (
f"apply upward nudge near {source_ref}: "
f"try \\vspace*{{-{mm}mm}} inside .column-margin"
)
return (
f"apply upward nudge near {source_ref}: "
f"try [offset=-{mm}mm] for sidenote/footnote or "
f"\\vspace*{{-{mm}mm}} inside .column-margin"
)
if source_ref:
return (
f"apply upward nudge near {source_ref}: footnote offset "
"or in-block .column-margin \\vspace*"
)
return (
"needs eyes: bottom overflow without source match; inspect "
"nearest margin note/figure and nudge upward"
)
if f.issue == "overflow-top":
offset_hint = self._offset_change_hint(source, line, mm)
if source_ref and mm:
if offset_hint:
return (
f"apply downward nudge near {source_ref}: "
f"{offset_hint}"
)
if context == "footnote":
return (
f"apply downward nudge near {source_ref}: "
f"try [offset=+{mm}mm] on the footnote/sidenote"
)
if context == "column-margin":
return (
f"apply downward nudge near {source_ref}: "
f"try positive \\vspace*{{{mm}mm}} inside .column-margin"
)
return (
f"apply downward nudge near {source_ref}: "
f"try [offset=+{mm}mm] or positive in-block \\vspace*"
)
return "needs eyes: top overflow; move/nudge the nearest margin object down"
if f.issue == "overlap":
if source_ref:
if context == "footnote":
return (
f"resolve overlap near {source_ref}: adjust the "
"footnote [offset=...] before moving the reader anchor"
)
if context == "column-margin":
return (
f"resolve overlap near {source_ref}: adjust in-block "
"\\vspace* before moving the reader anchor"
)
return (
f"resolve overlap near {source_ref}: prefer in-block "
"\\vspace* or footnote [offset=...] before moving the "
"reader anchor"
)
return (
"needs eyes: overlap without source match; inspect same-page "
"margin figure/sidenote stack"
)
return "manual review"
def _offset_change_hint(
self,
source_file: str,
source_line: int,
delta_mm: int,
) -> str:
if not source_file or not source_line or not delta_mm:
return ""
try:
lines = (self._repo_root() / source_file).read_text(
encoding="utf-8"
).splitlines()
except OSError:
return ""
if source_line > len(lines):
return ""
line = lines[source_line - 1]
m = re.search(r"\[offset=([+-]?\d+(?:\.\d+)?)mm\]", line)
if not m:
return ""
old = float(m.group(1))
new = old + delta_mm
old_s = f"{old:g}"
new_s = f"{new:g}"
return f"change [offset={old_s}mm] to [offset={new_s}mm]"
@staticmethod
def _margin_side(pw, x0, x1) -> str:
"""Return the physical margin side containing an object, if any."""
if x1 <= pw * LEFT_MARGIN_RIGHT_FRAC:
return "left"
if x0 >= pw * RIGHT_MARGIN_LEFT_FRAC:
return "right"
return ""
@classmethod
def _margin_graphic_objects(cls, pw, images, rects=None, curves=None):
"""Return substantial image/vector boxes that sit in a margin band."""
objects = list(images or []) + list(rects or []) + list(curves or [])
margin_max_w = pw * 0.25
graphics = []
for obj in objects:
x0 = obj.get("x0", 0)
x1 = obj.get("x1", 0)
top = obj.get("top", 0)
bottom = obj.get("bottom", 0)
width = x1 - x0
height = bottom - top
if not cls._margin_side(pw, x0, x1):
continue
if width <= 0 or height <= 0 or width > margin_max_w:
continue
graphics.append(obj)
return graphics
@classmethod
def _page_overflow(cls, pw, ph, chars, images, tol):
"""Pure geometry: true margin-column chars/images whose bottom runs
below the usable bottom (footer band top + tol). Returns (over_chars,
over_imgs). Coordinates follow pdfplumber: x0 from left, bottom from
page top (increasing downward). Unit-tested with dict fixtures.
A char counts as margin content when it sits in either physical margin
band. This handles bound PDFs where margin notes alternate left/right.
Likewise an image/vector box must be narrow and fully in a margin band,
not a full-text-block figure straddling the body column.
"""
usable_bottom = ph * (1.0 - FOOTER_FRAC) + tol
# Text overflow is bounded to the page: a real margin caption clips at
# the page edge (dips into the footer band, still on-page). Text flung
# far BELOW the page edge (bottom > ph) is figure-internal label text
# mispositioned off-canvas, not a margin caption — exclude it. Images
# (below) stay uncapped: a tall margin figure legitimately runs off.
over_chars = [
line for line in cls._margin_text_lines(pw, chars)
if usable_bottom < line["bottom"] <= ph
]
margin_max_w = pw * 0.25
over_imgs = [
im for im in images
if cls._margin_side(pw, im.get("x0", 0), im.get("x1", 0))
and (im.get("x1", 0) - im.get("x0", 0)) <= margin_max_w
and im["bottom"] > usable_bottom
]
return over_chars, over_imgs
@classmethod
def _margin_text_lines(cls, pw, chars) -> List[dict]:
"""Cluster left/right margin chars into logical text-line boxes."""
raw_lines: Dict[Tuple[str, float], list] = {}
for c in chars:
side = cls._margin_side(pw, c["x0"], c["x1"])
if not side:
continue
for key in raw_lines:
key_side, ly = key
if key_side == side and abs(c["top"] - ly) < 2.0:
raw_lines[key].append(c)
break
else:
raw_lines[(side, c["top"])] = [c]
lines = []
for key in sorted(raw_lines, key=lambda item: (item[0], item[1])):
line_chars = sorted(raw_lines[key], key=lambda c: c["x0"])
text = "".join(ch.get("text", "") for ch in line_chars).strip()
if not text:
continue
lines.append({
"side": key[0],
"x0": min(ch["x0"] for ch in line_chars),
"x1": max(ch["x1"] for ch in line_chars),
"top": min(ch["top"] for ch in line_chars),
"bottom": max(ch["bottom"] for ch in line_chars),
"text": text[:90],
})
return lines
@staticmethod
def _substantial_margin_text(text: str) -> bool:
return len(re.findall(r"[A-Za-z0-9]", text or "")) >= 6
@classmethod
def _margin_baseline_crowding(
cls,
pw,
chars,
min_gap: float = 3.0,
min_deficit: float = 1.0,
) -> List[Tuple[dict, dict, float]]:
"""Return adjacent margin text lines whose baselines are implausibly close.
This intentionally uses baseline/top distance instead of bbox
intersection. Normal sidenote leading can make adjacent line boxes
geometrically overlap; truly bad collisions have baselines only a few
points apart.
"""
lines = cls._margin_text_lines(pw, chars)
findings: List[Tuple[dict, dict, float]] = []
for prev, cur in zip(lines, lines[1:]):
if prev.get("side") != cur.get("side"):
continue
if not (
cls._substantial_margin_text(prev["text"])
and cls._substantial_margin_text(cur["text"])
):
continue
baseline_gap = cur["top"] - prev["top"]
deficit = min_gap - baseline_gap
if 0 < baseline_gap < min_gap and deficit >= min_deficit:
findings.append((prev, cur, deficit))
return findings
@classmethod
def _margin_image_text_overlaps(
cls,
pw,
chars,
images,
min_image_edge: float = 20.0,
) -> List[Tuple[dict, dict, float]]:
"""Return substantial margin text lines intersecting substantial images.
Tiny icons are ignored; those often sit inside callout artwork and are
expected to share a band with labels.
"""
lines = cls._margin_text_lines(pw, chars)
overlaps: List[Tuple[dict, dict, float]] = []
for im in images or []:
im_side = cls._margin_side(pw, im.get("x0", 0), im.get("x1", 0))
if not im_side:
continue
width = im.get("x1", 0) - im.get("x0", 0)
height = im.get("bottom", 0) - im.get("top", 0)
if width < min_image_edge or height < min_image_edge:
continue
for line in lines:
if line.get("side") != im_side:
continue
if not cls._substantial_margin_text(line["text"]):
continue
x_overlap = min(line["x1"], im["x1"]) - max(line["x0"], im["x0"])
y_overlap = min(line["bottom"], im["bottom"]) - max(line["top"], im["top"])
if x_overlap > 0 and y_overlap > 1.0:
overlaps.append((line, im, y_overlap))
return overlaps
def _collect_margin_findings(
self,
pdf_path: Path,
tol: float = 2.0,
limit: int = 0,
chapter_filter: Optional[List[str]] = None,
include_overlaps: bool = False,
) -> Tuple[List["MarginFinding"], int]:
"""Detection core (no console output). Returns (findings, pages_scanned).
Shared by the ``binder layout margins`` gate and the warn-only build
postflight (via the module-level ``scan_margin_overflow`` helper).
"""
import pdfplumber # type: ignore
chapter_starts, labels = self._load_chapter_map(pdf_path)
source_map = self._build_source_map(pdf_path)
# Resolve repo root once for source-line lookups.
repo_root = self._repo_root_for(pdf_path)
findings: List[MarginFinding] = []
scanned = 0
with pdfplumber.open(str(pdf_path)) as pdf:
n_pages = len(pdf.pages)
n_scan = n_pages if limit <= 0 else min(limit, n_pages)
for i in range(n_scan):
page = pdf.pages[i]
sheet = i + 1
pw, ph = page.width, page.height
footer_top = ph * (1.0 - FOOTER_FRAC)
chapter = self._chapter_for(sheet, chapter_starts)
if not self._matches_chapter(chapter, chapter_filter):
continue
scanned += 1
label = labels[i] if i < len(labels) else str(sheet)
margin_text_lines = self._margin_text_lines(pw, page.chars)
line_texts = [line["text"] for line in margin_text_lines]
margin_graphics = self._margin_graphic_objects(
pw, page.images, page.rects, page.curves
)
over_chars, over_imgs = self._page_overflow(
pw, ph, page.chars, margin_graphics, tol
)
if over_chars or over_imgs:
bottoms = (
[c["bottom"] for c in over_chars]
+ [im["bottom"] for im in over_imgs]
)
max_bottom = max(bottoms)
over_pts = max_bottom - footer_top
off_page = max_bottom > ph
sig = []
if over_imgs:
sig.append("image")
if over_chars:
sig.append("caption/text")
signal = "+".join(sig)
snippet = line_texts[-1] if line_texts else ""
src_file, src_line, section = self._locate_margin_source(
repo_root, source_map, chapter, line_texts
)
findings.append(MarginFinding(
sheet=sheet, label=label, chapter=chapter,
signal=signal, over_pts=over_pts, off_page=off_page,
snippet=snippet, source_file=src_file,
source_line=src_line, section=section,
))
if include_overlaps:
crowding = self._margin_baseline_crowding(pw, page.chars)
if crowding:
prev, cur, deficit = max(
crowding, key=lambda item: item[2]
)
overlap_lines = [prev["text"], cur["text"]]
src_file, src_line, section = self._locate_margin_source(
repo_root, source_map, chapter, overlap_lines
)
findings.append(MarginFinding(
sheet=sheet, label=label, chapter=chapter,
signal="caption/text", over_pts=deficit,
off_page=False, snippet=cur["text"],
source_file=src_file, source_line=src_line,
section=section, issue="baseline-crowding",
severity="warning", related=prev["text"],
))
image_overlaps = self._margin_image_text_overlaps(
pw, page.chars, margin_graphics
)
if image_overlaps:
line, im, y_overlap = max(
image_overlaps, key=lambda item: item[2]
)
src_file, src_line, section = self._locate_margin_source(
repo_root, source_map, chapter, [line["text"]]
)
if src_line <= 0:
continue
related = (
f"image {im.get('x0', 0):.0f},"
f"{im.get('top', 0):.0f}-"
f"{im.get('bottom', 0):.0f}"
)
findings.append(MarginFinding(
sheet=sheet, label=label, chapter=chapter,
signal="image+caption/text", over_pts=y_overlap,
off_page=False, snippet=line["text"],
source_file=src_file, source_line=src_line,
section=section, issue="image-text-overlap",
severity="warning", related=related,
))
return findings, scanned
def _locate_margin_source(
self,
repo_root: Optional[Path],
source_map: Dict[str, Path],
chapter: str,
line_texts: List[str],
) -> Tuple[str, int, str]:
qmd = source_map.get(chapter)
if qmd is None:
return "", 0, ""
src_file, src_line, section = str(qmd), 0, ""
if repo_root is not None and line_texts:
abs_path = repo_root / qmd
# Try the longest margin lines first — caption text matches the
# source line more reliably than a short wrapped fragment.
for cand in sorted(line_texts, key=len, reverse=True):
src_line = self._find_source_line(abs_path, cand)
if src_line > 0:
break
if src_line > 0:
section = self._find_section(abs_path, src_line)
return src_file, src_line, section
def _render_margins(
self, findings: List[MarginFinding], scanned: int, pdf_path: Path
) -> None:
if not findings:
console.print(Panel(
f"No margin overflow across {scanned} pages — every margin "
f"figure/note sits on-page.",
title="✅ Margin check clean",
border_style="green",
))
return
errors = [f for f in findings if f.severity == "error"]
warnings = [f for f in findings if f.severity != "error"]
console.print()
if errors:
console.print(
f"[bold red]✗ {len(errors)} blocking margin overflow"
f"{'s' if len(errors) != 1 else ''}[/bold red] "
f"[dim]+ {len(warnings)} warning candidate"
f"{'s' if len(warnings) != 1 else ''}[/dim]"
)
else:
console.print(
f"[bold yellow]⚠ {len(warnings)} margin warning candidate"
f"{'s' if len(warnings) != 1 else ''}[/bold yellow] "
f"[dim](no blocking footer/off-page overflow)[/dim]"
)
table = Table(show_header=True, header_style="bold")
table.add_column("page", justify="right")
table.add_column("chapter")
table.add_column("issue")
table.add_column("sev")
table.add_column("signal")
table.add_column("pts", justify="right")
table.add_column("source", overflow="fold")
for f in sorted(findings, key=lambda r: (r.severity != "error", -r.over_pts)):
over = f"{f.over_pts:.0f}pt"
if f.off_page:
over += "" # off the physical page edge
src = f.source_file
if f.source_line:
src += f":{f.source_line}"
if f.section:
src += f" [{f.section}]"
table.add_row(
f"{f.label} (sheet {f.sheet})",
f.chapter,
f.issue,
f.severity,
f.signal,
over,
src or "[dim]?[/dim]",
)
console.print(table)
console.print(
"[dim]⎋ = past the physical page edge. Fix per figure-margin.md "
"§7: reposition the .column-margin block to a footnote-sparse, "
"mid-page anchor, shorten the caption, or cut it (§0 gate). "
"Overlap/crowding rows are warning-level candidates and require "
"visual confirmation.[/dim]"
)
def _render_margins_csv(self, findings: List[MarginFinding]) -> None:
import csv as _csv
repo_root = self._repo_root()
writer = _csv.writer(sys.stdout)
writer.writerow([
"chapter", "sheet", "label", "issue", "severity", "signal",
"over_pts", "off_page", "source_file", "source_line", "section",
"snippet", "related", "suggested_fix",
])
for f in findings:
writer.writerow([
f.chapter, f.sheet, f.label, f.issue, f.severity, f.signal,
f"{f.over_pts:.1f}", int(f.off_page),
_clickable_source(f.source_file, repo_root),
f.source_line, f.section, f.snippet, f.related,
self._suggest_margin_fix(f),
])
def _resolve_margin_block(
self, f: MarginFinding
) -> Optional[Tuple[int, str]]:
"""Resolve a margin finding to its source ``.column-margin`` block.
The scanner's ``source_line`` is a prose-anchor guess that can sit
hundreds of lines from the real figure (auto-layout.md §2.4), so scan
the chapter for ``.column-margin`` blocks and pick the one nearest the
anchor, disambiguating by the caption snippet when several sit close.
Returns ``(block_line, asset_basename)`` (1-indexed) or ``None`` when no
block is found near the anchor — a "needs eyes" case, usually a body
table or a mis-anchored object.
"""
if not f.source_file or not f.source_line:
return None
try:
text = (self._repo_root() / f.source_file).read_text(
encoding="utf-8"
)
except OSError:
return None
lines = text.splitlines()
want = re.sub(r"[^a-z0-9]", "", f.snippet.lower())
best: Optional[Tuple[Tuple[int, int], int, str]] = None
for i, line in enumerate(lines):
if ".column-margin" not in line:
continue
dist = abs(i + 1 - f.source_line)
if dist > 40:
continue
asset, caption = "", ""
for j in range(i + 1, min(len(lines), i + 6)):
if lines[j].strip() == ":::":
break
m = re.search(r"images/[\w/.-]+/([\w.-]+\.(?:svg|png|jpg))",
lines[j])
if m and not asset:
asset = m.group(1)
stripped = lines[j].strip()
if stripped.startswith("*") and stripped.endswith("*"):
caption = stripped
cap = re.sub(r"[^a-z0-9]", "", caption.lower())
matched = bool(want) and bool(cap) and (
want in cap or cap[-12:] in want
)
key = (0 if matched else 1, dist)
if best is None or key < best[0]:
best = (key, i + 1, asset or "(no asset)")
if best is None:
return None
return (best[1], best[2])
def _suggest_margin_fix(self, f: MarginFinding) -> str:
"""Actionable one-line fix recipe for a margin overflow.
Seeds the nudge as ``ceil(over_pts * 0.45) + 2`` mm — the 0.45
pre-absorbs the ~18% a ``\\vspace*`` loses inside a marginnote
(auto-layout.md §2) — then classifies figure vs footnote and resolves
the target block by asset rather than the scanner's line guess. Returns
an empty string for non-overflow rows.
"""
if not f.over_pts or f.over_pts <= 0:
return ""
mm = math.ceil(f.over_pts * 0.45) + 2
block = self._resolve_margin_block(f)
if block:
line, asset = block
return (f"\\vspace*{{-{mm}mm}} in .column-margin ({asset}) "
f"@ {f.source_file}:{line}")
if "image" in f.signal:
return ("needs eyes: image overflow with no .column-margin nearby "
"(likely a body table or mis-anchored figure)")
return (f"[offset=-{mm}mm] on the footnote near "
f"{f.source_file}:{f.source_line}")
# ------------------------------------------------------------------
# CSV output
# ------------------------------------------------------------------
def _render_csv(self, flagged: List[PageReport]) -> None:
import csv as _csv
repo_root = self._repo_root()
writer = _csv.writer(sys.stdout)
writer.writerow([
"chapter", "sheet", "label", "gap_pct", "pts",
"culprit", "detail", "size_hint", "fix_hint", "is_frontmatter",
"source_file", "source_line", "section",
"next_page_starts_chapter", "class", "action",
"layout_strategy", "suggested_fix",
])
for r in flagged:
strategy = r.layout_strategy or self._layout_strategy(r)
writer.writerow([
r.chapter,
r.sheet,
r.label,
int(r.whitespace_frac * 100),
int(r.whitespace_pts),
r.cause,
r.detail,
r.size_hint,
r.fix_hint,
int(r.is_frontmatter),
_clickable_source(r.source_file, repo_root),
r.source_line,
r.section,
int(r.next_page_starts_chapter),
r.klass,
r.action,
strategy,
self._suggest_whitespace_fix(r),
])
# ------------------------------------------------------------------
# outline + page labels
# ------------------------------------------------------------------
def _load_chapter_map(
self, pdf_path: Path
) -> Tuple[List[Tuple[int, str]], List[str]]:
"""Return (chapter_starts, labels).
chapter_starts: [(start_sheet_1indexed, title)] for top-level outline
entries only. Sorted ascending by start sheet.
labels: list of printed page labels indexed by 0-based page index,
same length as pdf.pages. Falls back to the 1-based sheet number
when PageLabels are missing.
"""
try:
import pypdf # type: ignore
except ImportError:
return [], []
try:
r = pypdf.PdfReader(str(pdf_path))
except Exception:
return [], []
n = len(r.pages)
# Printed page labels (PDF /PageLabels). Pad to len(pages).
try:
labels = list(r.page_labels or [])
except Exception:
labels = []
if len(labels) < n:
labels.extend(str(i + 1) for i in range(len(labels), n))
# Top-level outline entries only — these are chapters in this book.
chapter_starts: List[Tuple[int, str]] = []
try:
outline = r.outline or []
except Exception:
outline = []
for item in outline:
if isinstance(item, list):
continue # skip nested sub-entries
try:
page0 = r.get_destination_page_number(item)
title = getattr(item, "title", None) or "(untitled)"
chapter_starts.append((page0 + 1, title))
except Exception:
continue
chapter_starts.sort(key=lambda x: x[0])
return chapter_starts, labels
def _chapter_for(
self, sheet_1indexed: int, chapter_starts: List[Tuple[int, str]]
) -> str:
"""Return the title of the chapter whose range contains this sheet."""
if not chapter_starts:
return "(no outline)"
current = "Frontmatter"
for start, title in chapter_starts:
if start <= sheet_1indexed:
current = title
else:
break
return current
# ------------------------------------------------------------------
# source map: chapter title → QMD file
# ------------------------------------------------------------------
def _build_source_map(self, pdf_path: Path) -> Dict[str, Path]:
"""Return {chapter_title → QMD path}, scoped to the PDF volume when known.
Vol I and Vol II intentionally share chapter titles such as
"Introduction". Source localization must therefore scan only the
matching ``contents/volN`` tree when the PDF path/name identifies a
volume.
"""
# Find repo root by walking up from pdf_path until we hit a dir
# containing book/quarto/contents.
repo_root = self._repo_root_for(pdf_path)
if repo_root is None:
return {}
contents = repo_root / "book" / "quarto" / "contents"
volume = self._volume_from_pdf_path(pdf_path)
if volume and (contents / volume).is_dir():
vol_dirs = [contents / volume]
else:
vol_dirs = sorted(contents.glob("vol*"))
out: Dict[str, Path] = {}
for vol_dir in vol_dirs:
for chapter_dir in sorted(vol_dir.iterdir()):
if not chapter_dir.is_dir():
continue
qmd = chapter_dir / f"{chapter_dir.name}.qmd"
if not qmd.exists():
continue
# Read first H1 from the file.
try:
with qmd.open() as f:
for line in f:
line = line.rstrip("\n")
if line.startswith("# ") and not line.startswith("##"):
# Strip "{#sec-…}" attribute suffix.
title = line[2:].strip()
hash_idx = title.find("{#")
if hash_idx != -1:
title = title[:hash_idx].rstrip()
out[title] = qmd.relative_to(repo_root)
break
except OSError:
continue
return out
@staticmethod
def _repo_root_for(pdf_path: Path) -> Optional[Path]:
cur = Path(pdf_path).resolve().parent
for _ in range(8):
if (cur / "book" / "quarto" / "contents").is_dir():
return cur
if cur.parent == cur:
break
cur = cur.parent
return None
@staticmethod
def _volume_from_pdf_path(pdf_path: Path) -> Optional[str]:
haystack = " ".join([Path(pdf_path).name, *Path(pdf_path).parts]).lower()
if "vol1" in haystack:
return "vol1"
if "vol2" in haystack:
return "vol2"
return None
@staticmethod
def _parse_chapter_filter(raw: str) -> List[str]:
return [part.strip() for part in (raw or "").split(",") if part.strip()]
@staticmethod
def _chapter_key(text: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", (text or "").lower()).strip()
@classmethod
def _matches_chapter(
cls,
chapter: str,
chapter_filter: Optional[List[str]],
) -> bool:
if not chapter_filter:
return True
chapter_key = cls._chapter_key(chapter)
chapter_slug = chapter_key.replace(" ", "-")
for raw in chapter_filter:
wanted_key = cls._chapter_key(raw)
wanted_slug = wanted_key.replace(" ", "-")
if not wanted_key:
continue
if wanted_key == chapter_key or wanted_slug == chapter_slug:
return True
if wanted_key in chapter_key:
return True
return False
@staticmethod
def _find_source_line(
qmd_path: Path,
detail: str,
allow_short: bool = True,
) -> int:
"""Find a line in the QMD file matching the detail text.
PDF extraction often removes spaces, injects line-wrap hyphens, or
normalizes mathematical glyphs differently from the source. Compare an
alphanumeric-only key so rendered snippets such as
``Ware-house-Scale Com-puter`` still resolve to
``Warehouse-Scale Computer`` in QMD. Returns a 1-indexed line number or
0.
"""
if not detail or not qmd_path.exists():
return 0
raw_needle = LayoutCommand._source_key(detail)
if len(raw_needle) < 12:
return 0 # too short to match reliably
title_key = ""
title_raw = detail.split(":", 1)[0] if ":" in detail else ""
if title_raw:
title_key = LayoutCommand._source_key(re.sub(r"^\s*\d+\s+", "", title_raw))
# Compare leading chars to avoid spurious matches. Rendered sidenotes
# often begin with the visible note number ("15 HIPAA..."), which is
# not present in the QMD definition key, so try a no-leading-digits
# variant as well.
needles = []
had_leading_digits = bool(re.match(r"^\d+", raw_needle))
bases = [raw_needle]
stripped = re.sub(r"^\d+", "", raw_needle)
if stripped and stripped != raw_needle:
bases.append(stripped)
for base in bases:
if allow_short:
lengths = (48, 32, 24, 16, 12)
for n in lengths:
if len(base) >= n:
needles.append(base[:n])
else:
needles.append(base[: min(48, len(base))])
needles = list(dict.fromkeys(needles))
try:
lines = qmd_path.read_text(encoding="utf-8").splitlines()
except OSError:
return 0
if had_leading_digits:
if len(title_key) >= 12:
for i, raw in enumerate(lines, start=1):
if not raw.lstrip().startswith("[^"):
continue
bold_titles = re.findall(r"\*\*(.*?)\*\*", raw)
for bold in bold_titles:
bold_key = LayoutCommand._source_key(bold)
if len(bold_key) >= 12 and (
title_key in bold_key or bold_key in title_key
):
return i
for i, raw in enumerate(lines, start=1):
if not raw.lstrip().startswith("[^"):
continue
haystack = LayoutCommand._source_key(raw)
if any(needle and needle in haystack for needle in needles):
return i
for i, raw in enumerate(lines, start=1):
haystack = LayoutCommand._source_key(raw)
if any(needle and needle in haystack for needle in needles):
return i
# Some captions/footnotes are wrapped in source but extracted as one
# line in the PDF. Search two-line windows and report the first line.
for i in range(len(lines) - 1):
haystack = LayoutCommand._source_key(lines[i] + " " + lines[i + 1])
if any(needle and needle in haystack for needle in needles):
return i + 1
return 0
@staticmethod
def _source_key(text: str) -> str:
"""Normalize source/rendered text for fuzzy line localization."""
text = unicodedata.normalize("NFKC", text)
return re.sub(r"[^0-9a-z]+", "", text.lower())
@staticmethod
def _find_callout_blocks(qmd_path: Path) -> List[CalloutBlock]:
"""Return source callout divs with balanced fence bounds.
Quarto callouts in this repo are fenced divs such as
``:::: {#id .callout-notebook title="..."}``. We only need enough
structure for layout repair: block bounds, title/class, and existing
explicit ``\\tcbbreak`` controls.
"""
if not qmd_path.exists():
return []
try:
lines = qmd_path.read_text(encoding="utf-8").splitlines()
except OSError:
return []
open_re = re.compile(r"^(?P<fence>:{3,})\s*\{(?P<attrs>[^}]*)\}\s*$")
close_re = re.compile(r"^(?P<fence>:{3,})\s*$")
stack: List[Tuple[str, int, str]] = []
blocks: List[CalloutBlock] = []
for i, line in enumerate(lines, start=1):
opened = open_re.match(line.strip())
if opened:
attrs = opened.group("attrs")
if "callout-" in attrs:
stack.append((opened.group("fence"), i, attrs))
continue
closed = close_re.match(line.strip())
if not closed or not stack:
continue
fence = closed.group("fence")
if fence != stack[-1][0]:
continue
open_fence, start, attrs = stack.pop()
body = lines[start:i]
title_m = re.search(r'title="([^"]+)"', attrs)
title = title_m.group(1) if title_m else ""
kind_m = re.search(r"\.([A-Za-z0-9_-]*callout-[A-Za-z0-9_-]+)", attrs)
kind = kind_m.group(1) if kind_m else "callout"
breaks = [
line_no
for line_no, raw in enumerate(body, start=start + 1)
if r"\tcbbreak" in raw
]
blocks.append(CalloutBlock(
start_line=start,
end_line=i,
fence=open_fence,
title=title,
kind=kind,
existing_breaks=breaks,
))
return blocks
@staticmethod
def _callout_block_for_line(
qmd_path: Path,
line_num: int,
) -> Optional[CalloutBlock]:
if line_num <= 0:
return None
containing = [
block for block in LayoutCommand._find_callout_blocks(qmd_path)
if block.start_line <= line_num <= block.end_line
]
if not containing:
return None
return min(containing, key=lambda b: b.end_line - b.start_line)
@staticmethod
def _semantic_callout_break_line(
qmd_path: Path,
block: CalloutBlock,
target_line: int,
) -> int:
"""Choose the nearest stable semantic boundary before target_line."""
try:
lines = qmd_path.read_text(encoding="utf-8").splitlines()
except OSError:
return target_line
start = max(block.start_line + 1, 1)
end = min(target_line, len(lines))
for line_no in range(end, start - 1, -1):
raw = lines[line_no - 1].strip()
if not raw:
continue
if raw.startswith("```"):
continue
if raw.startswith("**") or raw.startswith("***"):
return line_no
if re.match(r"^[-*]\s+\*\*", raw):
return line_no
if re.match(r"^\d+\.\s+\*\*", raw):
return line_no
if raw.startswith("| **"):
return line_no
return target_line
def _suggest_callout_break_fix(
self,
source_file: str,
source_line: int,
detail: str,
) -> str:
"""Return a rendered-PDF callout split recipe for a gap finding."""
if not source_file or source_line <= 0:
return (
"callout gap: locate the next-page callout source, then insert "
"\\tcbbreak at the first semantic label that appears on the "
"next page"
)
qmd_path = self._repo_root() / source_file
block = self._callout_block_for_line(qmd_path, source_line)
if block is None:
return (
f"callout gap near {source_file}:{source_line}: source line is "
"not inside a callout block; inspect adjacent block movement"
)
candidate = self._semantic_callout_break_line(
qmd_path,
block,
source_line,
)
title_suffix = f' "{block.title}"' if block.title else ""
block_ref = (
f"{block.kind}"
f"{title_suffix}"
f" ({source_file}:{block.start_line}-{block.end_line})"
)
first_content = block.start_line + 1
if candidate <= first_content + 1:
return (
f"callout gap: {block_ref} starts too late for the remaining "
"page; move the whole callout or its lead-in earlier before "
"adding micro-spacing"
)
later_breaks = [line for line in block.existing_breaks if line > candidate]
earlier_breaks = [line for line in block.existing_breaks if line <= candidate]
if later_breaks:
return (
f"callout gap: move existing \\tcbbreak from line "
f"{later_breaks[0]} to before line {candidate} in {block_ref}"
)
if earlier_breaks:
return (
f"callout gap: existing \\tcbbreak at line {earlier_breaks[-1]} "
f"is before the next-page start; add another \\tcbbreak before "
f"line {candidate} in {block_ref}"
)
return (
f"callout gap: insert \\tcbbreak before line {candidate} in "
f"{block_ref}"
)
def _layout_strategy(self, report: PageReport) -> str:
"""Return the auto-layout strategy bucket for a whitespace finding.
The bucket is intentionally coarser than ``suggested_fix``: downstream
automation can route a row to the right repair algorithm before the
exact source edit is chosen.
"""
if report.next_page_starts_chapter:
return "accept-chapter-break"
if report.cause == "end-of-document":
return "accept-end-of-doc"
if report.cause in {"heading", "delayed-opener"}:
return "accept-structural-opener"
if report.cause == "callout/box":
if self._report_source_inside_callout(report):
return "callout-tcbbreak"
if report.source_file and report.source_line:
return "source-flow-callout-adjacent"
return "callout-localize"
if report.cause == "table":
return "table-source-flow"
if report.cause == "figure":
return "figure-source-flow"
if report.cause == "paragraph":
return "paragraph-source-flow"
return "manual-review"
def _report_source_inside_callout(self, report: PageReport) -> bool:
if not report.source_file or report.source_line <= 0:
return False
qmd_path = self._repo_root() / report.source_file
return self._callout_block_for_line(qmd_path, report.source_line) is not None
@staticmethod
def _find_section(qmd_path: Path, line_num: int) -> str:
"""Walk backwards from line_num to find the nearest H2/H3.
Returns the header text without the markdown ## / ### prefix,
with any trailing `{#sec-…}` attribute stripped.
"""
if not qmd_path.exists() or line_num <= 0:
return ""
try:
with qmd_path.open() as f:
lines = f.readlines()
except OSError:
return ""
start = min(line_num, len(lines)) - 1
for i in range(start, -1, -1):
line = lines[i].rstrip("\n")
m = None
if line.startswith("### "):
m = line[4:]
elif line.startswith("## "):
m = line[3:]
if m is not None:
hash_idx = m.find("{#")
if hash_idx != -1:
m = m[:hash_idx]
return m.strip()
return ""
@staticmethod
def _classify(report: "PageReport") -> Tuple[str, str]:
"""Return (class, action) for a flagged page.
Class definitions:
A — Likely movable (callout/table/figure overshoot)
B — Accept or pattern-α split (very long block)
C — Chapter-end natural whitespace (next page starts chapter)
D — Heading orphan-prevention (structural)
"""
if report.next_page_starts_chapter:
return ("C", "accept-chapter-end")
if report.cause == "heading":
return ("D", "accept-orphan")
if report.cause == "delayed-opener":
return ("D", "accept-opener")
if report.cause == "end-of-document":
return ("C", "filter-end-of-doc")
if report.cause in ("table", "figure", "callout/box"):
# Big gaps are likely too large to be movable.
if report.whitespace_frac >= 0.55:
return ("B", "accept-or-split")
return ("A", "try-move-up")
if report.cause == "paragraph":
return ("A", "try-move-up")
return ("?", "manual-review")
# ------------------------------------------------------------------
# per-page analysis
# ------------------------------------------------------------------
def _scan_page(
self, page, next_page, sheet: int, label: str, chapter: str
) -> Optional[PageReport]:
chars = page.chars
if not chars:
return None
page_h = page.height
page_w = page.width
header_y = page_h * HEADER_FRAC # top band
footer_y = page_h * (1.0 - FOOTER_FRAC) # bottom band
main_col_max_x = page_w * MAIN_COL_RIGHT_FRAC
# Main-column chars only: exclude margin notes (right side) and
# header/footer bands.
body_chars = [
c for c in chars
if c["x0"] < main_col_max_x
and c["top"] > header_y
and c["bottom"] < footer_y
]
if not body_chars:
return None
body_bottom = max(c["bottom"] for c in body_chars)
body_top = min(c["top"] for c in body_chars)
usable_height = footer_y - header_y
whitespace_pts = footer_y - body_bottom
# Normalize against usable column height, not full page.
whitespace_frac = whitespace_pts / usable_height if usable_height > 0 else 0.0
if next_page is None:
cause, detail, size_hint = "end-of-document", "", ""
else:
cause, detail, size_hint = self._guess_cause(next_page)
return PageReport(
sheet=sheet,
label=label,
chapter=chapter,
whitespace_pts=whitespace_pts,
whitespace_frac=whitespace_frac,
body_bottom_y=body_bottom,
usable_bottom_y=footer_y,
cause=cause,
detail=detail,
size_hint=size_hint,
fix_hint=self._fix_hint(cause, size_hint),
is_frontmatter=self._is_frontmatter_label(label),
)
@staticmethod
def _is_frontmatter_label(label: str) -> bool:
"""Detect roman-numeral printed page labels (frontmatter)."""
if not label:
return False
s = label.strip().lower()
return bool(s) and all(c in "ivxlcdm" for c in s)
@staticmethod
def _fix_hint(cause: str, size_hint: str) -> str:
"""One-word suggested fix per culprit class."""
if cause == "table":
# Extract row count from size_hint if present.
rows = 0
if "rows" in size_hint:
try:
rows = int(size_hint.split()[0])
except (ValueError, IndexError):
rows = 0
if rows >= 6:
return "longtable?"
if rows > 0:
return "shrink/keep"
return "longtable?"
if cause == "figure":
return "fig-pos?"
if cause == "heading":
return "relax-penalty?"
if cause == "paragraph":
return "rewrite"
if cause == "callout/box":
return "tcbbreak"
if cause == "delayed-opener":
return "opener"
if cause == "end-of-document":
return "ignore"
return "manual"
def _suggest_whitespace_fix(self, report: PageReport) -> str:
"""Map a bottom-whitespace finding to the Zeljko layout device."""
if report.next_page_starts_chapter:
return "accept: structural chapter break"
if report.cause == "end-of-document":
return "accept/filter: end-of-document whitespace"
loc = (
f"{report.source_file}:{report.source_line}"
if report.source_file and report.source_line
else report.source_file
)
where = f" near {loc}" if loc else ""
if report.cause == "table":
return (
"source-flow first: move the lead-in directly above the table"
f"{where}; then retune tbl-colwidths/alignment or split only "
"if the rendered table is still too tall"
)
if report.cause == "figure":
return (
"source-flow first: move lead-in above and interpretation below"
f"{where}; then reduce figure height in small steps if needed"
)
if report.cause == "callout/box":
return self._suggest_callout_break_fix(
report.source_file,
report.source_line,
report.detail,
)
if report.cause == "heading":
return (
"usually accept orphan prevention; if repeated, use a PDF-only "
"\\newpage before the heading rather than micro-spacing"
)
if report.cause == "delayed-opener":
return (
"usually accept: next page is an opener/part page whose design "
"starts below the scan zone"
)
if report.cause == "paragraph":
return (
"source-flow/prose fix: move or lightly rewrite the paragraph"
f"{where} so the previous page can fill naturally"
)
return (
"manual review: inspect the next-page block and choose the "
"smallest rendered-PDF fix"
)
# ------------------------------------------------------------------
# cause guess: look at top of next page
# ------------------------------------------------------------------
def _guess_cause(self, page) -> Tuple[str, str, str]:
"""Best-effort guess at the block that probably forced the break.
Inspects the top region of the next page (after the header band)
and returns (cause, detail, size_hint):
cause — one of: table, figure, callout/box, heading,
paragraph, delayed-opener, unknown
detail — caption / heading / title text (truncated)
size_hint — "10 rows", "380pt tall", etc., where extractable
"""
if page is None:
return ("end-of-document", "", "")
page_h = page.height
page_w = page.width
header_y = page_h * HEADER_FRAC
# "Top of next page" = first ~40% of usable height after header.
zone_bottom = header_y + (page_h - header_y) * 0.40
# 1. Figure? — any image object whose top is in the zone.
images = getattr(page, "images", []) or []
for im in images:
if header_y < im.get("top", 0) < zone_bottom:
im_h = im.get("height", 0) or (
im.get("bottom", 0) - im.get("top", 0)
)
size_hint = f"{int(im_h)}pt tall" if im_h else ""
detail = self._extract_caption(page, prefix_pattern="Figure")
return ("figure", detail, size_hint)
chars = [c for c in page.chars if header_y < c["top"] < zone_bottom]
# 2. Table? — many horizontal lines (rules) clustered in the zone.
# Tables in this book are typed with booktabs-style rules.
lines = getattr(page, "lines", []) or []
rects = getattr(page, "rects", []) or []
horiz_lines_full = [
ln for ln in lines
if header_y < ln.get("top", 0) < zone_bottom
and abs(ln.get("height", 0)) < 1.0
]
# booktabs tables typically have >= 3 horizontal rules.
if len(horiz_lines_full) >= 3:
# Row estimate: count distinct text-line y-positions BETWEEN the
# topmost and bottommost rule of the table. Booktabs uses only
# 3 rules (top / mid / bottom) regardless of row count, so we
# have to count baselines, not rules.
rule_ys = sorted(ln.get("top", 0) for ln in horiz_lines_full)
top_rule_y = rule_ys[0]
bot_rule_y = rule_ys[-1]
# Allow the table to extend somewhat below the zone (tables
# often run further down the page); scan all chars within the
# rule band.
table_chars = [
c for c in page.chars
if top_rule_y - 2 <= c["top"] <= bot_rule_y + 2
]
# Distinct baselines (y rounded to nearest pt).
ys = {round(c["top"], 0) for c in table_chars}
rows_est = max(0, len(ys) - 1) # -1 for header row
size_hint = f"{rows_est} rows" if rows_est else ""
detail = self._extract_caption(page, prefix_pattern="Table")
return ("table", detail, size_hint)
# 3. Callout / box? — a large filled rectangle in the zone.
# Heuristic: a rect taller than ~30pt and at least 60% of main
# column width counts as a box.
for r in rects:
top = r.get("top", 0)
if not (header_y < top < zone_bottom):
continue
height = r.get("height", 0) or (r.get("bottom", 0) - top)
width = r.get("width", 0) or (r.get("x1", 0) - r.get("x0", 0))
if height >= 30 and width >= page_w * 0.45:
# First line of text inside the box = callout title.
title = self._first_line_text(chars, top_y_min=top)
return ("callout/box", title, "")
if not chars:
lower_chars = [c for c in page.chars if c["top"] >= zone_bottom]
first_lower = self._first_line_text(lower_chars)
if first_lower:
return ("delayed-opener", first_lower, "")
return ("unknown", "", "")
# 4. Heading? — first line's font size is notably above median.
sizes = [round(c.get("size", 0), 1) for c in chars if c.get("size")]
if sizes:
sorted_sizes = sorted(sizes)
median = sorted_sizes[len(sorted_sizes) // 2]
# First line: chars sharing the topmost band (within 2pt).
top_y = min(c["top"] for c in chars)
first_line_sizes = [
round(c.get("size", 0), 1)
for c in chars
if abs(c["top"] - top_y) < 2.0 and c.get("size")
]
if first_line_sizes:
first_max = max(first_line_sizes)
if first_max >= median + 1.5:
heading = self._first_line_text(chars)
return ("heading", heading, "")
# 5. Paragraph fallback — return the first ~60 chars so it's
# easy to grep back to the source.
first_text = self._first_line_text(chars)
return ("paragraph", first_text, "")
# ------------------------------------------------------------------
# text extraction helpers
# ------------------------------------------------------------------
def _first_line_text(self, chars, top_y_min: float = 0.0) -> str:
"""Reconstruct the topmost text line from a list of page chars."""
if not chars:
return ""
candidates = [c for c in chars if c["top"] >= top_y_min]
if not candidates:
return ""
top_y = min(c["top"] for c in candidates)
line_chars = [c for c in candidates if abs(c["top"] - top_y) < 2.0]
line_chars.sort(key=lambda c: c["x0"])
text = "".join(c.get("text", "") for c in line_chars).strip()
return self._truncate(text, 70)
def _extract_caption(self, page, prefix_pattern: str) -> str:
"""Find a 'Table X.Y:' or 'Figure X.Y:' caption on a page.
Captions in this book are rendered by Quarto as:
Table N.M: Some caption text
We extract the line beginning with the given prefix and return
the caption text (truncated). Returns "" if not found.
"""
try:
text = page.extract_text() or ""
except Exception:
return ""
for raw in text.splitlines():
line = raw.strip()
if not line.startswith(prefix_pattern):
continue
# Strip "Table N.M:" / "Figure N.M:" prefix. Allow up to 30
# chars because pdfplumber sometimes drops spaces, producing
# e.g. "Table5.3summarizes:" instead of "Table 5.3 summarizes:".
colon = line.find(":")
if colon != -1 and colon < 30:
line = line[colon + 1:].strip()
return self._truncate(line, 70)
return ""
@staticmethod
def _truncate(s: str, n: int) -> str:
s = s.replace("­", "").strip() # strip soft hyphens
if len(s) <= n:
return s
return s[: n - 1].rstrip() + ""
# ------------------------------------------------------------------
# rendering
# ------------------------------------------------------------------
def _render(
self,
flagged: List[PageReport],
scanned: int,
threshold: float,
pdf_path: Path,
total_flagged: int = 0,
) -> None:
if not flagged:
extra = ""
if total_flagged and total_flagged != len(flagged):
extra = (
f" (filtered from {total_flagged} unfiltered flags)"
)
console.print(
Panel(
f"No pages exceeded {int(threshold*100)}% bottom whitespace "
f"across {scanned} pages{extra}.",
title="✅ Layout check clean",
border_style="green",
)
)
return
# Group by chapter; sort each group DESCENDING by sheet so the
# natural read order matches the fix order (back-to-front within
# a chapter, so earlier fixes don't shift later diagnoses).
by_chapter: Dict[str, List[PageReport]] = defaultdict(list)
for r in flagged:
by_chapter[r.chapter].append(r)
# Chapter order: by first-flagged sheet ascending, so the report
# reads forward through the book even though each chapter is
# internally descending.
chapter_order = sorted(
by_chapter.keys(),
key=lambda c: min(r.sheet for r in by_chapter[c]),
)
cause_counts = Counter(r.cause for r in flagged)
summary = ", ".join(f"{n}× {c}" for c, n in cause_counts.most_common())
console.print()
filter_note = ""
if total_flagged and total_flagged != len(flagged):
filter_note = (
f" [dim](filtered from {total_flagged} total)[/dim]"
)
console.print(
f"[bold yellow]⚠ {len(flagged)}/{scanned} pages flagged[/bold yellow]"
f"{filter_note} "
f"[dim](≥{int(threshold*100)}% bottom whitespace · "
f"fix order: back-to-front within chapter)[/dim]"
)
console.print(f"[dim]by culprit: {summary}[/dim]")
fix_counts = Counter(r.fix_hint for r in flagged)
fix_summary = ", ".join(
f"{n}× {h}" for h, n in fix_counts.most_common()
)
console.print(f"[dim]by fix hint: {fix_summary}[/dim]")
strategy_counts = Counter(
(r.layout_strategy or self._layout_strategy(r)) for r in flagged
)
strategy_summary = ", ".join(
f"{n}× {h}" for h, n in strategy_counts.most_common()
)
console.print(f"[dim]by strategy: {strategy_summary}[/dim]")
for chapter in chapter_order:
rows = sorted(by_chapter[chapter], key=lambda x: -x.sheet)
console.print()
console.print(
Rule(
f"[bold magenta]{chapter}[/bold magenta] "
f"[dim]({len(rows)} page{'s' if len(rows) != 1 else ''})[/dim]",
style="magenta",
align="left",
)
)
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
table.add_column("p.", justify="right", style="cyan", width=5)
table.add_column("sheet", justify="right", style="dim", width=6)
table.add_column("gap%", justify="right", style="yellow", width=6)
table.add_column("cls", justify="center", style="magenta", width=3)
table.add_column("culprit", style="white", width=11)
table.add_column("strategy", style="blue", width=22)
table.add_column("detail / source / section", style="white")
table.add_column("action", style="green", width=20)
for r in rows:
label = r.label
if r.is_frontmatter:
label = f"{label}*"
# Build the multi-line detail/source/section block.
lines = []
if r.detail:
lines.append(r.detail)
if r.source_file:
src = r.source_file
if r.source_line:
src = f"{src}:{r.source_line}"
lines.append(f"[dim]→ {src}[/dim]")
if r.section:
lines.append(f"[dim] in: {r.section}[/dim]")
if not lines:
lines.append(r.size_hint or "")
detail_block = "\n".join(lines)
table.add_row(
label,
str(r.sheet),
f"{int(r.whitespace_frac * 100)}%",
r.klass or "?",
r.cause,
r.layout_strategy or self._layout_strategy(r),
detail_block,
r.action or r.fix_hint,
)
console.print(table)
console.print()
console.print(
f"[dim]PDF:[/dim] {pdf_path}\n"
f"[dim]Within each chapter, fix the LAST flagged page first — "
f"front-to-back fixes shift downstream pages and invalidate "
f"later diagnoses.[/dim]\n"
f"[dim]Asterisk (*) after p. = roman-numeral label "
f"(frontmatter/backmatter — usually intentional). "
f"Use --skip-frontmatter to hide.[/dim]"
)
def scan_margin_overflow(pdf_path: Path, tol: float = 2.0, limit: int = 0):
"""Module-level entry point for warn-only margin-overflow detection.
Returns the list of MarginFinding objects (empty when clean), or None when
detection cannot run (missing PDF or pdfplumber). Used by the PDF build
postflight to surface margin overflow as a non-blocking warning; the
blocking gate is ``binder layout margins`` (LayoutCommand._margins).
"""
if not Path(pdf_path).exists():
return None
try:
import pdfplumber # type: ignore # noqa: F401
except ImportError:
return None
cmd = LayoutCommand(None, None)
findings, _ = cmd._collect_margin_findings(Path(pdf_path), tol=tol, limit=limit)
return findings