#!/usr/bin/env python3 """ Smoke Signal — Stage 3: PDF Profiler & Renderer ================================================= For every PDF in the source manifest (status=pending or profiled), this script: 1. Opens each PDF and inspects every page 2. Detects whether a page has embedded text, is image-only, or hybrid 3. Detects rotation, skew hints, two-page spreads, dimensions 4. Routes each page: embedded_text | ocr | hybrid 5. Renders OCR-candidate pages to PNG at 300 DPI (450 DPI fallback) 6. Saves page_profile.json per book 7. Updates source_manifest.csv status to 'rendered' Usage: python scripts/02_profile_pdfs.py python scripts/02_profile_pdfs.py --book-id SS-BOOK-0001 python scripts/02_profile_pdfs.py --batch-id SS-BATCH-001 python scripts/02_profile_pdfs.py --dry-run """ import argparse import csv import json import sys import time from datetime import datetime from pathlib import Path from typing import Optional ROOT = Path(__file__).resolve().parents[1] SOURCE_DIR = ROOT / "source_pdfs" MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv" RENDERS_DIR = ROOT / "renders" PROFILES_DIR = ROOT / "manifest" / "page_profiles" LOGS_DIR = ROOT / "logs" PROFILES_DIR.mkdir(parents=True, exist_ok=True) RENDERS_DIR.mkdir(parents=True, exist_ok=True) LOGS_DIR.mkdir(parents=True, exist_ok=True) CONFIG = { "config_version": "ss_profiler_v0.1", "render_dpi_baseline": 300, "render_dpi_fallback": 450, "text_char_threshold": 20, "spread_aspect_ratio": 1.6, "image_coverage_threshold": 0.15, "skip_statuses": ["exported", "quarantined"], "eligible_statuses": ["pending", "profiled"], } def load_manifest() -> dict: records = {} if not MANIFEST_CSV.exists(): return records with open(MANIFEST_CSV, newline="", encoding="utf-8") as f: for row in csv.DictReader(f): if row.get("book_id"): records[row["book_id"]] = row return records def save_manifest(records: dict) -> None: fields = [ "book_id", "source_id", "filename", "sha256", "file_size_bytes", "page_count", "rights_class", "source_location", "acquisition_date", "status", "allowed_use", "notes" ] rows = sorted(records.values(), key=lambda r: r.get("book_id", "")) with open(MANIFEST_CSV, "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=fields) writer.writeheader() writer.writerows(rows) def _import_fitz(): try: import fitz return fitz except ImportError: print(" [error] PyMuPDF (fitz) not installed. Run: pip install pymupdf") sys.exit(1) def analyse_page(page, fitz, config: dict) -> dict: rect = page.rect width = rect.width height = rect.height rotation = page.rotation text = page.get_text("text").strip() char_count = len(text) has_embedded_text = char_count >= config["text_char_threshold"] image_list = page.get_images(full=True) has_images = len(image_list) > 0 image_coverage = 0.0 page_area = width * height if has_images and page_area > 0: for img in image_list: try: rects = page.get_image_rects(img[0]) for r in rects: image_coverage += abs(r.width * r.height) / page_area except Exception: pass image_coverage = min(image_coverage, 1.0) if has_embedded_text and not has_images: route = "embedded_text" elif has_embedded_text and has_images: route = "hybrid" if image_coverage >= config["image_coverage_threshold"] else "embedded_text" else: route = "ocr" aspect = width / height if height > 0 else 0 is_spread = aspect >= config["spread_aspect_ratio"] warnings = [] if rotation not in (0, 360): warnings.append(f"rotation_{rotation}deg") if is_spread: warnings.append("possible_two_page_spread") if route == "ocr" and char_count == 0 and not has_images: warnings.append("blank_or_undetectable_page") return { "width_pt": round(width, 2), "height_pt": round(height, 2), "rotation_deg": rotation, "char_count": char_count, "has_embedded_text": has_embedded_text, "has_images": has_images, "image_count": len(image_list), "image_coverage": round(image_coverage, 3), "is_spread": is_spread, "route": route, "warnings": warnings, } def render_page(page, book_id: str, page_num: int, dpi: int, render_dir: Path) -> Optional[str]: book_render_dir = render_dir / book_id book_render_dir.mkdir(parents=True, exist_ok=True) filename = f"{book_id}_page_{page_num:04d}_{dpi}dpi.png" out_path = book_render_dir / filename try: mat = page.fitz_module.Matrix(dpi / 72, dpi / 72) pix = page.get_pixmap(matrix=mat, alpha=False) pix.save(str(out_path)) return str(out_path.relative_to(ROOT)) except Exception: return None def profile_book(record: dict, dry_run: bool = False): book_id = record["book_id"] filename = record["filename"] pdf_path = SOURCE_DIR / filename print(f"\n [{book_id}] {filename}") if not pdf_path.exists(): return record, None, {"error": "file_not_found"} if record.get("rights_class") in ("unknown", "excluded"): return record, None, {"error": "rights_blocked"} fitz = _import_fitz() try: doc = fitz.open(str(pdf_path)) except Exception as e: return record, None, {"error": str(e)} page_count = doc.page_count print(f" Pages: {page_count}") pages = [] render_errors = [] route_counts = {"embedded_text": 0, "ocr": 0, "hybrid": 0} for i in range(page_count): page_num = i + 1 page = doc[i] page.fitz_module = fitz profile = analyse_page(page, fitz, CONFIG) profile["page_number"] = page_num render_path = None render_dpi = None if not dry_run and profile["route"] in ("ocr", "hybrid"): dpi = CONFIG["render_dpi_baseline"] render_path = render_page(page, book_id, page_num, dpi, RENDERS_DIR) if render_path is None: dpi = CONFIG["render_dpi_fallback"] render_path = render_page(page, book_id, page_num, dpi, RENDERS_DIR) if render_path is None: render_errors.append(page_num) else: render_dpi = dpi else: render_dpi = dpi profile["render_path"] = render_path profile["render_dpi"] = render_dpi pages.append(profile) route_counts[profile["route"]] += 1 doc.close() book_profile = { "book_id": book_id, "filename": filename, "source_hash": record.get("sha256", ""), "page_count": page_count, "config_version": CONFIG["config_version"], "profiled_at": datetime.utcnow().isoformat() + "Z", "route_summary": route_counts, "render_errors": render_errors, "pages": pages, } if not dry_run: profile_path = PROFILES_DIR / f"{book_id}_page_profile.json" with open(profile_path, "w", encoding="utf-8") as f: json.dump(book_profile, f, indent=2) print(f" Profile saved -> {profile_path.relative_to(ROOT)}") total_ocr = route_counts["ocr"] + route_counts["hybrid"] print(f" Routes: embedded={route_counts['embedded_text']} | ocr={route_counts['ocr']} | hybrid={route_counts['hybrid']}") record["page_count"] = page_count record["status"] = "profiled" if not dry_run else record["status"] return record, book_profile, {} def main(): parser = argparse.ArgumentParser(description="Smoke Signal Stage 3: PDF Profiler") parser.add_argument("--book-id", help="Profile a single book by ID") parser.add_argument("--batch-id", help="Tag this run with a batch ID") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--all", action="store_true") args = parser.parse_args() run_id = args.batch_id or f"SS-RUN-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}" dry_run = args.dry_run print(f"\nSmoke Signal Stage 3 | {run_id} | config={CONFIG['config_version']}") manifest = load_manifest() if not manifest: print("Manifest empty. Run 01_register_sources.py first.") sys.exit(1) eligible = CONFIG["eligible_statuses"] if not args.all else ["pending","profiled","rendered"] books = [manifest[args.book_id]] if args.book_id else [r for r in manifest.values() if r.get("status") in eligible] if not books: print(f"No books with status in {eligible}.") sys.exit(0) print(f"Books to profile: {len(books)}") results = [] t_start = time.time() for record in books: updated, profile, error = profile_book(record, dry_run=dry_run) result = {"book_id": record["book_id"], "filename": record["filename"], "error": error} if profile: result.update({"route_summary": profile["route_summary"], "page_count": profile["page_count"]}) manifest[record["book_id"]] = updated results.append(result) if not dry_run: save_manifest(manifest) log_path = LOGS_DIR / f"{run_id}_profile_run.json" with open(log_path, "w") as f: json.dump({"run_id": run_id, "config": CONFIG, "results": results}, f, indent=2) elapsed = round(time.time() - t_start, 1) succeeded = sum(1 for r in results if not r.get("error")) print(f"\nDone: {succeeded}/{len(results)} succeeded in {elapsed}s") print("Next: run 03_ocr_bakeoff.py on calibration corpus") if __name__ == "__main__": main()