Cluster sibling text lines that share roughly the same vertical band. PDFs created by LaTeX often emit one PyMuPDF "line" per cell, so a single visual row of a table is split into many independent line records. We merge lines whose ``y0`` falls within ``y_tolerance`` points of the row
(
lines: list[dict], *, y_tolerance: float = 2.0
)
| 691 | |
| 692 | |
| 693 | def _cluster_lines_into_rows( |
| 694 | lines: list[dict], *, y_tolerance: float = 2.0 |
| 695 | ) -> list[dict]: |
| 696 | """Cluster sibling text lines that share roughly the same vertical band. |
| 697 | |
| 698 | PDFs created by LaTeX often emit one PyMuPDF "line" per cell, so a single |
| 699 | visual row of a table is split into many independent line records. We |
| 700 | merge lines whose ``y0`` falls within ``y_tolerance`` points of the row |
| 701 | seed so that downstream heuristics can reason about a true logical row. |
| 702 | |
| 703 | Each output record:: |
| 704 | |
| 705 | { |
| 706 | "bbox": (x0, y0, x1, y1), # union of all member bboxes |
| 707 | "tokens": [str, ...], # text content of each member, left-to-right |
| 708 | "text": str, # tokens joined by single spaces |
| 709 | "members": [dict, ...], # original line records inside the row |
| 710 | } |
| 711 | """ |
| 712 | rows: list[dict] = [] |
| 713 | sorted_lines = sorted(lines, key=lambda r: (r["bbox"][1], r["bbox"][0])) |
| 714 | for line in sorted_lines: |
| 715 | bx0, by0, bx1, by1 = line["bbox"] |
| 716 | placed = False |
| 717 | for row in rows: |
| 718 | rx0, ry0, rx1, ry1 = row["bbox"] |
| 719 | row_mid = (ry0 + ry1) / 2.0 |
| 720 | line_mid = (by0 + by1) / 2.0 |
| 721 | if abs(line_mid - row_mid) <= y_tolerance: |
| 722 | row["bbox"] = ( |
| 723 | min(rx0, bx0), |
| 724 | min(ry0, by0), |
| 725 | max(rx1, bx1), |
| 726 | max(ry1, by1), |
| 727 | ) |
| 728 | row["members"].append(line) |
| 729 | placed = True |
| 730 | break |
| 731 | if not placed: |
| 732 | rows.append({ |
| 733 | "bbox": (bx0, by0, bx1, by1), |
| 734 | "members": [line], |
| 735 | }) |
| 736 | |
| 737 | for row in rows: |
| 738 | row["members"].sort(key=lambda m: m["bbox"][0]) |
| 739 | row["tokens"] = [m["text"] for m in row["members"]] |
| 740 | row["text"] = " ".join(row["tokens"]) |
| 741 | rows.sort(key=lambda r: r["bbox"][1]) |
| 742 | return rows |
| 743 | |
| 744 | |
| 745 | def _row_is_table_like(row: dict) -> bool: |
no outgoing calls
no test coverage detected