apodex/frontier-agent-demo
14
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("&", "&").replace("<", "<").replace(">", ">")))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 