File size: 9,815 Bytes
d4ee6c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
#!/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()