CoolFace
Apppublic

apodex/frontier-agent-demo

sourceHugging Faceapache-2.0updated 1mo agoView on Hugging Face
14likes
_reader_pdf.py674 linesDownload Raw Back to tools
1 2# pdf reading: per-page routing.3#   pdf_mode: auto  — per-page gate (default): rule (1) no text (2) maths/LaTeX4#                     (3) garbled (4) large image block → OCR the whole page;5#                     everything else uses the text layer (two-column reflow).6#                     Document-level bypasses: AcroForm / attachments / OCG / Tagged.7#             text  — force the text layer, pdftotext -layout (faithful, never OCR)8#             image — pdftoppm converts the given pages to PNG and returns the paths (handed to view_image)9#   pages:    "1-5,12,40-" (1-based; empty = all)10#11#   Detection is all poppler (pdffonts / pdftotext-bbox / pdfimages / pdfinfo /12#   pdfdetach), no new packages;13#   pypdf is only an enhancement (form values / OCG names / Tagged tree / vector counts) and degrades automatically when absent.14#   OCR port: a synchronous API (POST {base}/ocr, body = PDF bytes + an Authorization15#   header → JSON:16#     text / text_with_img_link / layout_json); health check GET {base}/health/ready;17#     base url comes from env READDOC_OCR_URL. Unconfigured, the page is only marked "routed to OCR (reason) + text-layer fallback".18import json as _pjson19import os as _pos20import re as _pre21import subprocess as _psub22import time as _ptime23 24# ---- Thresholds (relative to scale / intrinsic properties, not fitted to a dataset) ----25_PDF_EMPTY_CHARS = 10        # rule 1: fewer extractable characters than this on a page → treat as empty (scan / pure image)26_PDF_MATH_FONTS = _pre.compile(27    r"(CMMI|CMSY|CMEX|CMMIB|CMBSY|MSAM|MSBM|RSFS|EU[FSM]|StandardSym|"28    r"Math|rsfs|cmmi|cmsy|cmex)", _pre.I)  # rule 2: maths-only font families29_PDF_GARBLE_RATIO = 0.15     # rule 3: share of unmappable glyphs (replacement char / PUA) above this → the text layer is untrustworthy30_PDF_IMG_COVER = 1.0 / 6     # rule 4: total image share of the page area above this → large image block (scan / screenshot / figure)31_PDF_VEC_OPS = 400           # rule 4 (vector, best-effort): more path operators than this plus little text → a figure32_PDF_SPARSE_TEXT = 200       # rule 4: less text than this plus substantial visual content → chart / scanned page (the text layer plainly is not carrying the content)33 34# auto overview / image deep read are separate, and both run concurrently35_PDF_CONCURRENCY = int(_pos.environ.get("READDOC_PDF_CONCURRENCY", "2"))  # per-page concurrency (shared by deep read and overview)36_PDF_INLINE_IMG_COVER = 0.08  # text page: raster coverage above this → an inline "figure not read" conclusion37_PDF_DRAW_OPS_FLOOR = 100     # text page: draw ops above this → report the count neutrally (draw no conclusion; let the model judge from the body text)38_OCR_FIG_MIN_WPCT = 15        # minimum share of page width for an OCR <img width="N%"> to count as a "real figure" (below this it is probably a logo)39 40 41def _pdf_parse_pages(pages, total):42    if not pages:43        return list(range(1, total + 1))44    out = set()45    for part in str(pages).split(","):46        part = part.strip()47        if not part:48            continue49        if "-" in part:50            a, _, b = part.partition("-")51            lo = int(a) if a.strip() else 152            hi = int(b) if b.strip() else total53        else:54            lo = hi = int(part)55        for p in range(max(lo, 1), min(hi, total) + 1):56            out.add(p)57    return sorted(out)58 59 60def _pdf_page_count(path):61    try:62        r = _psub.run(["pdfinfo", path], capture_output=True, text=True, timeout=30)63        for ln in r.stdout.splitlines():64            if ln.startswith("Pages:"):65                return int(ln.split(":")[1])66    except Exception:67        pass68    _ensure("pypdf", "pypdf")69    from pypdf import PdfReader70    return len(PdfReader(path).pages)71 72 73def _pdf_page_text(path, page_no):74    """One page of text: pdftotext -layout (preserves layout), falling back to pypdf on failure."""75    try:76        r = _psub.run(["pdftotext", "-layout", "-f", str(page_no), "-l", str(page_no), path, "-"],77                      capture_output=True, text=True, timeout=60)78        if r.returncode == 0:79            return r.stdout80    except Exception:81        pass82    try:83        _ensure("pypdf", "pypdf")84        from pypdf import PdfReader85        return PdfReader(path).pages[page_no - 1].extract_text() or ""86    except Exception:87        return ""88 89 90# ---------- Document-level metadata ----------91def _pinfo(path):92    d = {}93    try:94        r = _psub.run(["pdfinfo", path], capture_output=True, text=True, timeout=30)95        for ln in r.stdout.splitlines():96            if ":" in ln:97                k, _, v = ln.partition(":")98                d[k.strip()] = v.strip()99    except Exception:100        pass101    return d102 103 104def _page_size(pinfo):105    m = _pre.search(r"([\d.]+)\s*x\s*([\d.]+)\s*pts", pinfo.get("Page size", ""))106    return (float(m.group(1)), float(m.group(2))) if m else (612.0, 792.0)107 108 109# ---------- Per-page detection (poppler) ----------110def _fonts_on_page(path, page_no):111    """[(name, has_tounicode)]; the uni column of pdffonts."""112    out = []113    try:114        r = _psub.run(["pdffonts", "-f", str(page_no), "-l", str(page_no), path],115                      capture_output=True, text=True, timeout=30)116        for ln in r.stdout.splitlines()[2:]:117            # The tail is always emb sub uni objid objgen — three yes/no plus two numbers (type contains spaces, so column splitting will not work)118            m = _pre.search(r"\b(yes|no)\s+(yes|no)\s+(yes|no)\s+\d+\s+\d+\s*$", ln)119            name = ln.split()[0] if ln.split() else ""120            if name:121                out.append((name, bool(m) and m.group(3) == "yes"))122    except Exception:123        pass124    return out125 126 127def _is_math_page(fonts):128    return any(_PDF_MATH_FONTS.search(n) for n, _ in fonts)129 130 131def _garble_ratio(text):132    if not text:133        return 0.0134    bad = sum(1 for c in text if c == "�" or 0xE000 <= ord(c) <= 0xF8FF)135    return bad / max(len(text), 1)136 137 138def _image_cover(path, page_no, pagew, pageh):139    """**Total** share of the page area taken by every image on it (placed area, capped at 1.0; smask excluded).140    From pdfimages -list width/height (px) + x/y-ppi → pt."""141    page_area = max(pagew * pageh, 1.0)142    total = 0.0143    try:144        r = _psub.run(["pdfimages", "-list", "-f", str(page_no), "-l", str(page_no), path],145                      capture_output=True, text=True, timeout=30)146        for ln in r.stdout.splitlines()[2:]:147            c = ln.split()148            if len(c) < 15 or c[2] == "smask":  # an smask is the companion mask, so its area is not counted twice149                continue150            try:151                w, h = float(c[3]), float(c[4])152                xppi, yppi = float(c[12]), float(c[13])153                if xppi <= 0 or yppi <= 0:154                    continue155                total += (w / xppi * 72.0) * (h / yppi * 72.0) / page_area156            except (ValueError, ZeroDivisionError):157                continue158    except Exception:159        pass160    return min(total, 1.0)161 162 163def _vector_ops(path, page_no):164    """Count of path-construction operators (including one level of Form XObject; best-effort, pypdf; missing or failing → -1)."""165    try:166        _ensure("pypdf", "pypdf")167        from pypdf import PdfReader168        from pypdf.generic import ContentStream169        rd = PdfReader(path)170        pg = rd.pages[page_no - 1]171 172        def _count(cs):173            return sum(1 for _, op in cs.operations if op in (b"l", b"c", b"re", b"m", b"v", b"y"))174 175        n = _count(ContentStream(pg.get_contents(), rd))176        xo = (pg.get("/Resources") or {}).get("/XObject")  # Office vector charts are usually wrapped in a Form XObject177        if xo:178            for ref in xo.values():179                try:180                    o = ref.get_object()181                    if o.get("/Subtype") == "/Form":182                        n += _count(ContentStream(o.get_data(), rd))183                except Exception:184                    continue185        return n186    except Exception:187        return -1188 189 190def _word_boxes(path, page_no):191    """[(xmin,ymin,xmax,ymax,text)] via pdftotext -bbox。"""192    out = []193    try:194        r = _psub.run(["pdftotext", "-bbox", "-f", str(page_no), "-l", str(page_no), path, "-"],195                      capture_output=True, text=True, timeout=60)196        for m in _pre.finditer(197                r'<word xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="([\d.]+)">(.*?)</word>',198                r.stdout):199            x0, y0, x1, y1, t = m.groups()200            out.append((float(x0), float(y0), float(x1), float(y1),201                        t.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")))202    except Exception:203        pass204    return out205 206 207def _detect_columns(boxes, pagew):208    """Two-column gutter detection: returns split_x or None. The test = both sides hold a sizeable share and very few words straddle the gutter."""209    if len(boxes) < 30:210        return None211    best = None212    for frac in (0.45, 0.5, 0.55):213        split = pagew * frac214        left = sum(1 for b in boxes if b[2] < split)215        right = sum(1 for b in boxes if b[0] > split)216        cross = sum(1 for b in boxes if b[0] <= split <= b[2])217        n = len(boxes)218        if left > 0.25 * n and right > 0.25 * n and cross < 0.05 * n:219            score = min(left, right) - cross220            if best is None or score > best[1]:221                best = (split, score)222    return best[0] if best else None223 224 225def _reorder_columns(boxes, split):226    """Reflow by column: the whole left column (row by row) → the whole right column. A word straddling the gutter goes to the nearer side."""227    def col_text(words):228        words = sorted(words, key=lambda b: (round(b[1] / 6), b[0]))  # by row (~6pt granularity), then by column229        lines, cur, cy = [], [], None230        for b in words:231            if cy is None or abs(b[1] - cy) <= 6:232                cur.append(b[4])233                cy = b[1] if cy is None else cy234            else:235                lines.append(" ".join(cur))236                cur = [b[4]]237                cy = b[1]238        if cur:239            lines.append(" ".join(cur))240        return "\n".join(lines)241    left = [b for b in boxes if (b[0] + b[2]) / 2 < split]242    right = [b for b in boxes if (b[0] + b[2]) / 2 >= split]243    return col_text(left) + "\n\n" + col_text(right)244 245 246# ---------- OCR port (synchronous POST /ocr; env READDOC_OCR_URL + READDOC_OCR_KEY; returns None when unconfigured) ----------247def _ocr_base():248    return _pos.environ.get("READDOC_OCR_URL", "").rstrip("/")249 250 251def _one_page_pdf(path, page_no, outdir):252    out = _pos.path.join(outdir, f"ocr_p{page_no}.pdf")253    if not _pos.path.exists(out):254        _psub.run(["pdfseparate", "-f", str(page_no), "-l", str(page_no), path, out],255                  capture_output=True, timeout=60)256    return out if _pos.path.exists(out) else None257 258 259def _ocr_page(path, page_no, outdir):260    """Whole page → the OCR port (synchronous: POST {base}/ocr, body = PDF bytes, returns JSON) → markdown.261    Raises on failure; returns None when the port is unconfigured (the caller falls back). Auth via env READDOC_OCR_KEY (Authorization header).262    Response JSON: text / text_with_img_link (carries <img> figure markers) / layout_json (block bboxes)."""263    base = _ocr_base()264    if not base:265        return None266    import urllib.error267    import urllib.request268    src = _one_page_pdf(path, page_no, outdir) or path269    data = open(src, "rb").read()270    headers = {"Content-Type": "application/pdf"}271    key = _pos.environ.get("READDOC_OCR_KEY", "")272    if key:273        headers["Authorization"] = key274    t0 = _ptime.time()275    # An inference pool returns intermittent 503 "pool not ready" (scale-down, cold start), so 503 is retried with backoff; every other error is raised.276    resp = None277    for attempt in range(6):278        req = urllib.request.Request(f"{base}/ocr", data=data, headers=headers, method="POST")279        try:280            resp = _pjson.loads(urllib.request.urlopen(req, timeout=300).read())281            break282        except urllib.error.HTTPError as e:283            if e.code == 503 and attempt < 5:284                _ptime.sleep(3 + attempt * 3)285                continue286            raise287    md = resp.get("text_with_img_link") or resp.get("text") or ""288    _trace({"stage": "ocr", "page": page_no,289            "ms": int((_ptime.time() - t0) * 1000), "status": "done",290            "has_img": "<img" in md})291    # OCR marks charts and figures as <img> (it does not read the data inside them). When292    # this page has a "real figure" (not a small logo):293    # the reader renders the whole page → calls vision with a figures-only prompt (body294    # text and tables stay as OCR produced them; vision only adds the figures) →295    # marks the position at the <img> and appends the figure content, clearly labelled, at296    # the end of the page. A mixed page thus keeps OCR body text, still gets its figures297    # read, and duplicates nothing.298    has_real = "<img" in md and bool(_pos.environ.get("READDOC_VISION_URL")) \299        and _ocr_has_real_figure(md)300    if "<img" in md:301        _trace({"stage": "figure-detect", "page": page_no, "has_img": True,302                "real_figure": has_real, "vision_url": bool(_pos.environ.get("READDOC_VISION_URL"))})303    if has_real:304        imgs = _pdf_to_images(path, [page_no])305        if imgs:306            try:307                vt = _vision_read(open(imgs[0][1], "rb").read(), "image/png",308                                  question=_VISION_FIGURE_PROMPT)309            except Exception:310                vt = None311            if vt and "NO_FIGURE" not in vt:312                marked = _pre.sub(r'<img[^>]*>', "`[figure — read via vision ↓]`", md)313                return marked + "\n\n`[figures on this page, read via vision]`\n\n" + vt314    return md315 316 317def _ocr_has_real_figure(md):318    """OCR already marks each figure's position and relative page-width share with <img ... width="N%">.319    Use that to judge a "real figure": any <img> whose width% >= the threshold (or, absent a width%, conservatively assume a figure) → True;320    False only when every <img> is clearly small (probably a logo or icon). This uses the321    labels OCR gave us directly, with no dependency on layout_json."""322    # (The threshold, and whether the OCR service emits <img> for logos at all, depend on the deployed service.)323    for m in _pre.finditer(r'<img\b[^>]*>', md or ""):324        wm = _pre.search(r'width\s*=\s*["\']?\s*(\d+(?:\.\d+)?)\s*%', m.group(0))325        if wm is None or float(wm.group(1)) >= _OCR_FIG_MIN_WPCT:326            return True327    return False328 329 330# ---------- Document-level bypasses ----------331def _attachments(path):332    """Embedded attachment names (pdfdetach -list, poppler)."""333    try:334        r = _psub.run(["pdfdetach", "-list", path], capture_output=True, text=True, timeout=30)335        names = _pre.findall(r"(?m)^\s*\d+:\s*(.+)$", r.stdout)336        return [n.strip() for n in names]337    except Exception:338        return []339 340 341def _acroform_fields(path):342    """AcroForm field values (pypdf; empty when absent)."""343    try:344        _ensure("pypdf", "pypdf")345        from pypdf import PdfReader346        f = PdfReader(path).get_fields()347        if not f:348            return []349        out = []350        for name, fld in f.items():351            v = fld.get("/V")352            out.append((str(name), "" if v is None else str(v)))353        return out354    except Exception:355        return []356 357 358def _ocg_layers(path):359    """Optional-content layer names (pypdf catalog /OCProperties)."""360    try:361        _ensure("pypdf", "pypdf")362        from pypdf import PdfReader363        root = PdfReader(path).trailer["/Root"]364        ocp = root.get("/OCProperties")365        if not ocp:366            return []367        names = []368        for g in (ocp.get("/OCGs") or []):369            try:370                names.append(str(g.get_object().get("/Name")))371            except Exception:372                continue373        return names374    except Exception:375        return []376 377 378_TAG_ROLE = {"/H1": "# ", "/H2": "## ", "/H3": "### ", "/H4": "#### ",379             "/H5": "##### ", "/H6": "###### ", "/Title": "# ", "/H": "## "}380 381 382def _tagged_outline(path, limit=400):383    """Tagged structure tree → reading-order outline (pypdf; best-effort).384    Takes each structure element's role + its /ActualText | /Alt | /T text, recursing in /K order."""385    try:386        _ensure("pypdf", "pypdf")387        from pypdf import PdfReader388        from pypdf.generic import IndirectObject389        root = PdfReader(path).trailer["/Root"]390        st = root.get("/StructTreeRoot")391        if not st:392            return ""393        lines = []394 395        def txt(node):396            for key in ("/ActualText", "/Alt", "/T"):397                v = node.get(key)398                if v:399                    return str(v)400            return ""401 402        def walk(node, depth):403            if len(lines) >= limit or depth > 12:404                return405            try:406                if isinstance(node, IndirectObject):407                    node = node.get_object()408            except Exception:409                return410            if isinstance(node, list):411                for c in node:412                    walk(c, depth)413                return414            if not hasattr(node, "get"):415                return416            role = node.get("/S")417            t = txt(node)418            if role is not None and (str(role) in _TAG_ROLE or t.strip()):419                r = str(role)  # keep only headings and nodes carrying text; skip pure structural noise like Div / NonStruct420                lines.append(_TAG_ROLE.get(r, "  " * min(depth, 6) + f"- [{r.lstrip('/')}] ") + t)421            k = node.get("/K")422            if k is not None:423                walk(k, depth + 1)424 425        walk(st.get("/K"), 0)426        body = "\n".join(x for x in lines if x.strip())427        return body428    except Exception:429        return ""430 431 432# ---------- Post-processing: strip repeated headers/footers ----------433def _norm_line(s):434    return _pre.sub(r"\d+", "#", s.strip())435 436 437def _dedup_headers(page_texts):438    """Detect first/last lines repeated across pages = running header/footer; returns (header, footer, cleaned_pages)."""439    n = len(page_texts)440    if n < 3:441        return "", "", page_texts442    firsts, lasts = {}, {}443    for t in page_texts:444        ls = [x for x in t.splitlines() if x.strip()]445        if ls:446            firsts[_norm_line(ls[0])] = firsts.get(_norm_line(ls[0]), 0) + 1447            lasts[_norm_line(ls[-1])] = lasts.get(_norm_line(ls[-1]), 0) + 1448    hdr = max(firsts, key=firsts.get) if firsts else ""449    ftr = max(lasts, key=lasts.get) if lasts else ""450    hdr_hit = firsts.get(hdr, 0) >= max(3, int(0.5 * n))451    ftr_hit = lasts.get(ftr, 0) >= max(3, int(0.5 * n))452    header = footer = ""453    cleaned = []454    for t in page_texts:455        ls = t.splitlines()456        nonempty = [i for i, x in enumerate(ls) if x.strip()]457        if hdr_hit and nonempty and _norm_line(ls[nonempty[0]]) == hdr:458            header = ls[nonempty[0]].strip()459            ls[nonempty[0]] = ""460        if ftr_hit and nonempty and _norm_line(ls[nonempty[-1]]) == ftr:461            footer = ls[nonempty[-1]].strip()462            ls[nonempty[-1]] = ""463        cleaned.append("\n".join(ls))464    return header, footer, cleaned465 466 467# ---------- image mode (render PNG, hand off to view_image) ----------468def _pdf_to_images(path, page_nos, dpi=150):469    stem = _pos.path.splitext(_pos.path.basename(path))[0].replace(" ", "_")470    outdir = _pos.path.join("/workspace", ".readdoc_pdf_img", stem)471    try:472        _pos.makedirs(outdir, exist_ok=True)473    except OSError:474        outdir = _pos.path.join("/tmp", ".readdoc_pdf_img", stem)475        _pos.makedirs(outdir, exist_ok=True)476    res = []477    for p in page_nos:478        prefix = _pos.path.join(outdir, f"p{p}")479        png = prefix + ".png"480        if not _pos.path.exists(png):481            _psub.run(["pdftoppm", "-png", "-r", str(dpi), "-f", str(p), "-l", str(p),482                       "-singlefile", path, prefix], capture_output=True, timeout=120)483        if _pos.path.exists(png):484            res.append((p, png))485    return res486 487 488def _ocr_workdir(path):489    stem = _pos.path.splitext(_pos.path.basename(path))[0].replace(" ", "_")490    for base in ("/workspace/.readdoc_pdf_img", "/tmp/.readdoc_pdf_img"):491        try:492            d = _pos.path.join(base, stem)493            _pos.makedirs(d, exist_ok=True)494            return d495        except OSError:496            continue497    return "."498 499 500# ---------- Main entry point ----------501def _pdf_route_decide(path, p, pagew, pageh):502    """The cheap per-page verdict (poppler only; runs no OCR and no vision). Shared by the auto overview and the image deep read.503    Returns a dict: route='text'|'ocr', reason (None for a text page), rule, sig (the signals), text (the extracted text layer)."""504    text = _pdf_page_text(path, p)505    fonts = _fonts_on_page(path, p)506    tlen = len(text.strip())507    sig = {"text_len": tlen, "math_font": _is_math_page(fonts),508           "garble": round(_garble_ratio(text), 3)}509    # The gate, in order; reason goes straight into the readout510    reason = rule = None511    if tlen < _PDF_EMPTY_CHARS:512        reason, rule = "scanned image or pure graphic", "1-empty"513    elif sig["math_font"]:514        reason, rule = "math/formula fonts (LaTeX)", "2-math"515    elif sig["garble"] > _PDF_GARBLE_RATIO:516        reason, rule = "garbled text layer (broken font encoding)", "3-garble"517    else:518        cov = _image_cover(path, p, pagew, pageh)519        vops = _vector_ops(path, p)520        sig["img_cover"], sig["vec_ops"] = round(cov, 3), vops521        if cov > _PDF_IMG_COVER:522            reason, rule = f"dominated by a raster image (covers {cov:.0%} of page)", "4a-large-image"523        elif vops > _PDF_VEC_OPS and tlen < 400:524            reason, rule = f"vector graphic ({vops} draw ops, little text)", "4b-vector"525        elif tlen < _PDF_SPARSE_TEXT and (cov > 0.05 or vops > 100):526            reason = f"sparse text ({tlen} chars) with visual content (img {cov:.0%}, draw ops {vops})"527            rule = "4c-sparse-visual"528    d = {"route": "ocr" if reason else "text", "reason": reason,529         "rule": rule or "text-fast", "sig": sig, "text": text}530    _trace({"stage": "route", "page": p, "signals": sig, "rule": d["rule"],531            "route": d["route"], "reason": reason})532    return d533 534 535def _text_body(path, p, pagew, text):536    """Body text of a text page: reflowed when two-column, otherwise the raw text layer."""537    boxes = _word_boxes(path, p)538    split = _detect_columns(boxes, pagew)539    return _reorder_columns(boxes, split) if split else text540 541 542def _overview_page(path, p, pagew, pageh):543    """auto overview (cheap; runs no OCR and no vision). Returns (body, tag, is_text).544    text page: the text plus an inline raster conclusion (cover above the threshold) and a neutral draw-ops count (above the threshold); ocr pages are placeholders only."""545    d = _pdf_route_decide(path, p, pagew, pageh)546    if d["route"] == "ocr":547        return ("", f" | not read: {d['reason']} — read via read_file with "548                f"pages={p} and pdf_mode=image", False)549    body = _text_body(path, p, pagew, d["text"])550    sig = d["sig"]551    extras = []552    if sig.get("img_cover", 0) > _PDF_INLINE_IMG_COVER:553        extras.append(f"figure not read: embedded image (covers {sig['img_cover']:.0%} of page)"554                      f" — read via read_file with pages={p} and pdf_mode=image")555    if sig.get("vec_ops", 0) > _PDF_DRAW_OPS_FLOOR:556        extras.append(f"draw ops = {sig['vec_ops']}")557    return (body, (" | " + " | ".join(extras)) if extras else "", True)558 559 560def _deep_page(path, p, pagew, pageh, outdir):561    """image deep read: runs the real logic behind the route (ocr class → OCR + figure vision; a text page with figures → text + figure vision).562    Returns (body, tag, is_text)."""563    d = _pdf_route_decide(path, p, pagew, pageh)564    text = d["text"]565    if d["route"] == "ocr":566        try:567            md = _ocr_page(path, p, outdir)568        except Exception as e:569            return ((text or "(no extractable text)")570                    + f"\n\n`[OCR failed: {type(e).__name__}; text-layer fallback]`",571                    " | OCR failed", False)572        if md is not None:573            return (md, " | OCR", False)574        note = (f"`[routed to OCR — {d['reason']}; OCR endpoint not configured "575                f"(set READDOC_OCR_URL). Showing text-layer fallback below.]`")576        return (note + ("\n\n" + text if text.strip() else ""), " | →OCR", False)577    # Deep-reading a text page: when the user explicitly chose image, always render the578    # whole page and run figures-only vision —579    # no longer gated on the cover threshold (a vector figure has cover=0% and still needs580    # reading). With no figure present the prompt returns NO_FIGURE and only the text layer remains.581    # The body text stays the high-quality pdftotext output; vision only adds figures and never re-transcribes the text.582    body = _text_body(path, p, pagew, text)583    if _pos.environ.get("READDOC_VISION_URL"):584        imgs = _pdf_to_images(path, [p])585        if imgs:586            try:587                vt = _vision_read(open(imgs[0][1], "rb").read(), "image/png",588                                  question=_VISION_FIGURE_PROMPT)589            except Exception:590                vt = None591            if vt and "NO_FIGURE" not in vt:592                return (body + "\n\n`[figure on this page, read via vision]`\n\n" + vt,593                        " | vision", False)594    return (body, "", True)595 596 597def _map_pages(sel, fn):598    """Run fn(p) concurrently while preserving page order; concurrency READDOC_PDF_CONCURRENCY (default 2), applied only to pages that actually need work."""599    if _PDF_CONCURRENCY <= 1 or len(sel) <= 1:600        return [fn(p) for p in sel]601    from concurrent.futures import ThreadPoolExecutor602    with ThreadPoolExecutor(max_workers=_PDF_CONCURRENCY) as ex:603        return list(ex.map(fn, sel))604 605 606def _pdf_doc_head(path, pinfo):607    """Document-level bypass header (encryption / embedded attachments / AcroForm / OCG / Tagged structure). Returns a list of lines."""608    head = []609    if pinfo.get("Encrypted", "no").startswith("yes"):610        head.append("`encrypted: yes (extraction may be limited)`")611    atts = _attachments(path)612    if atts:613        head.append("▸ embedded files: " + ", ".join(atts))614    if pinfo.get("Form", "none") not in ("none", ""):615        fields = _acroform_fields(path)616        if fields:617            head.append("▸ form fields:\n" + "\n".join(f"  - {n}: {v}" for n, v in fields if n))618    ocg = _ocg_layers(path)619    if ocg:620        head.append("▸ optional layers (OCG, may be hidden): " + ", ".join(ocg) +621                    " — re-read with pdf_mode='image' to render a specific layer")622    if pinfo.get("Tagged", "no").startswith("yes"):623        outline = _tagged_outline(path)624        head.append("▸ tagged-PDF structure (reading order):\n" + outline if outline625                    else "`tagged-PDF: yes (structure tree present)`")626    return head627 628 629def _pdf_to_md(path, pdf_mode="auto", pages=None):630    total = _pdf_page_count(path)631    sel = _pdf_parse_pages(pages, total)632    span = "" if (not pages) else f" (pages {pages})"633 634    if pdf_mode == "text":  # force the text layer: faithful, never OCR635        out = [f"(PDF: {total} pages{span})"]636        for p in sel:637            out += [f"\n<!-- page {p} -->\n", _pdf_page_text(path, p)]638        return "\n".join(out)639 640    pinfo = _pinfo(path)641    pagew, pageh = _page_size(pinfo)642    outdir = _ocr_workdir(path)643    head = _pdf_doc_head(path, pinfo)644 645    # auto = overview (verdict only; ocr pages are placeholders) / image = deep read (runs the real routing logic). Concurrent per page.646    if pdf_mode == "image":647        results = _map_pages(sel, lambda p: _deep_page(path, p, pagew, pageh, outdir))648        mode_note = f"deep read (pdf_mode=image) — {len(sel)} page(s) executed"649    else:650        results = _map_pages(sel, lambda p: _overview_page(path, p, pagew, pageh))651        mode_note = ("overview (pdf_mode=auto) — figure/scan pages are flagged, not read; "652                     "re-read a flagged page with pages=N and pdf_mode=image")653 654    bodies = [r[0] for r in results]655    tags = [r[1] for r in results]656    is_text = [r[2] for r in results]657 658    # Header/footer dedup: only between text pages (placeholder / OCR / vision pages stay out, to avoid false positives)659    text_idx = [i for i, t in enumerate(is_text) if t]660    header, footer, sub_clean = _dedup_headers([bodies[i] for i in text_idx])661    for j, i in enumerate(text_idx):662        bodies[i] = sub_clean[j]663 664    out = [f"(PDF: {total} pages{span}) — {mode_note}"]665    if head:666        out.append("\n```meta\n" + "\n".join(head) + "\n```")667    if header:668        out.append(f"\n`running header (all pages)`: {header}")669    if footer:670        out.append(f"`running footer (all pages)`: {footer}")671    for p, body, tag in zip(sel, bodies, tags, strict=False):672        out += [f"\n<!-- page {p}{tag} -->\n", body]673    return "\n".join(out)674