From 78b9973123a029f1649e30235564e264856d5624 Mon Sep 17 00:00:00 2001 From: LLLin000 <809867916@qq.com> Date: Thu, 2 Jul 2026 19:06:06 +0800 Subject: [PATCH] fix: reference-zone ordering fixes (P0-P4) P0: disambiguate table field keys from block_page_by_id in ocr_render.py P1: add _is_tail_backmatter_continuation() guard, order backmatter correctly P2: phase 4b tail nonref guard with tail_backmatter_blocks list P3: page-continuation-marker false reference gate in ocr_families.py P4: force reference heading before same-page refs, ref_item bypass usable gate --- paperforge/worker/ocr_families.py | 27 ++++ paperforge/worker/ocr_render.py | 173 +++++++++++++++++--- tests/test_ocr_families.py | 28 ++++ tests/test_ocr_render.py | 258 ++++++++++++++++++++++++++++++ 4 files changed, 465 insertions(+), 21 deletions(-) diff --git a/paperforge/worker/ocr_families.py b/paperforge/worker/ocr_families.py index a4b28f3c..b575e64f 100644 --- a/paperforge/worker/ocr_families.py +++ b/paperforge/worker/ocr_families.py @@ -40,8 +40,26 @@ _EDITORIAL_TAIL_PHRASES = ( "published online", ) +_PAGE_CONTINUATION_MARKER_PATTERN = re.compile( + r"^(?:page\s*)?\d{1,4}\s*(?:[\.\-–—]?\s+of\s+|/)\s*\d{1,4}$", + re.IGNORECASE, +) +def _looks_like_page_continuation_marker(block: dict) -> bool: + text = str(block.get("text") or block.get("block_content") or "").strip() + if not _PAGE_CONTINUATION_MARKER_PATTERN.match(text): + return False + + bbox = block.get("bbox") or block.get("block_bbox") or [] + width = float(bbox[2]) - float(bbox[0]) if len(bbox) >= 4 else None + layout_sig = block.get("layout_signature") or {} + if layout_sig.get("width") is not None: + width = float(layout_sig["width"]) + + if width is not None: + return width < 180 + return len(text.split()) <= 3 _TABLE_PREFIX_LIKE = re.compile( rf"^(?:Table|Supplementary\s+Table|Extended\s+Data\s+Table)\s*" rf"(?:S\.?\s*)?(?:\d+(?:\.\d+)?|[IVXLCDM]+)\b", @@ -321,6 +339,9 @@ def _anchor_matches_block( def _reference_anchor_matches_block(anchor: dict[str, Any], block: dict) -> bool: + if _looks_like_page_continuation_marker(block): + return False + if not _anchor_matches_block(anchor, block, _reference_family_key): return False marker_type = ((block.get("marker_signature") or {}).get("type") or "none") @@ -350,6 +371,9 @@ def _has_reference_text_structure(block: dict) -> bool: if not text: return False + if _looks_like_page_continuation_marker(block): + return False + if re.match(r"^\[\d+\]", text): return True if re.match(r"^\d+[\.)]", text): @@ -463,6 +487,9 @@ def _word_count(block: dict) -> int: def _is_reference_family_candidate(block: dict) -> bool: + if _looks_like_page_continuation_marker(block): + return False + marker_type = ((block.get("marker_signature") or {}).get("type") or "none") if marker_type not in _REFERENCE_MARKER_TYPES: return False diff --git a/paperforge/worker/ocr_render.py b/paperforge/worker/ocr_render.py index 108759b6..1b658993 100644 --- a/paperforge/worker/ocr_render.py +++ b/paperforge/worker/ocr_render.py @@ -489,6 +489,21 @@ def _ref_number_sort_key(block: dict) -> tuple: return (0, int(m.group(1) or m.group(2))) return (1, text) +_TAIL_BACKMATTER_CONTINUATION_PATTERN = re.compile( + r"^(?:address correspondence|correspondence|e-?mail|email|" + r"received|accepted|published|available online|" + r"author contributions?|conflicts? of interest|funding|" + r"acknowledg(?:e)?ments?)\b", + re.IGNORECASE, +) + + +def _is_tail_backmatter_continuation(block: dict) -> bool: + zone = str(block.get("zone") or "") + if zone == "tail_nonref_hold_zone": + return True + text = str(block.get("text") or block.get("block_content") or "").strip() + return bool(_TAIL_BACKMATTER_CONTINUATION_PATTERN.match(text)) def _reorder_tail_run( tail_blocks: list[dict], @@ -523,13 +538,30 @@ def _reorder_tail_run( # Column-sort is maintained; ref items are grouped at the end. if skip_section_grouping: ref_roles = frozenset({"reference_heading", "reference_item", "reference_body"}) - backmatter_roles = frozenset({"backmatter_body", "backmatter_heading"}) - non_ref = [b for b in tail_blocks if b.get("role") not in ref_roles and b.get("role") not in backmatter_roles and b.get("role") != "footnote"] + backmatter_roles = frozenset({ + "backmatter_body", + "backmatter_heading", + "backmatter_boundary_heading", + }) + + non_ref_all = [ + b for b in tail_blocks + if b.get("role") not in ref_roles + and b.get("role") not in backmatter_roles + and b.get("role") != "footnote" + ] + refs = [b for b in tail_blocks if b.get("role") in ref_roles] refs.sort(key=_ref_number_sort_key) + backmatter = [b for b in tail_blocks if b.get("role") in backmatter_roles] fnotes = [b for b in tail_blocks if b.get("role") == "footnote"] - return non_ref + refs + backmatter + fnotes, carried_ref, carried_backmatter + + tail_backmatter = [b for b in non_ref_all if _is_tail_backmatter_continuation(b)] + ordinary_non_ref = [b for b in non_ref_all if not _is_tail_backmatter_continuation(b)] + + return (ordinary_non_ref + refs + backmatter + tail_backmatter + fnotes, + carried_ref, carried_backmatter) # Quick check: do blocks have bbox data? If not, use FIFO fallback. has_geo = any( @@ -559,10 +591,7 @@ def _reorder_tail_run( elif role == "reference_heading": ref_section = {"heading": block, "bodies": []} elif role == "reference_item": - if _is_in_usable_content(block, header_band, footer_band): - ref_items.append(block) - else: - non_tail_pass.append(block) + ref_items.append(block) elif role == "backmatter_body": if _is_in_usable_content(block, header_band, footer_band): body_pool.append(block) @@ -573,6 +602,8 @@ def _reorder_tail_run( idx = _find_owning_heading(block, backmatter_sections, page_width) if backmatter_sections else None if idx is not None: backmatter_sections[idx]["bodies"].append(block) + elif _is_tail_backmatter_continuation(block): + body_pool.append(block) else: non_tail_pass.append(block) else: @@ -618,6 +649,7 @@ def _reorder_tail_run( # Phase 4b — geometric body attachment for remaining bodies first_local_anchor_top: float | None = None + tail_backmatter_blocks: list[dict] = [] local_heading_tops = [] for sec in backmatter_sections: bbox = sec["heading"].get("bbox") or sec["heading"].get("block_bbox") @@ -634,7 +666,13 @@ def _reorder_tail_run( idx = _find_owning_heading(body, backmatter_sections, page_width) if idx is not None: backmatter_sections[idx]["bodies"].append(body) - elif carried_backmatter is not None: + continue + + if _is_tail_backmatter_continuation(body): + tail_backmatter_blocks.append(body) + continue + + if carried_backmatter is not None: bbox = body.get("bbox") or body.get("block_bbox") if bbox and len(bbox) >= 4: body_top = bbox[1] @@ -643,7 +681,8 @@ def _reorder_tail_run( ): carried_bodies.append(body) continue - elif ref_section is not None and not _needs_synthetic_ref: + + if ref_section is not None and not _needs_synthetic_ref: if ref_section is carried_ref: ref_section["bodies"].append(body) orphan_blocks.append(body) @@ -677,6 +716,7 @@ def _reorder_tail_run( if ref_section.get("bodies"): ref_section["bodies"].sort(key=_ref_number_sort_key) result.extend(ref_section["bodies"]) + result.extend(tail_backmatter_blocks) result.extend(footnote_blocks) result.extend(orphan_blocks) @@ -774,6 +814,26 @@ def _sort_blocks_by_column(blocks: list[dict], page_width: int) -> list[dict]: return sorted(blocks, key=_column_key) + +def _force_reference_heading_before_same_page_refs(page_blocks: list[dict]) -> list[dict]: + """Reorder page blocks so reference_item/reference_body always follow + the first reference_heading on the same page. Non-ref blocks are unchanged.""" + headings = [b for b in page_blocks if b.get("role") == "reference_heading"] + if not headings: + return page_blocks + + ref_roles = {"reference_item", "reference_body"} + refs = [b for b in page_blocks if b.get("role") in ref_roles] + if not refs: + return page_blocks + + first_heading = headings[0] + heading_idx = page_blocks.index(first_heading) + + before = [b for b in page_blocks[:heading_idx] if b.get("role") not in ref_roles] + after = [b for b in page_blocks[heading_idx + 1:] if b.get("role") not in ref_roles] + + return before + [first_heading] + sorted(refs, key=_ref_number_sort_key) + after def _order_tail_blocks( blocks: list[dict], style_profiles: dict | None = None, *, has_verified_reference_zone: bool = False ) -> list[dict]: @@ -823,7 +883,17 @@ def _order_tail_blocks( page_widths.setdefault(page, pw) if not tail_pages: - return blocks + result: list[dict] = [] + by_page: dict[int, list[dict]] = {} + for block in blocks: + p = block.get("page") + if p is None: + result.append(block) + else: + by_page.setdefault(p, []).append(block) + for page in sorted(by_page): + result.extend(_force_reference_heading_before_same_page_refs(by_page[page])) + return result # Group blocks by page by_page: dict[int, list[dict]] = {} @@ -843,7 +913,8 @@ def _order_tail_blocks( if page in tail_pages: pw = page_widths.get(page, 1200) sorted_blocks = _sort_blocks_by_column(page_blocks, pw) - _page_has_ref_items = any(b.get("role") == "reference_item" for b in sorted_blocks) + sorted_blocks = _force_reference_heading_before_same_page_refs(sorted_blocks) + _page_has_ref_items = any(b.get("role") in {"reference_item", "reference_body"} for b in sorted_blocks) _page_has_ref_heading = any(b.get("role") == "reference_heading" for b in sorted_blocks) ordered, carried_ref, carried_backmatter = _reorder_tail_run( sorted_blocks, @@ -986,6 +1057,18 @@ def _emit_reader_figures_before_references( lines.append("") +def _add_consumed_key( + keys: set[tuple[int | None, str | int]], + page: int | None, + block_id: str | int, +) -> None: + """Add a per-page consumed block key, keyed by (page, block_id).""" + if block_id is None or block_id == "": + return + keys.add((page, block_id)) + keys.add((page, str(block_id))) + + def render_fulltext_markdown( *, structured_blocks: list[dict], @@ -1003,23 +1086,71 @@ def render_fulltext_markdown( reader_figures = (reader_payload or {}).get("reader_figures", []) consumed_caption_keys: set[tuple[int | None, int | str]] = set() consumed_caption_ids_unkeyed: set[int | str] = set() - consumed_table_block_keys: set[tuple[int | None, str | int]] = set() - # Map both raw id and str(id) because note_block_ids are stringified in - # build_table_inventory while structured_blocks may carry int block_ids. - block_page_by_id: dict[str | int, int | None] = {} + # Build page-block_id index from structured blocks for fallback resolution. + page_block_ids: dict[int | None, set[str | int]] = {} for block in structured_blocks: bid = block.get("block_id") if bid is None: continue page = block.get("page") - block_page_by_id[bid] = page - block_page_by_id[str(bid)] = page + page_block_ids.setdefault(page, set()).add(bid) + page_block_ids.setdefault(page, set()).add(str(bid)) + + consumed_table_block_keys: set[tuple[int | None, str | int]] = set() for table in table_inventory.get("tables", []): - for block_id in table.get("consumed_block_ids", []): - if not block_id: + table_page = table.get("page") + + # Build explicit page mapping from table inventory fields. + # These fields know each block's true page and disambiguate + # cross-page collisions (same block_id on different pages). + explicit_id_page: dict[str | int, int | None] = {} + + def _map_eid(bid, page): + if bid is not None and bid != "": + explicit_id_page[bid] = page + explicit_id_page[str(bid)] = page + + _map_eid(table.get("caption_block_id"), table_page) + + for seg in table.get("segments", []): + _map_eid(seg.get("asset_block_id"), seg.get("page") or table_page) + + for note_bid in table.get("note_block_ids", []): + if note_bid is not None and note_bid != "": + # Notes may be on a different page — resolve from structured blocks + note_page = None + for p, ids in page_block_ids.items(): + if note_bid in ids or str(note_bid) in ids: + note_page = p + break + _map_eid(note_bid, note_page or table_page) + + for br_bid in table.get("bridge_block_ids", []): + _map_eid(br_bid, table_page) + + for bid in table.get("consumed_block_ids", []): + if bid is None or bid == "": continue - page = block_page_by_id.get(block_id, table.get("page")) - consumed_table_block_keys.add((page, block_id)) + # Try explicit mapping first (handles cross-page assets and notes) + page = explicit_id_page.get(bid) or explicit_id_page.get(str(bid)) + if page is not None: + _add_consumed_key(consumed_table_block_keys, page, bid) + continue + + # Not in explicit fields — fall back to structured block lookup + str_bid = str(bid) + table_ids = page_block_ids.get(table_page, set()) + if bid in table_ids or str_bid in table_ids: + _add_consumed_key(consumed_table_block_keys, table_page, bid) + else: + found = False + for p, ids in page_block_ids.items(): + if bid in ids or str_bid in ids: + _add_consumed_key(consumed_table_block_keys, p, bid) + found = True + break + if not found: + _add_consumed_key(consumed_table_block_keys, table_page, bid) for item in (reader_payload or {}).get("consumed_caption_block_ids", []): if isinstance(item, dict): page = item.get("page") diff --git a/tests/test_ocr_families.py b/tests/test_ocr_families.py index e22e4017..b0b9c4d5 100644 --- a/tests/test_ocr_families.py +++ b/tests/test_ocr_families.py @@ -780,3 +780,31 @@ def test_table_roman_prefix_family_not_reference_like(): family, authority = _classify_style_family(block, {}, {}) assert family == "table_caption_like" assert authority == "table_marker" + + +def test_page_continuation_marker_is_not_reference(): + """Page continuation markers like "13 of 18", "13. of 18", "Page 13 of 18", "13/18" should NOT be classified as reference family.""" + from paperforge.worker.ocr_families import _is_reference_family_candidate + + markers = [ + {"text": "13 of 18", "bbox": [0, 0, 60, 10], "marker_signature": {"type": "reference_numeric_dot"}, "span_signature": {"font_size_median": 9.0}, "layout_signature": {"width": 60}}, + {"text": "13. of 18", "bbox": [0, 0, 65, 10], "marker_signature": {"type": "reference_numeric_dot"}, "span_signature": {"font_size_median": 9.0}, "layout_signature": {"width": 65}}, + {"text": "Page 13 of 18", "bbox": [0, 0, 90, 10], "marker_signature": {"type": "reference_numeric_dot"}, "span_signature": {"font_size_median": 9.0}, "layout_signature": {"width": 90}}, + {"text": "13/18", "bbox": [0, 0, 40, 10], "marker_signature": {"type": "reference_numeric_dot"}, "span_signature": {"font_size_median": 9.0}, "layout_signature": {"width": 40}}, + ] + for marker in markers: + assert not _is_reference_family_candidate(marker), f"'{marker['text']}' should not be reference family" + + +def test_real_reference_is_still_reference(): + """Real references like "13. Smith J, Wang L. Cartilage repair in 2018." should still be classified as reference family.""" + from paperforge.worker.ocr_families import _is_reference_family_candidate + + ref = { + "text": "13. Smith J, Wang L. Cartilage repair in 2018. Journal of Orthopaedic Research.", + "bbox": [100, 0, 500, 20], + "marker_signature": {"type": "reference_numeric_dot"}, + "span_signature": {"font_size_median": 10.0}, + "layout_signature": {"width": 400}, + } + assert _is_reference_family_candidate(ref), "Real reference should still be classified as reference family" diff --git a/tests/test_ocr_render.py b/tests/test_ocr_render.py index a90a1c98..69503360 100644 --- a/tests/test_ocr_render.py +++ b/tests/test_ocr_render.py @@ -537,3 +537,261 @@ def test_materialized_table_caption_continuation_is_skipped_by_render_when_consu ) assert "Structural parameters of nanocomposites" not in md + +def test_cross_page_table_asset_id_does_not_consume_same_id_on_caption_page(): + """ + Caption page has block_id=7 reference_item. + Table asset on previous page also has block_id=7. + Explicit segment consumes only previous-page asset, not caption-page ref. + """ + from paperforge.worker.ocr_render import render_fulltext_markdown + + table_inventory = { + "tables": [ + { + "page": 5, + "caption_block_id": 10, + "segments": [ + {"page": 4, "asset_block_id": 7}, + ], + "note_block_ids": [11], + "bridge_block_ids": [12], + "consumed_block_ids": [7, 10, 11, 12], + } + ] + } + + structured_blocks = [ + # Table asset on page 4, block_id=7 + {"block_id": 7, "page": 4, "role": "table_asset", "text": "[Table 1]"}, + # Reference item on page 5, block_id=7 — must NOT be consumed + {"block_id": 7, "page": 5, "role": "reference_item", "text": "7. Smith J...", "bbox": [0, 0, 500, 20]}, + ] + + result = render_fulltext_markdown( + structured_blocks=structured_blocks, + table_inventory=table_inventory, + resolved_metadata={}, + figure_inventory={"matched_figures": [], "unmatched_assets": [], "unresolved_clusters": []}, + page_count=10, + ) + + assert "7. Smith J..." in result, "Reference on caption page should not be consumed" + + +def test_table_consumed_block_ids_do_not_drop_same_id_reference_on_later_page(): + """Reference on later page with same block_id as earlier table must not be dropped.""" + from paperforge.worker.ocr_render import render_fulltext_markdown + + table_inventory = { + "tables": [ + { + "page": 1, + "caption_block_id": 5, + "segments": [{"page": 1, "asset_block_id": 6}], + "note_block_ids": [], + "bridge_block_ids": [], + "consumed_block_ids": [5, 6], + } + ] + } + + structured_blocks = [ + {"block_id": 5, "page": 1, "role": "table_caption", "text": "Table 1. Results"}, + {"block_id": 6, "page": 1, "role": "table_asset", "text": "[data]"}, + # Same block_id=5 on page 2 — must NOT be consumed + {"block_id": 5, "page": 2, "role": "reference_item", "text": "5. Author...", "bbox": [0, 0, 500, 20]}, + ] + + result = render_fulltext_markdown( + structured_blocks=structured_blocks, + table_inventory=table_inventory, + resolved_metadata={}, + figure_inventory={"matched_figures": [], "unmatched_assets": [], "unresolved_clusters": []}, + page_count=10, + ) + + assert "5. Author..." in result, "Reference on later page with same block_id should not be consumed" + + +def test_table_caption_still_consumed(): + """Table caption blocks on same page as table must still be consumed.""" + from paperforge.worker.ocr_render import render_fulltext_markdown + + table_inventory = { + "tables": [ + { + "page": 1, + "caption_block_id": 5, + "segments": [{"page": 1, "asset_block_id": 6}], + "note_block_ids": [], + "bridge_block_ids": [], + "consumed_block_ids": [5, 6], + } + ] + } + + structured_blocks = [ + {"block_id": 5, "page": 1, "role": "table_caption", "text": "Table 1. Results"}, + {"block_id": 6, "page": 1, "role": "table_asset", "text": "[data]"}, + ] + + result = render_fulltext_markdown( + structured_blocks=structured_blocks, + table_inventory=table_inventory, + resolved_metadata={}, + figure_inventory={"matched_figures": [], "unmatched_assets": [], "unresolved_clusters": []}, + page_count=10, + ) + + assert "Table 1. Results" not in result, "Table caption should be consumed (excluded from output)" + assert "[data]" not in result, "Table asset should be consumed" + +def test_skip_section_grouping_places_tail_nonref_after_refs(): + """When skip_section_grouping=True, tail backmatter (Address correspondence) must appear AFTER refs.""" + from paperforge.worker.ocr_render import _reorder_tail_run + + ref_block = {"role": "reference_item", "text": "1. Author A. Title. Journal. 2024.", "bbox": [100, 0, 500, 20], "block_number": "1"} + tail_block = {"role": "body_paragraph", "text": "Address correspondence to: Dr. Smith", "bbox": [100, 50, 500, 70], "zone": "tail_nonref_hold_zone"} + heading = {"role": "backmatter_heading", "text": "Correspondence", "bbox": [100, -10, 500, 10]} + + result, ref, bm = _reorder_tail_run( + [ref_block, tail_block, heading], + carried_ref=None, + carried_backmatter=None, + page_width=800, + skip_section_grouping=True, + ) + texts = [b.get("text", "") for b in result] + ref_idx = texts.index("1. Author A. Title. Journal. 2024.") + tail_idx = texts.index("Address correspondence to: Dr. Smith") + assert tail_idx > ref_idx, f"Tail block at {tail_idx} should be after ref at {ref_idx}" + + +def test_skip_section_grouping_keeps_ordinary_nonref_before_refs(): + """Ordinary non-ref blocks (not tail backmatter) must remain BEFORE refs.""" + from paperforge.worker.ocr_render import _reorder_tail_run + + ordinary = {"role": "body_paragraph", "text": "This is some ordinary body text.", "bbox": [100, 0, 500, 20]} + ref_block = {"role": "reference_item", "text": "1. Author A. Title. 2024.", "bbox": [100, 50, 500, 70], "block_number": "1"} + + result, ref, bm = _reorder_tail_run( + [ordinary, ref_block], + carried_ref=None, + carried_backmatter=None, + page_width=800, + skip_section_grouping=True, + ) + texts = [b.get("text", "") for b in result] + ordinary_idx = texts.index("This is some ordinary body text.") + ref_idx = texts.index("1. Author A. Title. 2024.") + assert ordinary_idx < ref_idx, f"Ordinary block at {ordinary_idx} should be before ref at {ref_idx}" + + +def test_real_backmatter_heading_still_attaches_its_own_body(): + """A real backmatter heading like 'FUNDING' must still have its body attached (not orphaned).""" + from paperforge.worker.ocr_render import _reorder_tail_run + + heading = {"role": "backmatter_heading", "text": "FUNDING", "bbox": [100, 0, 500, 20]} + body = {"role": "backmatter_body", "text": "This work was supported by NIH grant R01...", "bbox": [100, 25, 500, 45]} + ref_block = {"role": "reference_item", "text": "1. Author A. 2024.", "bbox": [100, 100, 500, 120], "block_number": "1"} + + result, ref, bm = _reorder_tail_run( + [heading, body, ref_block], + carried_ref=None, + carried_backmatter=None, + page_width=800, + skip_section_grouping=False, + ) + texts = [b.get("text", "") for b in result] + assert "FUNDING" in texts + assert "This work was supported by NIH grant R01..." in texts + funding_idx = texts.index("FUNDING") + body_idx = texts.index("This work was supported by NIH grant R01...") + assert body_idx > funding_idx, "Funding body should be after Funding heading" + + +def test_phase_4b_does_not_swallow_tail_nonref_into_ref_section(): + """Phase 4b must not place tail backmatter continuations into ref_section["bodies"].""" + from paperforge.worker.ocr_render import _reorder_tail_run + + ref_heading = {"role": "reference_heading", "text": "References", "bbox": [100, 0, 500, 20]} + ref_item = {"role": "reference_item", "text": "1. Author. Title. 2024.", "bbox": [100, 25, 500, 45], "block_number": "1"} + tail_body = {"role": "body_paragraph", "text": "Address correspondence to: Dr. Smith", "bbox": [100, 100, 500, 120], "zone": "tail_nonref_hold_zone"} + + result, ref, bm = _reorder_tail_run( + [ref_heading, ref_item, tail_body], + carried_ref=None, + carried_backmatter=None, + page_width=800, + ) + texts = [b.get("text", "") for b in result] + ref_idx = texts.index("1. Author. Title. 2024.") + tail_idx = texts.index("Address correspondence to: Dr. Smith") + assert tail_idx > ref_idx, f"Tail backmatter at {tail_idx} should be after ref at {ref_idx}" + + +def test_funding_body_still_attaches_to_funding_heading(): + """A real funding body must still attach to its heading even with tail_backmatter_blocks logic.""" + from paperforge.worker.ocr_render import _reorder_tail_run + + funding_heading = {"role": "backmatter_heading", "text": "FUNDING", "bbox": [100, 0, 500, 20]} + funding_body = {"role": "backmatter_body", "text": "This work was funded by...", "bbox": [100, 25, 500, 45]} + ref_item = {"role": "reference_item", "text": "1. Author. 2024.", "bbox": [100, 100, 500, 120], "block_number": "1"} + + result, ref, bm = _reorder_tail_run( + [funding_heading, funding_body, ref_item], + carried_ref=None, + carried_backmatter=None, + page_width=800, + ) + texts = [b.get("text", "") for b in result] + funding_idx = texts.index("FUNDING") + body_idx = texts.index("This work was funded by...") + assert body_idx > funding_idx, "Funding body after funding heading" + + +def test_refs_on_heading_page_are_rendered_after_heading(): + """When reference_item blocks appear before reference_heading on the same + page (e.g. due to column sort or PDF reading order), the helper must + reorder them so the heading comes first.""" + from paperforge.worker.ocr_render import _force_reference_heading_before_same_page_refs + + ref_heading = {"role": "reference_heading", "text": "References", "bbox": [400, 100, 500, 120]} + ref_item = {"role": "reference_item", "text": "1. Author A. Title. 2024.", "bbox": [100, 0, 300, 20], "block_number": "1"} + body_block = {"role": "body_paragraph", "text": "Some body text.", "bbox": [100, 50, 300, 70]} + + # Input has ref_item BEFORE ref_heading (bad ordering) + result = _force_reference_heading_before_same_page_refs([ref_item, ref_heading, body_block]) + + texts = [b.get("text", "") for b in result] + heading_idx = texts.index("References") + ref_idx = texts.index("1. Author A. Title. 2024.") + body_idx = texts.index("Some body text.") + + assert heading_idx < ref_idx, f"Heading at {heading_idx} should be before ref at {ref_idx}" + assert body_idx < heading_idx or body_idx > ref_idx, f"Body at {body_idx} should stay in its original position relative to heading/ref" + + +def test_non_ref_blocks_not_moved_by_guard(): + """Blocks without reference role should not be moved or duplicated by + the force-reorder guard.""" + from paperforge.worker.ocr_render import _force_reference_heading_before_same_page_refs + + body1 = {"role": "body_paragraph", "text": "First body.", "bbox": [100, 0, 300, 20]} + body2 = {"role": "body_paragraph", "text": "Second body.", "bbox": [100, 30, 300, 50]} + ref_heading = {"role": "reference_heading", "text": "References", "bbox": [400, 100, 500, 120]} + ref_item = {"role": "reference_item", "text": "1. Author. 2024.", "bbox": [100, 60, 300, 80], "block_number": "1"} + + result = _force_reference_heading_before_same_page_refs([body1, ref_item, ref_heading, body2]) + + texts = [b.get("text", "") for b in result] + assert "First body." in texts + assert "Second body." in texts + assert "References" in texts + assert "1. Author. 2024." in texts + # Body blocks must not be duplicated + assert texts.count("First body.") == 1 + assert texts.count("Second body.") == 1 + assert texts.count("References") == 1 + assert texts.count("1. Author. 2024.") == 1