lllin000_PaperForge/paperforge/worker/ocr_render.py
Research Assistant 8a0f6dd0fb feat(ocr): PeerJ-style remediation — frontmatter zoning, heading hierarchy, backmatter container
- Task 1: Strengthen first-page frontmatter zoning (author/affiliation/furniture zones)
- Task 2: Style-aware heading hierarchy with _infer_heading_level content heuristics
- Tasks 3+4: ADDITIONAL INFORMATION AND DECLARATIONS as backmatter_boundary_heading,
  container ordering (boundary heading emitted first, sub-sections after)
- Task 5: Figure legend validation via _is_formal_legend (width/criterion/axis-vocab rejection)
- Verified on 2GN9LMCW: authors correct, furniture suppressed, heading hierarchy,
  references last
- 7C8829BD regression-free: 26/26 markers, tail order intact
- 296/296 tests pass
2026-06-05 23:27:10 +08:00

1139 lines
41 KiB
Python

from __future__ import annotations
import re
from pathlib import Path
from paperforge.worker.ocr_roles import FRONTMATTER_NOISE
def _normalize_latex(text: str) -> str:
text = re.sub(r"\$\s+", "$", text)
text = re.sub(r"\s+\$", "$", text)
text = re.sub(r"\$\^\{\s+", "$^{", text)
text = re.sub(r"\s+\}\$", "}$", text)
return text
def _is_bogus_heading(text: str) -> bool:
t = text.strip()
if len(t) > 100:
return True
if t.count(". ") > 1:
return True
return any(v in t.lower().split() for v in ["is", "are", "was", "were", "have", "has", "been"]) and len(t) > 50
_TAIL_ROLES = frozenset(
{
"backmatter_boundary_heading",
"backmatter_heading",
"backmatter_body",
"tail_candidate_body",
"reference_heading",
"reference_item",
}
)
def _has_tail_role(block: dict) -> bool:
return block.get("role") in _TAIL_ROLES
def _get_column(block: dict, page_width: float = 1200) -> int:
bbox = block.get("bbox") or block.get("block_bbox")
if bbox and len(bbox) >= 4:
x_center = (bbox[0] + bbox[2]) / 2
return 0 if x_center < page_width / 2 else 1
return 0
def _estimate_noise_bands(
structured_blocks: list[dict],
) -> tuple[float | None, float | None]:
header_candidates: list[float] = []
footer_candidates: list[float] = []
for block in structured_blocks:
role = block.get("role", "")
bbox = block.get("bbox") or block.get("block_bbox")
if not bbox or len(bbox) < 4:
continue
page_height = block.get("page_height", 0) or 0
if page_height == 0:
continue
y2, y1 = bbox[3], bbox[1]
noise_roles = {"noise", "header", "footer", "number"}
raw_label = block.get("raw_label", "")
if role in noise_roles or raw_label in ("header", "footer", "number"):
if y2 < page_height * 0.15:
header_candidates.append(y2)
if y1 > page_height * 0.85:
footer_candidates.append(y1)
header_band = max(header_candidates) if header_candidates else None
footer_band = min(footer_candidates) if footer_candidates else None
return header_band, footer_band
def _is_in_usable_content(
block: dict,
header_band: float | None,
footer_band: float | None,
) -> bool:
bbox = block.get("bbox") or block.get("block_bbox")
if not bbox or len(bbox) < 4:
return True
y1, y2 = bbox[1], bbox[3]
if header_band is not None and y2 < header_band:
return False
return not (footer_band is not None and y1 > footer_band)
def _find_owning_heading(
body: dict,
sections: list[dict],
page_width: float = 1200,
) -> int | None:
body_bbox = body.get("bbox") or body.get("block_bbox")
if not body_bbox or len(body_bbox) < 4:
return None
body_y = body_bbox[1]
body_col = _get_column(body, page_width)
candidates: list[tuple[int, float]] = []
for i, sec in enumerate(sections):
h = sec["heading"]
h_bbox = h.get("bbox") or h.get("block_bbox")
if not h_bbox or len(h_bbox) < 4:
continue
h_bottom = h_bbox[3]
if h_bottom > body_y:
continue
h_col = _get_column(h, page_width)
dist = body_y - h_bottom
col_penalty = 0.0 if h_col == body_col else 10000.0
candidates.append((i, dist + col_penalty))
if not candidates:
return None
candidates.sort(key=lambda x: x[1])
return candidates[0][0]
def _has_same_column_anchor_above(
body: dict,
anchors: list[dict],
page_width: float = 1200,
) -> bool:
body_bbox = body.get("bbox") or body.get("block_bbox")
if not body_bbox or len(body_bbox) < 4:
return False
body_y = body_bbox[1]
body_col = _get_column(body, page_width)
for anchor in anchors:
anchor_bbox = anchor.get("bbox") or anchor.get("block_bbox")
if not anchor_bbox or len(anchor_bbox) < 4:
continue
if _get_column(anchor, page_width) != body_col:
continue
if anchor_bbox[3] <= body_y:
return True
return False
def _extract_style_profile(block: dict) -> dict | None:
span_meta = block.get("span_metadata")
if not span_meta:
return None
# List of per-character spans (future format from PyMuPDF)
if isinstance(span_meta, list) and len(span_meta) > 0:
sizes = []
fonts = set()
flags_list = []
colors = []
for s in span_meta:
if not isinstance(s, dict):
continue
size = s.get("size") or 0
if size:
sizes.append(size)
font = s.get("font", "")
if font:
fonts.add(font)
flags_list.append(s.get("flags", 0) or 0)
colors.append(s.get("color", 0) or 0)
if not sizes:
return None
return {
"mean_size": sum(sizes) / len(sizes),
"max_size": max(sizes),
"font_families": fonts,
"is_bold": any(f & 16 for f in flags_list),
"is_italic": any(f & 4 for f in flags_list),
"is_colored": any(c != 0 for c in colors),
}
# Flat dict format (backward compat with test data)
if isinstance(span_meta, dict):
size = span_meta.get("size", 0) or 0
flags = span_meta.get("flags", "")
if isinstance(flags, str):
is_bold = "bold" in flags.lower()
is_italic = "italic" in flags.lower()
else:
is_bold = bool(flags & 16) if flags else False
is_italic = bool(flags & 4) if flags else False
if not size and not is_bold:
return None
return {
"mean_size": float(size),
"max_size": float(size),
"font_families": set(),
"is_bold": is_bold,
"is_italic": is_italic,
"is_colored": False,
}
return None
def _build_heading_style_profiles(blocks: list[dict]) -> dict:
heading_roles = frozenset(
{
"backmatter_boundary_heading",
"section_heading",
"subsection_heading",
"sub_subsection_heading",
"backmatter_heading",
"reference_heading",
}
)
heading_items = []
for block in blocks:
if block.get("role") in heading_roles:
profile = _extract_style_profile(block)
if profile is not None:
heading_items.append(
{
"max_size": profile["max_size"],
"mean_size": profile["mean_size"],
"profile": profile,
"block": block,
}
)
if len(heading_items) < 3:
return {}
# Use max_size for clustering — section headings often have slightly larger
# first characters, making max_size more discriminating than mean.
unique_sizes = sorted(set(item["max_size"] for item in heading_items))
if not unique_sizes:
return {}
clusters = []
current = [unique_sizes[0]]
for s in unique_sizes[1:]:
if s <= current[-1] + 2.0:
current.append(s)
else:
clusters.append(current)
current = [s]
clusters.append(current)
# Build full profile data for each cluster
cluster_items = []
for cluster_sizes in clusters:
matching = [item for item in heading_items if any(abs(item["max_size"] - s) <= 2 for s in cluster_sizes)]
cluster_items.append(matching)
# Sort clusters by max_size descending to establish hierarchy level
clusters.sort(key=lambda c: max(c), reverse=True)
cluster_items.sort(key=lambda items: max(i["max_size"] for i in items), reverse=True)
keys = ["primary", "subsection", "sub_subsection", "backmatter", "body"]
result = {}
for i, (cluster_sizes, items) in enumerate(zip(clusters, cluster_items, strict=False)):
if i >= len(keys):
break
profiles = [item["profile"] for item in items]
is_bold = any(p["is_bold"] for p in profiles)
fonts = set()
for p in profiles:
fonts.update(p["font_families"])
# Compute spacing before/after by searching ALL blocks on the same page
space_before_vals = []
space_after_vals = []
for item in items:
heading_block = item["block"]
heading_page = heading_block.get("page")
heading_bbox = heading_block.get("bbox") or heading_block.get("block_bbox")
if heading_bbox and len(heading_bbox) >= 4 and heading_page is not None:
h_y1 = heading_bbox[1]
h_y2 = heading_bbox[3]
nearest_above_bottom = None
nearest_below_top = None
for other in blocks:
if other is heading_block:
continue
if other.get("page") == heading_page:
obbox = other.get("bbox") or other.get("block_bbox")
if obbox and len(obbox) >= 4:
o_y2 = obbox[3]
o_y1 = obbox[1]
if o_y2 <= h_y1 and (nearest_above_bottom is None or o_y2 > nearest_above_bottom):
nearest_above_bottom = o_y2
if o_y1 >= h_y2 and (nearest_below_top is None or o_y1 < nearest_below_top):
nearest_below_top = o_y1
if nearest_above_bottom is not None:
space_before_vals.append(h_y1 - nearest_above_bottom)
if nearest_below_top is not None:
space_after_vals.append(nearest_below_top - h_y2)
space_before = sum(space_before_vals) / len(space_before_vals) if space_before_vals else 0.0
space_after = sum(space_after_vals) / len(space_after_vals) if space_after_vals else 0.0
result[keys[i]] = {
"size_min": min(cluster_sizes),
"size_max": max(cluster_sizes),
"bold": is_bold,
"fonts": fonts,
"space_before": space_before,
"space_after": space_after,
}
return result
def _disambiguate_heading_role(block: dict, style_profiles: dict) -> str | None:
profile = _extract_style_profile(block)
if profile is None:
return None
size = profile["mean_size"]
for role_key, role_name in [
("primary", "section_heading"),
("subsection", "subsection_heading"),
("sub_subsection", "sub_subsection_heading"),
("backmatter", "backmatter_heading"),
("body", None),
]:
cfg = style_profiles.get(role_key)
if cfg is None:
continue
if cfg["size_min"] <= size <= cfg["size_max"]:
if role_key == "backmatter":
if profile["is_bold"]:
return role_name
return None
elif role_key == "body":
return None
return role_name
return None
def _reorder_tail_run(
tail_blocks: list[dict],
carried_ref: dict | None = None,
carried_backmatter: dict | None = None,
header_band: float | None = None,
footer_band: float | None = None,
page_width: float = 1200,
) -> tuple[list[dict], dict | None, dict | None]:
"""Group tail blocks using geometric ownership and reference zone.
Non-tail pass blocks (body_paragraph, tail_candidate_body, etc.) are
emitted first, preserving their natural page order. Backmatter sections
(heading + body) are emitted next in column-sorted order. References
zone (heading + all items below it) is emitted last. Body blocks are
attached to the nearest backmatter heading above them in the same column,
falling back to absolute proximity with a cross-column penalty.
When blocks lack bbox data, falls back to FIFO matching for backward
compatibility.
"""
if not tail_blocks:
return tail_blocks, carried_ref
# Quick check: do blocks have bbox data? If not, use FIFO fallback.
has_geo = any(
block.get("bbox") or block.get("block_bbox")
for block in tail_blocks
if block.get("role") in ("backmatter_heading", "reference_heading", "backmatter_body")
)
if not has_geo:
ordered, next_ref = _reorder_tail_run_fifo(tail_blocks, carried_ref)
return ordered, next_ref, carried_backmatter
backmatter_sections: list[dict] = []
ref_section: dict | None = carried_ref
ref_items: list[dict] = []
non_tail_pass: list[dict] = []
orphan_blocks: list[dict] = []
carried_bodies: list[dict] = []
# Phase 1 — classify
body_pool: list[dict] = []
for block in tail_blocks:
role = block.get("role")
if role in ("backmatter_boundary_heading", "backmatter_heading"):
backmatter_sections.append({"heading": block, "bodies": []})
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)
elif role == "backmatter_body":
if _is_in_usable_content(block, header_band, footer_band):
body_pool.append(block)
else:
non_tail_pass.append(block)
else:
non_tail_pass.append(block)
# Phase 2 — build references zone
ref_heading = ref_section.get("heading") if ref_section and ref_section is not carried_ref else None
ref_bottom: float = 0.0
if ref_heading:
rh_bbox = ref_heading.get("bbox") or ref_heading.get("block_bbox")
if rh_bbox and len(rh_bbox) >= 4:
ref_bottom = rh_bbox[3]
# Phase 3 — assign ref items to zone, backmatter bodies to body pool
for block in ref_items:
if ref_heading:
bbox = block.get("bbox") or block.get("block_bbox")
if bbox and len(bbox) >= 4 and bbox[1] >= ref_bottom:
ref_section["bodies"].append(block)
else:
body_pool.append(block)
else:
body_pool.append(block)
# Phase 4 — geometric body attachment
first_local_anchor_top: float | None = None
local_heading_tops = []
for sec in backmatter_sections:
bbox = sec["heading"].get("bbox") or sec["heading"].get("block_bbox")
if bbox and len(bbox) >= 4:
local_heading_tops.append(bbox[1])
if ref_heading:
bbox = ref_heading.get("bbox") or ref_heading.get("block_bbox")
if bbox and len(bbox) >= 4:
local_heading_tops.append(bbox[1])
if local_heading_tops:
first_local_anchor_top = min(local_heading_tops)
for body in body_pool:
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:
bbox = body.get("bbox") or body.get("block_bbox")
if bbox and len(bbox) >= 4:
body_top = bbox[1]
if (first_local_anchor_top is None or body_top < first_local_anchor_top) and (
not ref_heading or body_top < ref_bottom
):
carried_bodies.append(body)
continue
elif ref_section is not None:
if ref_section is carried_ref:
ref_section["bodies"].append(body)
orphan_blocks.append(body)
else:
ref_section["bodies"].append(body)
else:
orphan_blocks.append(body)
# Phase 5 — emit: non-tail pass, then boundary heading first
# (backmatter container), then sub-sections, then references
result: list[dict] = []
result.extend(non_tail_pass)
result.extend(carried_bodies)
boundary_secs = [s for s in backmatter_sections
if s["heading"].get("role") == "backmatter_boundary_heading"]
sub_secs = [s for s in backmatter_sections
if s["heading"].get("role") != "backmatter_boundary_heading"]
for sec in boundary_secs + sub_secs:
result.append(sec["heading"])
result.extend(sec["bodies"])
if ref_section is not None and ref_section is not carried_ref:
result.append(ref_section["heading"])
result.extend(ref_section["bodies"])
result.extend(orphan_blocks)
next_backmatter = carried_backmatter
if backmatter_sections:
next_backmatter = backmatter_sections[-1]["heading"]
if ref_section is not None and ref_section is not carried_ref:
next_backmatter = None
return result, ref_section if ref_section else carried_ref, next_backmatter
def _reorder_tail_run_fifo(
tail_blocks: list[dict],
carried_ref: dict | None = None,
) -> tuple[list[dict], dict | None]:
"""FIFO fallback for blocks without bbox data."""
backmatter_sections: list[dict] = []
ref_section: dict | None = carried_ref
heading_queue: list[dict] = []
orphan_bodies: list[dict] = []
orphan_ref_items: list[dict] = []
non_tail_pass: list[dict] = []
for block in tail_blocks:
role = block.get("role")
if role in ("backmatter_boundary_heading", "backmatter_heading"):
sec = {"heading": block, "bodies": []}
backmatter_sections.append(sec)
heading_queue.append(sec)
elif role == "reference_heading":
ref_section = {"heading": block, "bodies": []}
elif role == "backmatter_body":
if heading_queue:
heading_queue.pop(0)["bodies"].append(block)
elif ref_section is not None:
if ref_section is carried_ref:
orphan_ref_items.append(block)
else:
ref_section["bodies"].append(block)
else:
orphan_bodies.append(block)
elif role == "body_paragraph":
non_tail_pass.append(block)
elif role == "reference_item":
if ref_section is not None:
if ref_section is carried_ref:
orphan_ref_items.append(block)
else:
ref_section["bodies"].append(block)
else:
non_tail_pass.append(block)
result: list[dict] = []
result.extend(non_tail_pass)
boundary_secs = [s for s in backmatter_sections
if s["heading"].get("role") == "backmatter_boundary_heading"]
sub_secs = [s for s in backmatter_sections
if s["heading"].get("role") != "backmatter_boundary_heading"]
for sec in boundary_secs + sub_secs:
result.append(sec["heading"])
result.extend(sec["bodies"])
if ref_section is not None and ref_section is not carried_ref:
result.append(ref_section["heading"])
result.extend(ref_section["bodies"])
result.extend(orphan_bodies)
result.extend(orphan_ref_items)
return result, ref_section
def _promote_tail_body_candidates(
blocks: list[dict],
tail_spread: tuple[int, int] | None,
header_band: float | None = None,
footer_band: float | None = None,
) -> list[dict]:
"""Promote plausible tail bodies from plain body_paragraph blocks.
Base role assignment should stay conservative. This pass upgrades only
those body paragraphs that are geometrically compatible with tail section
ownership inside the reconciled tail spread.
"""
if tail_spread is None:
return blocks
spread_start, spread_end = tail_spread
by_page: dict[int, list[int]] = {}
for idx, block in enumerate(blocks):
page = block.get("page")
if page is not None:
by_page.setdefault(page, []).append(idx)
result = [dict(block) for block in blocks]
for page, indices in by_page.items():
if page < spread_start or page > spread_end:
continue
page_blocks = [result[i] for i in indices]
local_headings = [
b for b in page_blocks if b.get("role") in ("backmatter_heading", "backmatter_boundary_heading")
]
ref_heading = next((b for b in page_blocks if b.get("role") == "reference_heading"), None)
local_anchors = local_headings
local_tops = []
for anchor in [*local_headings, *([ref_heading] if ref_heading else [])]:
bbox = anchor.get("bbox") or anchor.get("block_bbox")
if bbox and len(bbox) >= 4:
local_tops.append(bbox[1])
first_local_anchor_top = min(local_tops) if local_tops else None
for idx in indices:
block = result[idx]
if block.get("role") != "body_paragraph":
continue
if not _is_in_usable_content(block, header_band, footer_band):
continue
bbox = block.get("bbox") or block.get("block_bbox")
if not bbox or len(bbox) < 4:
continue
promote = False
page_width = block.get("page_width", 1200) or 1200
if local_anchors and _has_same_column_anchor_above(block, local_anchors, page_width):
promote = True
elif page > spread_start:
body_top = bbox[1]
if first_local_anchor_top is None or body_top < first_local_anchor_top:
promote = True
if promote:
block["role"] = "tail_candidate_body"
block["evidence"] = list(block.get("evidence") or []) + ["promoted in tail spread from body_paragraph"]
return result
def _find_best_anchor(
body: dict,
anchors: list[dict],
ref_heading: dict | None = None,
page_width: float = 1200,
) -> int | None:
"""Find the best backmatter heading anchor for a tail candidate body.
Prefers same column, then nearest heading above in any column.
Handles cross-page continuations: an anchor on an earlier page is
always treated as above the body (regardless of raw Y coordinate).
Excludes ref_heading. Returns the anchor index or None.
"""
body_bbox = body.get("bbox") or body.get("block_bbox")
if not body_bbox or len(body_bbox) < 4:
return None
body_y = body_bbox[1]
body_page = body.get("page", 0) or 0
body_mid = (body_bbox[0] + body_bbox[2]) / 2
pw_mid = page_width / 2
body_col = 0 if body_mid < pw_mid else 1
best_same: tuple[int, float] | None = None
best_other: tuple[int, float] | None = None
for idx, anchor in enumerate(anchors):
if anchor is ref_heading:
continue
a_bbox = anchor.get("bbox") or anchor.get("block_bbox")
if not a_bbox or len(a_bbox) < 4:
continue
anchor_page = anchor.get("page", 0) or 0
if anchor_page > body_page:
continue # anchor on later page → cannot own body
a_bottom = a_bbox[3]
if anchor_page == body_page and a_bottom > body_y:
continue # anchor below body on same page
# Cross-page anchors (earlier page) are always valid —
# raw Y comparison across pages is meaningless.
a_mid = (a_bbox[0] + a_bbox[2]) / 2
a_col = 0 if a_mid < pw_mid else 1
if anchor_page == body_page:
dist = body_y - a_bottom
else:
page_extent = body.get("page_height", 0) or page_width
dist = (body_page - anchor_page) * page_extent + max(0.0, page_extent - a_bottom)
if body_col == a_col:
if best_same is None or dist < best_same[1]:
best_same = (idx, dist)
else:
if best_other is None or dist < best_other[1]:
best_other = (idx, dist)
best = best_same or best_other
return best[0] if best is not None else None
def _detect_forward_body_end(blocks: list[dict]) -> int | None:
"""Scan blocks front-to-back and return the last page of stable body.
Tracks pages with body headings (section_heading, subsection_heading)
and body_paragraph continuity. When a page has tail roles
(backmatter_heading, reference_heading, etc.) and no body content,
the body is considered to have ended on the preceding clean body page.
Returns None if no clear body/backmatter boundary is found.
"""
if not blocks:
return None
by_page: dict[int, list[dict]] = {}
for block in blocks:
p = block.get("page")
if p is not None:
by_page.setdefault(p, []).append(block)
pages = sorted(by_page.keys())
if not pages:
return None
last_clean_body_page: int | None = None
for page in pages:
roles = {b.get("role") for b in by_page[page]}
has_body = bool(roles & {"body_paragraph", "section_heading", "subsection_heading", "sub_subsection_heading"})
has_tail = bool(roles & _TAIL_ROLES)
if has_body and not has_tail:
last_clean_body_page = page
elif has_tail:
if last_clean_body_page is not None:
return last_clean_body_page
if not has_body:
prev_idx = pages.index(page) - 1
if prev_idx >= 0:
return pages[prev_idx]
return None
return None
def _detect_backward_backmatter_start(blocks: list[dict]) -> int | None:
"""Scan blocks backward and return the page where backmatter begins.
Starting from the last page, looks for the first reference_heading or
backmatter_heading. Dense reference pages (>= 4 reference_item blocks)
are a strong signal. Short backmatter_body blocks near headings confirm
the backmatter zone. Returns None if no backmatter found.
"""
if not blocks:
return None
by_page: dict[int, list[dict]] = {}
for block in blocks:
p = block.get("page")
if p is not None:
by_page.setdefault(p, []).append(block)
pages = sorted(by_page.keys(), reverse=True)
if not pages:
return None
for page in pages:
page_blocks = by_page[page]
roles = {b.get("role") for b in page_blocks}
if "reference_heading" in roles or "backmatter_heading" in roles or "backmatter_boundary_heading" in roles:
return page
dense_refs = sum(1 for b in page_blocks if b.get("role") == "reference_item")
if dense_refs >= 4:
return page
return None
def _reconcile_tail_spread(blocks: list[dict]) -> tuple[int, int] | None:
"""Reconcile forward and backward scans into a tail spread page range.
Returns (start_page, end_page) or None when no tail spread exists.
"""
forward_end = _detect_forward_body_end(blocks)
backward_start = _detect_backward_backmatter_start(blocks)
if forward_end is None and backward_start is None:
return None
max_page = 0
for block in blocks:
p = block.get("page")
if p is not None and p > max_page:
max_page = p
if forward_end is None and backward_start is not None:
start = max(1, backward_start - 2)
return (start, max_page)
if backward_start is None and forward_end is not None:
return None
if forward_end is not None and backward_start is not None:
if forward_end < backward_start:
return (forward_end + 1, backward_start)
else:
return (backward_start, forward_end)
return None
def _assign_tail_spread_ownership(
blocks: list[dict],
tail_spread: tuple[int, int] | None = None,
) -> list[dict]:
"""Assign tail_candidate_body blocks to backmatter anchors across pages.
When a tail_spread boundary is provided, only tail_candidate_body blocks
within the spread are eligible for anchor matching. Blocks outside the
spread revert to body_paragraph. Inside the spread, tail_candidate_body
is replaced with backmatter_body when the block sits below a valid
backmatter heading (same-column preferred). Marks spread-assigned bodies
with _spread_anchor so the page-local reorder pass does not reassign them.
Unanchored candidates inside the spread stay as tail_candidate_body.
"""
tail_heading_roles = {"backmatter_heading", "backmatter_boundary_heading"}
anchors = [b for b in blocks if b.get("role") in tail_heading_roles]
ref_heading = next((b for b in blocks if b.get("role") == "reference_heading"), None)
if tail_spread is not None:
spread_start, spread_end = tail_spread
else:
spread_start, spread_end = 0, 0
if not anchors:
return [{**b, "role": "body_paragraph"} if b.get("role") == "tail_candidate_body" else b for b in blocks]
result = list(blocks)
for i, block in enumerate(result):
if block.get("role") != "tail_candidate_body":
continue
block_page = block.get("page", 0) or 0
# Outside the reconciled tail spread → revert to body_paragraph
if tail_spread is not None and (block_page < spread_start or block_page > spread_end):
result[i] = dict(block)
result[i]["role"] = "body_paragraph"
continue
# Inside tail spread (or no spread set): try geometric anchor matching
pw = block.get("page_width", 0) or 1200
idx = _find_best_anchor(block, anchors, ref_heading, pw)
result[i] = dict(block)
if idx is not None:
anchor_page = anchors[idx].get("page", 0)
result[i]["role"] = "backmatter_body"
result[i]["_spread_anchor"] = anchor_page
else:
# If no anchor match inside the spread, revert to plain body.
result[i]["role"] = "body_paragraph"
return result
def _sort_blocks_by_column(blocks: list[dict], page_width: int) -> list[dict]:
"""Sort blocks on a page into natural reading order (left col then right
col, top to bottom within each column), using bbox x/y positions.
Blocks without bbox data are left in their original relative order.
"""
midpoint = page_width / 2
def _column_key(block: dict) -> tuple[int, int, int]:
bbox = block.get("bbox") or block.get("block_bbox")
if bbox and len(bbox) >= 4:
x_center = (bbox[0] + bbox[2]) / 2
col = 0 if x_center < midpoint else 1
return (col, bbox[1], bbox[0])
return (0, 0, 0)
return sorted(blocks, key=_column_key)
def _order_tail_blocks(blocks: list[dict], style_profiles: dict | None = None) -> list[dict]:
"""Fix block reading order on tail pages with mixed-column layout.
Two-column tail pages can have blocks in non-reading order (e.g.
left-column References at y=705 placed between right-column Gen AI
heading at y=154 and Pub note heading at y=297). This sorts all
blocks on such pages by (column, y-position), then groups them into
backmatter sections and a references zone using geometric ownership.
Before per-page sorting, runs a multi-page tail-spread ownership pass
that resolves tail_candidate_body blocks against backmatter anchors
across page boundaries. Non-tail pages are untouched.
"""
if not blocks:
return blocks
# Step 0: Reconcile tail spread boundary
tail_spread = _reconcile_tail_spread(blocks)
# Step 0.5: only inside the reconciled tail spread, promote plausible
# body paragraphs into tail candidates using geometry rather than
# page-level text heuristics.
header_band, footer_band = _estimate_noise_bands(blocks)
blocks = _promote_tail_body_candidates(blocks, tail_spread, header_band=header_band, footer_band=footer_band)
# Step 1: cross-page tail spread ownership (now boundary-aware)
blocks = _assign_tail_spread_ownership(blocks, tail_spread)
# Find pages that contain tail blocks and their page widths
tail_pages: set[int] = set()
page_widths: dict[int, int] = {}
for block in blocks:
if _has_tail_role(block):
page = block.get("page")
if page is not None:
tail_pages.add(page)
pw = block.get("page_width") or 0
p = block.get("page")
if p is not None and pw:
page_widths.setdefault(p, pw)
if not tail_pages:
return blocks
# Group blocks by page
by_page: dict[int, list[dict]] = {}
for block in blocks:
p = block.get("page")
if p is not None:
by_page.setdefault(p, []).append(block)
# For tail pages: column-sort then group into sections using
# geometric ownership that handles cross-column body attachment
# and references zone boundaries.
carried_ref: dict | None = None
carried_backmatter: dict | None = None
result: list[dict] = []
for page in sorted(by_page.keys()):
page_blocks = by_page[page]
if page in tail_pages:
pw = page_widths.get(page, 1200)
sorted_blocks = _sort_blocks_by_column(page_blocks, pw)
ordered, carried_ref, carried_backmatter = _reorder_tail_run(
sorted_blocks,
carried_ref,
carried_backmatter,
header_band=header_band,
footer_band=footer_band,
page_width=pw,
)
result.extend(ordered)
else:
result.extend(page_blocks)
return result
def render_fulltext_markdown(
*,
structured_blocks: list[dict],
resolved_metadata: dict,
figure_inventory: dict,
table_inventory: dict,
page_count: int | None = None,
) -> str:
lines: list[str] = []
# --- title ---
title = resolved_metadata.get("title", {}).get("value", "")
if title:
lines.append(f"# {title}")
lines.append("")
# --- authors ---
authors_display = resolved_metadata.get("authors_display", "")
if not authors_display:
authors = resolved_metadata.get("authors", {}).get("value", [])
if authors:
authors_display = ", ".join(authors)
if authors_display:
lines.append(f"**Authors:** {authors_display}")
lines.append("")
# --- metadata block ---
journal = resolved_metadata.get("journal", {}).get("value", "")
year = resolved_metadata.get("year", {}).get("value", 0)
doi = resolved_metadata.get("doi", {}).get("value", "")
meta_parts: list[str] = []
if journal:
meta_parts.append(f"**Journal:** {journal}")
if year:
meta_parts.append(f"**Year:** {year}")
if doi:
meta_parts.append(f"**DOI:** {doi}")
if meta_parts:
lines.extend(meta_parts)
lines.append("")
# --- abstract ---
abstract_blocks = [
b
for b in structured_blocks
if b.get("role") in ("abstract_heading", "abstract_body") and b.get("render_default", True)
]
if abstract_blocks:
lines.append("## Abstract")
lines.append("")
for block in abstract_blocks:
if block.get("role") == "abstract_body":
text = block.get("text", "")
if text:
lines.append(_normalize_latex(text))
lines.append("")
# Build per-page figure/table lookups
figures_by_page: dict[int, list[str]] = {}
for i, fig in enumerate(figure_inventory.get("matched_figures", [])):
fig_id = fig.get("figure_id") or f"figure_{i + 1:03d}"
page = fig.get("page", 0) or 1
figures_by_page.setdefault(page, []).append(fig_id)
tables_by_page: dict[int, list[str]] = {}
for i, tbl in enumerate(table_inventory.get("tables", [])):
if tbl.get("has_asset"):
tbl_id = tbl.get("table_id") or f"table_{i + 1:03d}"
page = tbl.get("page", 0) or 1
tables_by_page.setdefault(page, []).append(tbl_id)
emitted_pages: set[int] = set()
# --- body with anchored figures/tables ---
# Find the min and max page across ALL blocks (including suppressed)
all_pages = {b.get("page", 0) for b in structured_blocks if b.get("page") is not None}
max_page = max(all_pages) if all_pages else 0
current_page: int | None = None
CONSUMED_FRONTMATTER_ROLES = frozenset(
{
"paper_title",
"authors",
"doi",
"affiliation",
"email",
"correspondence",
}
)
style_profiles = _build_heading_style_profiles(structured_blocks)
ordered_blocks = _order_tail_blocks(structured_blocks, style_profiles=style_profiles)
for block in ordered_blocks:
if not block.get("render_default", True):
continue
role = block.get("role", "")
if role in CONSUMED_FRONTMATTER_ROLES and block.get("page") == 1:
continue
_SKIPPED_BODY_ROLES = {
"abstract_heading",
"abstract_body",
"figure_caption",
"table_caption",
"frontmatter_noise",
"table_html",
}
if role in _SKIPPED_BODY_ROLES:
continue
text = _normalize_latex(block.get("text", ""))
text = re.sub(r"<table[^>]*>.*?</table>", "", text, flags=re.DOTALL | re.IGNORECASE)
if text.strip().lower().startswith("<table"):
continue
block_page = block.get("page")
if block_page is not None and block_page != current_page:
# Emit objects for the page we just finished rendering
if current_page is not None:
for fig_id in figures_by_page.get(current_page, []):
lines.append(f"![[render/figures/{fig_id}.md]]")
lines.append("")
for tbl_id in tables_by_page.get(current_page, []):
lines.append(f"![[render/tables/{tbl_id}.md]]")
lines.append("")
emitted_pages.add(current_page)
# Fill in page markers for skipped pages (no renderable blocks)
first_new_page = (current_page or 0) + 1
for p in range(first_new_page, block_page):
lines.append(f"<!-- page {p} -->")
lines.append("")
for fig_id in figures_by_page.get(p, []):
lines.append(f"![[render/figures/{fig_id}.md]]")
lines.append("")
for tbl_id in tables_by_page.get(p, []):
lines.append(f"![[render/tables/{tbl_id}.md]]")
lines.append("")
emitted_pages.add(p)
current_page = block_page
lines.append(f"<!-- page {block_page} -->")
lines.append("")
emitted_pages.add(block_page)
if role == "backmatter_boundary_heading" or role == "backmatter_heading" or role == "reference_heading":
lines.append(f"## {text}")
lines.append("")
elif role == "section_heading":
if text.strip().lower() in FRONTMATTER_NOISE:
continue
if _is_bogus_heading(text):
if text:
lines.append(text)
lines.append("")
else:
lines.append(f"## {text}")
lines.append("")
elif role == "subsection_heading" or role == "sub_subsection_heading":
lines.append(f"### {text}")
lines.append("")
elif role in ("backmatter_body", "tail_candidate_body", "body_paragraph"):
if text:
lines.append(text)
lines.append("")
else:
if text:
lines.append(text)
lines.append("")
# Emit objects for the last rendered page
if current_page is not None:
for fig_id in figures_by_page.get(current_page, []):
lines.append(f"![[render/figures/{fig_id}.md]]")
lines.append("")
for tbl_id in tables_by_page.get(current_page, []):
lines.append(f"![[render/tables/{tbl_id}.md]]")
lines.append("")
emitted_pages.add(current_page)
# Emit any remaining objects and markers for pages not covered by body transitions
# (includes pages before, after, and between renderable body pages)
effective_count = page_count if page_count is not None else max_page
if current_page is not None:
for p in range(1, effective_count + 1):
if p in emitted_pages:
continue
if p > (current_page or 0):
lines.append(f"<!-- page {p} -->")
lines.append("")
for fig_id in figures_by_page.get(p, []):
lines.append(f"![[render/figures/{fig_id}.md]]")
lines.append("")
for tbl_id in tables_by_page.get(p, []):
lines.append(f"![[render/tables/{tbl_id}.md]]")
lines.append("")
return "\n".join(lines).strip() + "\n"
def write_render_outputs(render_root: Path, compat_fulltext: Path, markdown: str) -> None:
render_root.mkdir(parents=True, exist_ok=True)
(render_root / "fulltext.md").write_text(markdown, encoding="utf-8")
compat_fulltext.write_text(markdown, encoding="utf-8")