# pdf reading: per-page routing. # pdf_mode: auto — per-page gate (default): rule (1) no text (2) maths/LaTeX # (3) garbled (4) large image block → OCR the whole page; # everything else uses the text layer (two-column reflow). # Document-level bypasses: AcroForm / attachments / OCG / Tagged. # text — force the text layer, pdftotext -layout (faithful, never OCR) # image — pdftoppm converts the given pages to PNG and returns the paths (handed to view_image) # pages: "1-5,12,40-" (1-based; empty = all) # # Detection is all poppler (pdffonts / pdftotext-bbox / pdfimages / pdfinfo / # pdfdetach), no new packages; # pypdf is only an enhancement (form values / OCG names / Tagged tree / vector counts) and degrades automatically when absent. # OCR port: a synchronous API (POST {base}/ocr, body = PDF bytes + an Authorization # header → JSON: # text / text_with_img_link / layout_json); health check GET {base}/health/ready; # base url comes from env READDOC_OCR_URL. Unconfigured, the page is only marked "routed to OCR (reason) + text-layer fallback". import json as _pjson import os as _pos import re as _pre import subprocess as _psub import time as _ptime # ---- Thresholds (relative to scale / intrinsic properties, not fitted to a dataset) ---- _PDF_EMPTY_CHARS = 10 # rule 1: fewer extractable characters than this on a page → treat as empty (scan / pure image) _PDF_MATH_FONTS = _pre.compile( r"(CMMI|CMSY|CMEX|CMMIB|CMBSY|MSAM|MSBM|RSFS|EU[FSM]|StandardSym|" r"Math|rsfs|cmmi|cmsy|cmex)", _pre.I) # rule 2: maths-only font families _PDF_GARBLE_RATIO = 0.15 # rule 3: share of unmappable glyphs (replacement char / PUA) above this → the text layer is untrustworthy _PDF_IMG_COVER = 1.0 / 6 # rule 4: total image share of the page area above this → large image block (scan / screenshot / figure) _PDF_VEC_OPS = 400 # rule 4 (vector, best-effort): more path operators than this plus little text → a figure _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) # auto overview / image deep read are separate, and both run concurrently _PDF_CONCURRENCY = int(_pos.environ.get("READDOC_PDF_CONCURRENCY", "2")) # per-page concurrency (shared by deep read and overview) _PDF_INLINE_IMG_COVER = 0.08 # text page: raster coverage above this → an inline "figure not read" conclusion _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) _OCR_FIG_MIN_WPCT = 15 # minimum share of page width for an OCR to count as a "real figure" (below this it is probably a logo) def _pdf_parse_pages(pages, total): if not pages: return list(range(1, total + 1)) out = set() for part in str(pages).split(","): part = part.strip() if not part: continue if "-" in part: a, _, b = part.partition("-") lo = int(a) if a.strip() else 1 hi = int(b) if b.strip() else total else: lo = hi = int(part) for p in range(max(lo, 1), min(hi, total) + 1): out.add(p) return sorted(out) def _pdf_page_count(path): try: r = _psub.run(["pdfinfo", path], capture_output=True, text=True, timeout=30) for ln in r.stdout.splitlines(): if ln.startswith("Pages:"): return int(ln.split(":")[1]) except Exception: pass _ensure("pypdf", "pypdf") from pypdf import PdfReader return len(PdfReader(path).pages) def _pdf_page_text(path, page_no): """One page of text: pdftotext -layout (preserves layout), falling back to pypdf on failure.""" try: r = _psub.run(["pdftotext", "-layout", "-f", str(page_no), "-l", str(page_no), path, "-"], capture_output=True, text=True, timeout=60) if r.returncode == 0: return r.stdout except Exception: pass try: _ensure("pypdf", "pypdf") from pypdf import PdfReader return PdfReader(path).pages[page_no - 1].extract_text() or "" except Exception: return "" # ---------- Document-level metadata ---------- def _pinfo(path): d = {} try: r = _psub.run(["pdfinfo", path], capture_output=True, text=True, timeout=30) for ln in r.stdout.splitlines(): if ":" in ln: k, _, v = ln.partition(":") d[k.strip()] = v.strip() except Exception: pass return d def _page_size(pinfo): m = _pre.search(r"([\d.]+)\s*x\s*([\d.]+)\s*pts", pinfo.get("Page size", "")) return (float(m.group(1)), float(m.group(2))) if m else (612.0, 792.0) # ---------- Per-page detection (poppler) ---------- def _fonts_on_page(path, page_no): """[(name, has_tounicode)]; the uni column of pdffonts.""" out = [] try: r = _psub.run(["pdffonts", "-f", str(page_no), "-l", str(page_no), path], capture_output=True, text=True, timeout=30) for ln in r.stdout.splitlines()[2:]: # The tail is always emb sub uni objid objgen — three yes/no plus two numbers (type contains spaces, so column splitting will not work) m = _pre.search(r"\b(yes|no)\s+(yes|no)\s+(yes|no)\s+\d+\s+\d+\s*$", ln) name = ln.split()[0] if ln.split() else "" if name: out.append((name, bool(m) and m.group(3) == "yes")) except Exception: pass return out def _is_math_page(fonts): return any(_PDF_MATH_FONTS.search(n) for n, _ in fonts) def _garble_ratio(text): if not text: return 0.0 bad = sum(1 for c in text if c == "�" or 0xE000 <= ord(c) <= 0xF8FF) return bad / max(len(text), 1) def _image_cover(path, page_no, pagew, pageh): """**Total** share of the page area taken by every image on it (placed area, capped at 1.0; smask excluded). From pdfimages -list width/height (px) + x/y-ppi → pt.""" page_area = max(pagew * pageh, 1.0) total = 0.0 try: r = _psub.run(["pdfimages", "-list", "-f", str(page_no), "-l", str(page_no), path], capture_output=True, text=True, timeout=30) for ln in r.stdout.splitlines()[2:]: c = ln.split() if len(c) < 15 or c[2] == "smask": # an smask is the companion mask, so its area is not counted twice continue try: w, h = float(c[3]), float(c[4]) xppi, yppi = float(c[12]), float(c[13]) if xppi <= 0 or yppi <= 0: continue total += (w / xppi * 72.0) * (h / yppi * 72.0) / page_area except (ValueError, ZeroDivisionError): continue except Exception: pass return min(total, 1.0) def _vector_ops(path, page_no): """Count of path-construction operators (including one level of Form XObject; best-effort, pypdf; missing or failing → -1).""" try: _ensure("pypdf", "pypdf") from pypdf import PdfReader from pypdf.generic import ContentStream rd = PdfReader(path) pg = rd.pages[page_no - 1] def _count(cs): return sum(1 for _, op in cs.operations if op in (b"l", b"c", b"re", b"m", b"v", b"y")) n = _count(ContentStream(pg.get_contents(), rd)) xo = (pg.get("/Resources") or {}).get("/XObject") # Office vector charts are usually wrapped in a Form XObject if xo: for ref in xo.values(): try: o = ref.get_object() if o.get("/Subtype") == "/Form": n += _count(ContentStream(o.get_data(), rd)) except Exception: continue return n except Exception: return -1 def _word_boxes(path, page_no): """[(xmin,ymin,xmax,ymax,text)] via pdftotext -bbox。""" out = [] try: r = _psub.run(["pdftotext", "-bbox", "-f", str(page_no), "-l", str(page_no), path, "-"], capture_output=True, text=True, timeout=60) for m in _pre.finditer( r'(.*?)', r.stdout): x0, y0, x1, y1, t = m.groups() out.append((float(x0), float(y0), float(x1), float(y1), t.replace("&", "&").replace("<", "<").replace(">", ">"))) except Exception: pass return out def _detect_columns(boxes, pagew): """Two-column gutter detection: returns split_x or None. The test = both sides hold a sizeable share and very few words straddle the gutter.""" if len(boxes) < 30: return None best = None for frac in (0.45, 0.5, 0.55): split = pagew * frac left = sum(1 for b in boxes if b[2] < split) right = sum(1 for b in boxes if b[0] > split) cross = sum(1 for b in boxes if b[0] <= split <= b[2]) n = len(boxes) if left > 0.25 * n and right > 0.25 * n and cross < 0.05 * n: score = min(left, right) - cross if best is None or score > best[1]: best = (split, score) return best[0] if best else None def _reorder_columns(boxes, split): """Reflow by column: the whole left column (row by row) → the whole right column. A word straddling the gutter goes to the nearer side.""" def col_text(words): words = sorted(words, key=lambda b: (round(b[1] / 6), b[0])) # by row (~6pt granularity), then by column lines, cur, cy = [], [], None for b in words: if cy is None or abs(b[1] - cy) <= 6: cur.append(b[4]) cy = b[1] if cy is None else cy else: lines.append(" ".join(cur)) cur = [b[4]] cy = b[1] if cur: lines.append(" ".join(cur)) return "\n".join(lines) left = [b for b in boxes if (b[0] + b[2]) / 2 < split] right = [b for b in boxes if (b[0] + b[2]) / 2 >= split] return col_text(left) + "\n\n" + col_text(right) # ---------- OCR port (synchronous POST /ocr; env READDOC_OCR_URL + READDOC_OCR_KEY; returns None when unconfigured) ---------- def _ocr_base(): return _pos.environ.get("READDOC_OCR_URL", "").rstrip("/") def _one_page_pdf(path, page_no, outdir): out = _pos.path.join(outdir, f"ocr_p{page_no}.pdf") if not _pos.path.exists(out): _psub.run(["pdfseparate", "-f", str(page_no), "-l", str(page_no), path, out], capture_output=True, timeout=60) return out if _pos.path.exists(out) else None def _ocr_page(path, page_no, outdir): """Whole page → the OCR port (synchronous: POST {base}/ocr, body = PDF bytes, returns JSON) → markdown. Raises on failure; returns None when the port is unconfigured (the caller falls back). Auth via env READDOC_OCR_KEY (Authorization header). Response JSON: text / text_with_img_link (carries figure markers) / layout_json (block bboxes).""" base = _ocr_base() if not base: return None import urllib.error import urllib.request src = _one_page_pdf(path, page_no, outdir) or path data = open(src, "rb").read() headers = {"Content-Type": "application/pdf"} key = _pos.environ.get("READDOC_OCR_KEY", "") if key: headers["Authorization"] = key t0 = _ptime.time() # An inference pool returns intermittent 503 "pool not ready" (scale-down, cold start), so 503 is retried with backoff; every other error is raised. resp = None for attempt in range(6): req = urllib.request.Request(f"{base}/ocr", data=data, headers=headers, method="POST") try: resp = _pjson.loads(urllib.request.urlopen(req, timeout=300).read()) break except urllib.error.HTTPError as e: if e.code == 503 and attempt < 5: _ptime.sleep(3 + attempt * 3) continue raise md = resp.get("text_with_img_link") or resp.get("text") or "" _trace({"stage": "ocr", "page": page_no, "ms": int((_ptime.time() - t0) * 1000), "status": "done", "has_img": " (it does not read the data inside them). When # this page has a "real figure" (not a small logo): # the reader renders the whole page → calls vision with a figures-only prompt (body # text and tables stay as OCR produced them; vision only adds the figures) → # marks the position at the and appends the figure content, clearly labelled, at # the end of the page. A mixed page thus keeps OCR body text, still gets its figures # read, and duplicates nothing. has_real = "]*>', "`[figure — read via vision ↓]`", md) return marked + "\n\n`[figures on this page, read via vision]`\n\n" + vt return md def _ocr_has_real_figure(md): """OCR already marks each figure's position and relative page-width share with . Use that to judge a "real figure": any whose width% >= the threshold (or, absent a width%, conservatively assume a figure) → True; False only when every is clearly small (probably a logo or icon). This uses the labels OCR gave us directly, with no dependency on layout_json.""" # (The threshold, and whether the OCR service emits for logos at all, depend on the deployed service.) for m in _pre.finditer(r']*>', md or ""): wm = _pre.search(r'width\s*=\s*["\']?\s*(\d+(?:\.\d+)?)\s*%', m.group(0)) if wm is None or float(wm.group(1)) >= _OCR_FIG_MIN_WPCT: return True return False # ---------- Document-level bypasses ---------- def _attachments(path): """Embedded attachment names (pdfdetach -list, poppler).""" try: r = _psub.run(["pdfdetach", "-list", path], capture_output=True, text=True, timeout=30) names = _pre.findall(r"(?m)^\s*\d+:\s*(.+)$", r.stdout) return [n.strip() for n in names] except Exception: return [] def _acroform_fields(path): """AcroForm field values (pypdf; empty when absent).""" try: _ensure("pypdf", "pypdf") from pypdf import PdfReader f = PdfReader(path).get_fields() if not f: return [] out = [] for name, fld in f.items(): v = fld.get("/V") out.append((str(name), "" if v is None else str(v))) return out except Exception: return [] def _ocg_layers(path): """Optional-content layer names (pypdf catalog /OCProperties).""" try: _ensure("pypdf", "pypdf") from pypdf import PdfReader root = PdfReader(path).trailer["/Root"] ocp = root.get("/OCProperties") if not ocp: return [] names = [] for g in (ocp.get("/OCGs") or []): try: names.append(str(g.get_object().get("/Name"))) except Exception: continue return names except Exception: return [] _TAG_ROLE = {"/H1": "# ", "/H2": "## ", "/H3": "### ", "/H4": "#### ", "/H5": "##### ", "/H6": "###### ", "/Title": "# ", "/H": "## "} def _tagged_outline(path, limit=400): """Tagged structure tree → reading-order outline (pypdf; best-effort). Takes each structure element's role + its /ActualText | /Alt | /T text, recursing in /K order.""" try: _ensure("pypdf", "pypdf") from pypdf import PdfReader from pypdf.generic import IndirectObject root = PdfReader(path).trailer["/Root"] st = root.get("/StructTreeRoot") if not st: return "" lines = [] def txt(node): for key in ("/ActualText", "/Alt", "/T"): v = node.get(key) if v: return str(v) return "" def walk(node, depth): if len(lines) >= limit or depth > 12: return try: if isinstance(node, IndirectObject): node = node.get_object() except Exception: return if isinstance(node, list): for c in node: walk(c, depth) return if not hasattr(node, "get"): return role = node.get("/S") t = txt(node) if role is not None and (str(role) in _TAG_ROLE or t.strip()): r = str(role) # keep only headings and nodes carrying text; skip pure structural noise like Div / NonStruct lines.append(_TAG_ROLE.get(r, " " * min(depth, 6) + f"- [{r.lstrip('/')}] ") + t) k = node.get("/K") if k is not None: walk(k, depth + 1) walk(st.get("/K"), 0) body = "\n".join(x for x in lines if x.strip()) return body except Exception: return "" # ---------- Post-processing: strip repeated headers/footers ---------- def _norm_line(s): return _pre.sub(r"\d+", "#", s.strip()) def _dedup_headers(page_texts): """Detect first/last lines repeated across pages = running header/footer; returns (header, footer, cleaned_pages).""" n = len(page_texts) if n < 3: return "", "", page_texts firsts, lasts = {}, {} for t in page_texts: ls = [x for x in t.splitlines() if x.strip()] if ls: firsts[_norm_line(ls[0])] = firsts.get(_norm_line(ls[0]), 0) + 1 lasts[_norm_line(ls[-1])] = lasts.get(_norm_line(ls[-1]), 0) + 1 hdr = max(firsts, key=firsts.get) if firsts else "" ftr = max(lasts, key=lasts.get) if lasts else "" hdr_hit = firsts.get(hdr, 0) >= max(3, int(0.5 * n)) ftr_hit = lasts.get(ftr, 0) >= max(3, int(0.5 * n)) header = footer = "" cleaned = [] for t in page_texts: ls = t.splitlines() nonempty = [i for i, x in enumerate(ls) if x.strip()] if hdr_hit and nonempty and _norm_line(ls[nonempty[0]]) == hdr: header = ls[nonempty[0]].strip() ls[nonempty[0]] = "" if ftr_hit and nonempty and _norm_line(ls[nonempty[-1]]) == ftr: footer = ls[nonempty[-1]].strip() ls[nonempty[-1]] = "" cleaned.append("\n".join(ls)) return header, footer, cleaned # ---------- image mode (render PNG, hand off to view_image) ---------- def _pdf_to_images(path, page_nos, dpi=150): stem = _pos.path.splitext(_pos.path.basename(path))[0].replace(" ", "_") outdir = _pos.path.join("/workspace", ".readdoc_pdf_img", stem) try: _pos.makedirs(outdir, exist_ok=True) except OSError: outdir = _pos.path.join("/tmp", ".readdoc_pdf_img", stem) _pos.makedirs(outdir, exist_ok=True) res = [] for p in page_nos: prefix = _pos.path.join(outdir, f"p{p}") png = prefix + ".png" if not _pos.path.exists(png): _psub.run(["pdftoppm", "-png", "-r", str(dpi), "-f", str(p), "-l", str(p), "-singlefile", path, prefix], capture_output=True, timeout=120) if _pos.path.exists(png): res.append((p, png)) return res def _ocr_workdir(path): stem = _pos.path.splitext(_pos.path.basename(path))[0].replace(" ", "_") for base in ("/workspace/.readdoc_pdf_img", "/tmp/.readdoc_pdf_img"): try: d = _pos.path.join(base, stem) _pos.makedirs(d, exist_ok=True) return d except OSError: continue return "." # ---------- Main entry point ---------- def _pdf_route_decide(path, p, pagew, pageh): """The cheap per-page verdict (poppler only; runs no OCR and no vision). Shared by the auto overview and the image deep read. Returns a dict: route='text'|'ocr', reason (None for a text page), rule, sig (the signals), text (the extracted text layer).""" text = _pdf_page_text(path, p) fonts = _fonts_on_page(path, p) tlen = len(text.strip()) sig = {"text_len": tlen, "math_font": _is_math_page(fonts), "garble": round(_garble_ratio(text), 3)} # The gate, in order; reason goes straight into the readout reason = rule = None if tlen < _PDF_EMPTY_CHARS: reason, rule = "scanned image or pure graphic", "1-empty" elif sig["math_font"]: reason, rule = "math/formula fonts (LaTeX)", "2-math" elif sig["garble"] > _PDF_GARBLE_RATIO: reason, rule = "garbled text layer (broken font encoding)", "3-garble" else: cov = _image_cover(path, p, pagew, pageh) vops = _vector_ops(path, p) sig["img_cover"], sig["vec_ops"] = round(cov, 3), vops if cov > _PDF_IMG_COVER: reason, rule = f"dominated by a raster image (covers {cov:.0%} of page)", "4a-large-image" elif vops > _PDF_VEC_OPS and tlen < 400: reason, rule = f"vector graphic ({vops} draw ops, little text)", "4b-vector" elif tlen < _PDF_SPARSE_TEXT and (cov > 0.05 or vops > 100): reason = f"sparse text ({tlen} chars) with visual content (img {cov:.0%}, draw ops {vops})" rule = "4c-sparse-visual" d = {"route": "ocr" if reason else "text", "reason": reason, "rule": rule or "text-fast", "sig": sig, "text": text} _trace({"stage": "route", "page": p, "signals": sig, "rule": d["rule"], "route": d["route"], "reason": reason}) return d def _text_body(path, p, pagew, text): """Body text of a text page: reflowed when two-column, otherwise the raw text layer.""" boxes = _word_boxes(path, p) split = _detect_columns(boxes, pagew) return _reorder_columns(boxes, split) if split else text def _overview_page(path, p, pagew, pageh): """auto overview (cheap; runs no OCR and no vision). Returns (body, tag, is_text). 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.""" d = _pdf_route_decide(path, p, pagew, pageh) if d["route"] == "ocr": return ("", f" | not read: {d['reason']} — read via read_file with " f"pages={p} and pdf_mode=image", False) body = _text_body(path, p, pagew, d["text"]) sig = d["sig"] extras = [] if sig.get("img_cover", 0) > _PDF_INLINE_IMG_COVER: extras.append(f"figure not read: embedded image (covers {sig['img_cover']:.0%} of page)" f" — read via read_file with pages={p} and pdf_mode=image") if sig.get("vec_ops", 0) > _PDF_DRAW_OPS_FLOOR: extras.append(f"draw ops = {sig['vec_ops']}") return (body, (" | " + " | ".join(extras)) if extras else "", True) def _deep_page(path, p, pagew, pageh, outdir): """image deep read: runs the real logic behind the route (ocr class → OCR + figure vision; a text page with figures → text + figure vision). Returns (body, tag, is_text).""" d = _pdf_route_decide(path, p, pagew, pageh) text = d["text"] if d["route"] == "ocr": try: md = _ocr_page(path, p, outdir) except Exception as e: return ((text or "(no extractable text)") + f"\n\n`[OCR failed: {type(e).__name__}; text-layer fallback]`", " | OCR failed", False) if md is not None: return (md, " | OCR", False) note = (f"`[routed to OCR — {d['reason']}; OCR endpoint not configured " f"(set READDOC_OCR_URL). Showing text-layer fallback below.]`") return (note + ("\n\n" + text if text.strip() else ""), " | →OCR", False) # Deep-reading a text page: when the user explicitly chose image, always render the # whole page and run figures-only vision — # no longer gated on the cover threshold (a vector figure has cover=0% and still needs # reading). With no figure present the prompt returns NO_FIGURE and only the text layer remains. # The body text stays the high-quality pdftotext output; vision only adds figures and never re-transcribes the text. body = _text_body(path, p, pagew, text) if _pos.environ.get("READDOC_VISION_URL"): imgs = _pdf_to_images(path, [p]) if imgs: try: vt = _vision_read(open(imgs[0][1], "rb").read(), "image/png", question=_VISION_FIGURE_PROMPT) except Exception: vt = None if vt and "NO_FIGURE" not in vt: return (body + "\n\n`[figure on this page, read via vision]`\n\n" + vt, " | vision", False) return (body, "", True) def _map_pages(sel, fn): """Run fn(p) concurrently while preserving page order; concurrency READDOC_PDF_CONCURRENCY (default 2), applied only to pages that actually need work.""" if _PDF_CONCURRENCY <= 1 or len(sel) <= 1: return [fn(p) for p in sel] from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=_PDF_CONCURRENCY) as ex: return list(ex.map(fn, sel)) def _pdf_doc_head(path, pinfo): """Document-level bypass header (encryption / embedded attachments / AcroForm / OCG / Tagged structure). Returns a list of lines.""" head = [] if pinfo.get("Encrypted", "no").startswith("yes"): head.append("`encrypted: yes (extraction may be limited)`") atts = _attachments(path) if atts: head.append("▸ embedded files: " + ", ".join(atts)) if pinfo.get("Form", "none") not in ("none", ""): fields = _acroform_fields(path) if fields: head.append("▸ form fields:\n" + "\n".join(f" - {n}: {v}" for n, v in fields if n)) ocg = _ocg_layers(path) if ocg: head.append("▸ optional layers (OCG, may be hidden): " + ", ".join(ocg) + " — re-read with pdf_mode='image' to render a specific layer") if pinfo.get("Tagged", "no").startswith("yes"): outline = _tagged_outline(path) head.append("▸ tagged-PDF structure (reading order):\n" + outline if outline else "`tagged-PDF: yes (structure tree present)`") return head def _pdf_to_md(path, pdf_mode="auto", pages=None): total = _pdf_page_count(path) sel = _pdf_parse_pages(pages, total) span = "" if (not pages) else f" (pages {pages})" if pdf_mode == "text": # force the text layer: faithful, never OCR out = [f"(PDF: {total} pages{span})"] for p in sel: out += [f"\n\n", _pdf_page_text(path, p)] return "\n".join(out) pinfo = _pinfo(path) pagew, pageh = _page_size(pinfo) outdir = _ocr_workdir(path) head = _pdf_doc_head(path, pinfo) # auto = overview (verdict only; ocr pages are placeholders) / image = deep read (runs the real routing logic). Concurrent per page. if pdf_mode == "image": results = _map_pages(sel, lambda p: _deep_page(path, p, pagew, pageh, outdir)) mode_note = f"deep read (pdf_mode=image) — {len(sel)} page(s) executed" else: results = _map_pages(sel, lambda p: _overview_page(path, p, pagew, pageh)) mode_note = ("overview (pdf_mode=auto) — figure/scan pages are flagged, not read; " "re-read a flagged page with pages=N and pdf_mode=image") bodies = [r[0] for r in results] tags = [r[1] for r in results] is_text = [r[2] for r in results] # Header/footer dedup: only between text pages (placeholder / OCR / vision pages stay out, to avoid false positives) text_idx = [i for i, t in enumerate(is_text) if t] header, footer, sub_clean = _dedup_headers([bodies[i] for i in text_idx]) for j, i in enumerate(text_idx): bodies[i] = sub_clean[j] out = [f"(PDF: {total} pages{span}) — {mode_note}"] if head: out.append("\n```meta\n" + "\n".join(head) + "\n```") if header: out.append(f"\n`running header (all pages)`: {header}") if footer: out.append(f"`running footer (all pages)`: {footer}") for p, body, tag in zip(sel, bodies, tags, strict=False): out += [f"\n\n", body] return "\n".join(out)