Return caption anchors sorted top-to-bottom by their y0 coordinate. Each anchor contains the full multi-line caption bbox so that the downstream crop includes the entire caption text, not just the first line. Each anchor:: { "label": "Figure 3", "kind":
(page)
| 297 | # --------------------------------------------------------------------------- |
| 298 | |
| 299 | def _find_caption_blocks(page) -> list[dict]: |
| 300 | """Return caption anchors sorted top-to-bottom by their y0 coordinate. |
| 301 | |
| 302 | Each anchor contains the full multi-line caption bbox so that the |
| 303 | downstream crop includes the entire caption text, not just the first line. |
| 304 | |
| 305 | Each anchor:: |
| 306 | |
| 307 | { |
| 308 | "label": "Figure 3", |
| 309 | "kind": "figure" | "table", |
| 310 | "bbox": (x0, y0, x1, y1), |
| 311 | "line_text": ..., |
| 312 | } |
| 313 | """ |
| 314 | anchors: list[dict] = [] |
| 315 | blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"] |
| 316 | for block in blocks: |
| 317 | if block.get("type") != 0: |
| 318 | continue |
| 319 | lines = block.get("lines", []) |
| 320 | for line_idx, line in enumerate(lines): |
| 321 | spans = line.get("spans", []) |
| 322 | if not spans: |
| 323 | continue |
| 324 | line_text = "".join(s.get("text", "") for s in spans).strip() |
| 325 | match = CAPTION_RE.match(line_text) |
| 326 | if not match or _caption_match_is_inline_reference(line_text, match): |
| 327 | continue |
| 328 | label = normalize_whitespace(match.group(1)) |
| 329 | kind = _classify_caption_kind(label) |
| 330 | |
| 331 | caption_lines_text = [line_text] |
| 332 | first_bbox = line["bbox"] |
| 333 | x0, y0, x1, y1 = first_bbox |
| 334 | prev_line_bottom = first_bbox[3] |
| 335 | line_height = max(first_bbox[3] - first_bbox[1], 6.0) |
| 336 | |
| 337 | for cont_line in lines[line_idx + 1:]: |
| 338 | cont_spans = cont_line.get("spans", []) |
| 339 | if not cont_spans: |
| 340 | break |
| 341 | cont_text = "".join(s.get("text", "") for s in cont_spans).strip() |
| 342 | if not cont_text: |
| 343 | break |
| 344 | if CAPTION_RE.match(cont_text): |
| 345 | break |
| 346 | if _looks_like_data_row(cont_text): |
| 347 | break |
| 348 | cb = cont_line["bbox"] |
| 349 | # Stop merging if the next line is too far below the previous one |
| 350 | # (it is then a separate paragraph, not a caption continuation). |
| 351 | if cb[1] - prev_line_bottom > line_height * 1.6: |
| 352 | break |
| 353 | x0 = min(x0, cb[0]) |
| 354 | y1 = max(y1, cb[3]) |
| 355 | x1 = max(x1, cb[2]) |
| 356 | prev_line_bottom = cb[3] |