"""Cross-page article-continuation linker (classical, no Claude / no API). Telugu papers "jump" long stories across pages: • bottom of the source article : 'మిగతా వ పేజీలో...' (rest is on page N) • top of the continuation page : the SAME headline repeated, plus the marker '(మొదటి పేజీ తరువాయి)' (continued from page 1) We REQUIRE BOTH signals: the jump-from marker on the source page (which also tells us the target page N) AND a repeated headline on page N that matches the source article's headline. We then build a boundary box for the continuation by injecting a SYNTHETIC dateline anchored on that repeated headline and running the existing boundary engine (so the continuation floors correctly against page-N's own articles), and finally STITCH the source crop + continuation crop into ONE tall image per article. Pure geometry + Tesseract on saved fixtures (page PNGs + regions.json + the per-article info.json the pipeline already wrote). No Claude. link_continuations(run_dir) -> list of link records (also written to /continuations/links.json, stitched PNGs alongside). """ import difflib import json import re import unicodedata from pathlib import Path from PIL import Image from _lines import detect_separator_lines try: from smart_extractor import _dateline_in_text except Exception: # keep the module importable in isolation def _dateline_in_text(_t, _paper=None): return None _ZW = "‌‍" # zero-width non-joiner / joiner _TERMINATORS = ".।?!" # sentence-enders: full stop, danda, ?, ! _TELUGU_DIGITS = {ord("౦") + i: str(i) for i in range(10)} # jump-FROM: 'మిగతా వ పేజీ...' — tolerate OCR noise between the tokens. _JUMP_FROM = re.compile(r"మిగ[తథధ]ా.{0,10}?([0-9౦-౯]{1,2})\s*వ?\s*పే[జీిౌ]", re.UNICODE) # jump-TO: 'మొదటి పేజీ తరువాయి' — the continuation marker on the target page. _JUMP_TO = re.compile(r"మొదట[ిి]?\s*పే[జీి].{0,6}?తరు[వ]?ా[యి]", re.UNICODE) def _paper_of_height(h): if 7040 <= h <= 7060: return "sakshi" if h in (6833, 6549): return "andhra_jyothi" if h == 6422: return "namaste_telangana" return f"h{h}" def _ocr(page, box): import pytesseract try: return pytesseract.image_to_string(page.crop((int(box[0]), int(box[1]), int(box[2]), int(box[3]))), lang="tel") except Exception: return "" def _page_num_from(text): m = _JUMP_FROM.search(text or "") if not m: return None digits = m.group(1).translate(_TELUGU_DIGITS) dm = re.search(r"\d+", digits) if not dm: return None d = dm.group() # OCR frequently DOUBLE-RECOGNISES a single glyph as the Latin digit followed # by its Telugu twin (e.g. '3౩' → '33'). District editions only ever run a # handful of pages, so a run of identical digits is that artefact, not page 33. if len(d) == 2 and d[0] == d[1]: d = d[0] return int(d) def _norm(s): s = unicodedata.normalize("NFC", s or "") s = "".join(c for c in s if c not in _ZW) return re.sub(r"[\s.,!?:;\-()।\"'‘’]", "", s) def _similar(a, b): a, b = _norm(a), _norm(b) if not a or not b: return 0.0 return difflib.SequenceMatcher(None, a, b).ratio() def _find_jump_from(page, art_bbox): """Scan the BOTTOM band of the source article for the 'మిగతా Nవ పేజీలో' marker; fall back to the whole article box. Returns the target page number or None.""" l, t, r, b = art_bbox band_top = max(t, b - 340) n = _page_num_from(_ocr(page, (l, band_top, r, b))) if n is None: # marker may sit a little higher n = _page_num_from(_ocr(page, (l, t, r, b))) return n def _best_headline(regs, page, headline_tel, thresh=0.60): """Among the doc_titles on the target page, return (region, score, has_marker) for the one whose OCR text best matches the source headline (>= thresh).""" best, best_score = None, 0.0 for r in regs: if r.get("type") != "doc_title": continue score = _similar(_ocr(page, r["bbox"]), headline_tel) if score > best_score: best, best_score = r, score if not best or best_score < thresh: return None, best_score, False hb = best["bbox"] below = _ocr(page, (hb[0], hb[3], hb[2], min(hb[3] + 240, page.height))) has_marker = bool(_JUMP_TO.search(below) or _JUMP_TO.search(_ocr(page, hb))) return best, best_score, has_marker def _ends_terminated(text): """True if the column's last word ends a sentence (., danda, ?, !), ignoring trailing quotes/brackets. This is the (noisy, OCR-based) 'article concluded' signal — used only AFTER the structural stop-tests have had their say.""" t = (text or "").rstrip() t = t.rstrip("\"'’”)]]》」』 ") return bool(t) and t[-1] in _TERMINATORS def _col_width(regs): """Robust single-column width = median width of the page's text boxes.""" ws = sorted(r["bbox"][2] - r["bbox"][0] for r in regs if r.get("type") == "text") return ws[len(ws) // 2] if ws else 700 def _has_body_below(regs, b): """True if a text box sits below box `b` within its horizontal span — i.e. `b` is a headline opening its OWN article (a new story), not a trailing label or an inline emphasis run. Used to let a paragraph_title act as an article floor.""" for r in regs: if r.get("type") == "text": rb = r["bbox"] if rb[1] > b[1] + 5 and rb[0] < b[2] and rb[2] > b[0]: return True return False def _is_floor_head(regs, r, hid): """A region that floors a continuation: any doc_title, OR a paragraph_title that has its own body beneath it (the start of a DIFFERENT article — e.g. a 'continued from page N' sub-head sitting below our continuation block).""" if r.get("id") == hid: return False t = r.get("type") if t == "doc_title": return True return t == "paragraph_title" and _has_body_below(regs, r["bbox"]) def _floor_under(regs, lines, hb, hid, x0, x1, H): """Lowest boundary below the header within the corridor [x0,x1]: the top of the next headline (doc_title, or an article-starting paragraph_title), or a horizontal rule, whichever comes first (else page bottom).""" f = H for r in regs: b = r["bbox"] if (_is_floor_head(regs, r, hid) and b[1] > hb[3] + 20 and b[0] < x1 and b[2] > x0): f = min(f, b[1]) for ln in (lines.get("h") or []): y = ln["y1"] if hb[3] + 20 < y < f and ln["x1"] < x1 and ln["x2"] > x0: f = min(f, y) return f def _floor_below_body(regs, lines, hb, hid, x0, x1, body, H): """Lowest article boundary in corridor [x0,x1]. A next-headline or horizontal rule only counts as the floor when NO included body text continues *below* it in an overlapping column — that distinguishes the next article's wide headline (no body beneath, within this block) from an interior sub-head / column-top rule (article body keeps going underneath it).""" def _no_body_below(bx0, bx2, yt): for bb in body: if bb[3] > yt + 10 and bb[0] < bx2 and bb[2] > bx0: return False return True f = H for r in regs: b = r["bbox"] if (_is_floor_head(regs, r, hid) and b[1] > hb[3] + 20 and b[0] < x1 and b[2] > x0 and _no_body_below(b[0], b[2], b[1])): f = min(f, b[1]) for ln in (lines.get("h") or []): y = ln["y1"] if hb[3] + 20 < y < f and ln["x1"] < x1 and ln["x2"] > x0 \ and _no_body_below(ln["x1"], ln["x2"], y): f = min(f, y) return f def _vrule_in_gutter(lines, x0, x1, y0, y1): """A printed vertical rule sitting in the gutter [x0,x1] across most of [y0,y1].""" for ln in (lines.get("v") or []): if x0 - 8 <= ln["x1"] <= x1 + 8: ov = min(ln["y2"], y1) - max(ln["y1"], y0) if ov > 0.4 * (y1 - y0): return True return False def _track_starts_with_title(regs, items, hb): """STOP-test 5: does a headline / sub-head box open the next column?""" xs = min(it["bbox"][0] for it in items) xe = max(it["bbox"][2] for it in items) ytop = min(it["bbox"][1] for it in items) for r in regs: if r.get("type") in ("doc_title", "paragraph_title"): b = r["bbox"] if b[0] < xe and b[2] > xs and b[1] > hb[3] + 20 and abs(b[1] - ytop) < 130: return True return False def _continuation_crop(regs, png_path, paper, headline_region): """Crop the FULL continuation article by GROWING RIGHT from the headline. A continuation's body is usually wider than its header and spans several columns. We start at the header's column and walk right column-by-column, absorbing the next column unless a STOP-test fires: 1 no next column 5 next column opens with a headline/sub-head 2 vertical-rule wall 6 current column's last word ends with a period 3 horizontal-rule break 7 grammatical bridge fails (approx: 4+6 absent) 4 next column opens a dateline Structural tests (1-5) outrank the OCR period (6). The crop is the single rectangle enclosing every absorbed column (+ any photo inside the block), floored at the next headline / horizontal rule. Returns (PIL.Image, debug_bbox) or (None, None).""" page = Image.open(png_path).convert("RGB") H = page.height hb = headline_region["bbox"] hid = headline_region.get("id") cx = (hb[0] + hb[2]) / 2 lines = detect_separator_lines(png_path) colW = _col_width(regs) # provisional floor under the header's own span gives us the band to cluster in F0 = _floor_under(regs, lines, hb, hid, hb[0], hb[2], H) # body text boxes in the band, clustered into left-to-right COLUMN TRACKS band = [r for r in regs if r.get("type") == "text" and (r["bbox"][1] + r["bbox"][3]) / 2 < F0 and r["bbox"][3] > hb[1] and r["bbox"][0] >= hb[0] - colW * 0.5] # drop columns left of the header if not band: return None, None tracks = [] for r in sorted(band, key=lambda r: r["bbox"][0]): for tr in tracks: if abs(r["bbox"][0] - tr["x0"]) < colW * 0.5: tr["items"].append(r) break else: tracks.append({"x0": r["bbox"][0], "items": [r]}) tracks.sort(key=lambda t: t["x0"]) # START column = the track straddling the headline centre (else nearest) start = None for i, t in enumerate(tracks): if min(it["bbox"][0] for it in t["items"]) <= cx <= max(it["bbox"][2] for it in t["items"]): start = i break if start is None: start = min(range(len(tracks)), key=lambda i: abs(tracks[i]["x0"] - cx)) import os DBG = os.environ.get("CONT_DEBUG") if DBG: print(f"[dbg] hb={hb} cx={cx} colW={colW} F0={F0}") for ti, t in enumerate(tracks): print(f"[dbg] track {ti} x0={t['x0']} items=" + ",".join(f"{it.get('id')}{it['bbox']}" for it in t['items'])) print(f"[dbg] start track = {start}") # GROW RIGHT, applying the stop-tests at each gutter included = [start] i = start while True: cur = tracks[i]["items"] cur_r = max(it["bbox"][2] for it in cur) nxt = next((j for j in range(i + 1, len(tracks)) if tracks[j]["x0"] > cur_r - colW * 0.3), None) if nxt is None: # 1 no next column if DBG: print(f"[dbg] from {i}: STOP test1 no next column") break nt = tracks[nxt]["items"] nt_l = min(it["bbox"][0] for it in nt) y0, y1 = hb[1], F0 if _vrule_in_gutter(lines, cur_r, nt_l, y0, y1): # 2 vertical wall if DBG: print(f"[dbg] from {i} to {nxt}: STOP test2 vrule wall {cur_r}-{nt_l}") break top_reg = min(nt, key=lambda it: it["bbox"][1]) tb = top_reg["bbox"] head_txt = _ocr(page, (tb[0], tb[1], tb[2], min(tb[1] + 150, tb[3]))) if _dateline_in_text(head_txt, paper): # 4 dateline opens next col if DBG: print(f"[dbg] from {i} to {nxt}: STOP test4 dateline '{head_txt[:40]}'") break if _track_starts_with_title(regs, nt, hb): # 5 headline opens next col if DBG: print(f"[dbg] from {i} to {nxt}: STOP test5 title opens next") break bot_reg = max(cur, key=lambda it: it["bbox"][3]) bot_txt = _ocr(page, bot_reg["bbox"]) if _ends_terminated(bot_txt): # 6 period concludes if DBG: print(f"[dbg] from {i} to {nxt}: STOP test6 period; tail='{bot_txt[-30:]}'") break if DBG: print(f"[dbg] grow {i} -> {nxt}") included.append(nxt) # 7 bridge holds -> grow i = nxt if DBG: print(f"[dbg] included tracks = {included}") # FINAL rectangle over the absorbed columns, floored against the real corridor left = hb[0] right = max(max(it["bbox"][2] for it in tracks[k]["items"]) for k in included) body = [it["bbox"] for k in included for it in tracks[k]["items"]] F = _floor_below_body(regs, lines, hb, hid, left, right, body, H) if DBG: print(f"[dbg] left={left} right={right} F={F}") members = list(body) for r in regs: # sweep in photos / captions inside the block if r.get("type") in ("image", "figure_title", "paragraph_title", "doc_title"): b = r["bbox"] bcx = (b[0] + b[2]) / 2 if left - 10 <= bcx <= right + 10 and b[3] <= F and b[3] > hb[1] - 320: members.append(b) left = min(left, min(m[0] for m in members)) top = min(hb[1], min(m[1] for m in members)) bot = min(int(F) - 8, max(m[3] for m in members) + 22) box = (int(left), int(max(0, top - 6)), int(right), int(bot)) return page.crop(box), list(box) def _stitch(top_png, cont_img, out_path): """Stack the source page-1 crop above the composed continuation image.""" top = Image.open(top_png).convert("RGB") W = max(top.width, cont_img.width) sep = 18 combo = Image.new("RGB", (W, top.height + sep + cont_img.height), "white") combo.paste(top, (0, 0)) combo.paste(cont_img, (0, top.height + sep)) combo.save(out_path) return combo.size def link_continuations(run_dir, out_dir=None, match_thresh=0.60, verbose=True): run = Path(run_dir) pages_dir = run / "pages" arts_dir = run / "articles" out_dir = Path(out_dir) if out_dir else (run / "continuations") out_dir.mkdir(parents=True, exist_ok=True) p1 = pages_dir / "page_001.png" paper = _paper_of_height(Image.open(p1).height) if p1.exists() else "unknown" results = [] for info_path in sorted(arts_dir.glob("*/info.json")): info = json.loads(info_path.read_text()) P, bbox = info.get("page"), info.get("bbox") headline = info.get("headline_telugu") or "" if not P or not bbox: continue # Continuations only ever START on page 1 — the front page jumps long lead # stories onto inside pages. Articles on pages 2+ are self-contained, so we # never scan them for a jump-from marker. if P != 1: continue src_png = pages_dir / f"page_{P:03d}.png" if not src_png.exists(): continue N = _find_jump_from(Image.open(src_png), bbox) # SIGNAL 1: jump-from if not N or N == P: continue # The jump-from page DIGIT is OCR-noisy (Telugu ౪/౬ confusion) and can point # at a page outside this extraction (here the front page said '6వ పేజీ' but # the run only has 4 pages). So treat the marker as "this front-page lead # continues on a later page" and resolve the ACTUAL target by the repeated- # headline signal: score every OTHER page's doc_titles against the source # headline and take the best match, breaking ties toward the OCR'd page N. existing = sorted( int(m.group(1)) for q in pages_dir.glob("page_*.png") if (m := re.match(r"page_(\d+)\.png$", q.name))) best = None # (score, page, tregs, tgt_png_str, hl, marker) for q in existing: if q == P: continue qr = pages_dir / f"page_{q:03d}.regions.json" qp = pages_dir / f"page_{q:03d}.png" if not qr.exists() or not qp.exists(): continue qregs = json.loads(qr.read_text())["regions"] hl_q, score_q, marker_q = _best_headline(qregs, Image.open(qp), headline, match_thresh) if hl_q is None: continue if best is None or score_q > best[0] or (score_q == best[0] and q == N): best = (score_q, q, qregs, str(qp), hl_q, marker_q) if best is None: rec = {"article": info_path.parent.name, "src_page": P, "headline": headline, "target_page": N, "match_score": 0.0, "taruvaayi_marker": False, "linked": False, "reason": "no matching repeated headline on any inside page"} results.append(rec) if verbose: print(f" ✗ {rec['article']} → p{N}: jump-from found but no repeated " f"headline match on any inside page (NOT linked)") continue score, N, tregs, tgt_png, hl, marker = best rec = {"article": info_path.parent.name, "src_page": P, "headline": headline, "target_page": N, "match_score": round(score, 2), "taruvaayi_marker": marker, "linked": False} cont_img, cbox = _continuation_crop(tregs, tgt_png, paper, hl) if cont_img is None: rec["reason"] = "no body under repeated headline" results.append(rec) continue out_png = out_dir / f"{info_path.parent.name}__cont_p{N:03d}.png" size = _stitch(arts_dir / info_path.parent.name / "article.png", cont_img, out_png) rec.update({"linked": True, "cont_bbox": cbox, "stitched": str(out_png), "stitched_size": size}) results.append(rec) if verbose: print(f" ✓ {rec['article']} '{headline[:26]}' → p{N} " f"(headline {score:.2f}, తరువాయి={marker}) → {out_png.name}") (out_dir / "links.json").write_text(json.dumps(results, indent=2, ensure_ascii=False)) linked = sum(1 for r in results if r["linked"]) if verbose: print(f"\n {linked} continuation(s) linked & stitched; report: {out_dir/'links.json'}") return results if __name__ == "__main__": import sys link_continuations(sys.argv[1] if len(sys.argv) > 1 else "output/20260601_234709")