mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 17:00:23 +00:00
1968 lines
77 KiB
Python
1968 lines
77 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from html import unescape
|
|
from pathlib import Path
|
|
|
|
from paperforge.worker.ocr_document import (
|
|
_TAIL_ROLES,
|
|
DocumentStructure,
|
|
_estimate_noise_bands,
|
|
_get_column,
|
|
_is_in_usable_content,
|
|
)
|
|
from paperforge.worker.ocr_banding import LayoutBandEstimate, decide_usable_content
|
|
from paperforge.worker.ocr_math import normalize_ocr_math_text
|
|
from paperforge.worker.ocr_pdf_spans import _BACKFILL_OVERLAP_REJECT_THRESHOLD, _backfill_coverage_in_existing
|
|
from paperforge.worker.ocr_roles import FRONTMATTER_NOISE
|
|
|
|
_BACKMATTER_HEADING_KEYWORDS: frozenset[str] = frozenset(
|
|
{
|
|
"author contributions",
|
|
"data availability",
|
|
"funding",
|
|
"acknowledg",
|
|
"conflict of interest",
|
|
"competing interests",
|
|
"supplementary material",
|
|
"ethics statement",
|
|
"publisher",
|
|
"biographies",
|
|
}
|
|
)
|
|
|
|
|
|
def _block_font_size(block: dict) -> float | None:
|
|
sig = block.get("span_signature") or {}
|
|
fs = sig.get("font_size")
|
|
if fs is not None:
|
|
return float(fs)
|
|
meta = block.get("span_metadata")
|
|
if isinstance(meta, dict) and meta.get("size") is not None:
|
|
return float(meta["size"])
|
|
if isinstance(meta, list):
|
|
sizes = [
|
|
float(s["size"]) for s in meta
|
|
if isinstance(s, dict) and s.get("size") is not None
|
|
]
|
|
if sizes:
|
|
return sorted(sizes)[len(sizes) // 2]
|
|
return None
|
|
|
|
|
|
def _is_near_body_flow(
|
|
fn_bbox: list[float],
|
|
body_blocks: list[dict],
|
|
page_height: float,
|
|
) -> bool:
|
|
fn_x1, fn_y1, fn_x2, fn_y2 = fn_bbox[0], fn_bbox[1], fn_bbox[2], fn_bbox[3]
|
|
fn_width = max(1.0, fn_x2 - fn_x1)
|
|
if fn_y1 > page_height * 0.82:
|
|
return False
|
|
for body in body_blocks:
|
|
bb = body.get("bbox") or body.get("block_bbox") or []
|
|
if len(bb) < 4:
|
|
continue
|
|
bx1, by1, bx2, by2 = bb[0], bb[1], bb[2], bb[3]
|
|
h_overlap = max(0.0, min(fn_x2, bx2) - max(fn_x1, bx1))
|
|
x_aligned = h_overlap / fn_width >= 0.5
|
|
if not x_aligned:
|
|
continue
|
|
overlaps_y = fn_y1 < by2 and fn_y2 > by1
|
|
gap = fn_y1 - by2
|
|
near_below = 0 <= gap <= page_height * 0.08
|
|
if overlaps_y or near_below:
|
|
return True
|
|
return False
|
|
|
|
|
|
_BOILERPLATE_MARKERS = frozenset({
|
|
"all rights reserved", "downloaded from",
|
|
"published by", "received:", "accepted:",
|
|
})
|
|
|
|
_POSITIVE_CALLOUT_MARKERS = frozenset({
|
|
"correspondence",
|
|
"corresponding author",
|
|
"e-mail",
|
|
"email",
|
|
"department",
|
|
"university",
|
|
"institute",
|
|
"affiliation",
|
|
"address",
|
|
"these authors contributed",
|
|
"contributed equally",
|
|
})
|
|
|
|
|
|
def _convert_footnotes_to_callouts(blocks: list[dict]) -> list[dict]:
|
|
body_blocks = [b for b in blocks if b.get("role") == "body_paragraph"]
|
|
body_fonts_by_page: dict[int, list[float]] = {}
|
|
for body in body_blocks:
|
|
p = int(body.get("page", 0) or 0)
|
|
fs = _block_font_size(body)
|
|
if fs is not None:
|
|
body_fonts_by_page.setdefault(p, []).append(fs)
|
|
all_body_fonts = [fs for sizes in body_fonts_by_page.values() for fs in sizes]
|
|
if not all_body_fonts:
|
|
return blocks
|
|
|
|
result = list(blocks)
|
|
for i, b in enumerate(result):
|
|
if b.get("role") != "footnote":
|
|
continue
|
|
bbox = b.get("bbox") or b.get("block_bbox") or []
|
|
if len(bbox) < 4:
|
|
continue
|
|
|
|
raw_text = str(b.get("text", "") or "")
|
|
text_lower = raw_text.lower()
|
|
|
|
if any(m in text_lower for m in _BOILERPLATE_MARKERS):
|
|
continue
|
|
|
|
has_positive = any(m in text_lower for m in _POSITIVE_CALLOUT_MARKERS)
|
|
has_symbol = any(sym in raw_text for sym in ("\u2020", "*", "\u2021", "\u00a7"))
|
|
if not (has_positive or has_symbol):
|
|
continue
|
|
|
|
fn_page = int(b.get("page", 0) or 0)
|
|
page_body_fonts = body_fonts_by_page.get(fn_page) or all_body_fonts
|
|
body_font_median = sorted(page_body_fonts)[len(page_body_fonts) // 2]
|
|
fn_fs = _block_font_size(b)
|
|
if fn_fs is None or fn_fs >= body_font_median:
|
|
continue
|
|
|
|
page_body_blocks = [bb for bb in body_blocks if int(bb.get("page", 0) or 0) == fn_page]
|
|
page_height = max(
|
|
(
|
|
float(bb.get("page_height") or 0)
|
|
for bb in blocks
|
|
if int(bb.get("page", 0) or 0) == fn_page
|
|
),
|
|
default=0.0,
|
|
) or 1500.0
|
|
|
|
if not _is_near_body_flow(bbox, page_body_blocks, page_height):
|
|
continue
|
|
|
|
result[i] = dict(b)
|
|
result[i]["role"] = "structured_insert"
|
|
from paperforge.worker.ocr_decisions import record_decision
|
|
record_decision(
|
|
result[i],
|
|
stage="footnote_to_callout",
|
|
old_role="footnote",
|
|
new_role="structured_insert",
|
|
reason=f"footnote near body flow font={fn_fs:.1f} < body={body_font_median:.1f}",
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def _should_suppress_frontmatter_heading(text: str) -> bool:
|
|
lower = text.strip().lower()
|
|
if not lower:
|
|
return True
|
|
if lower in FRONTMATTER_NOISE:
|
|
return True
|
|
if "published online" in lower:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _display_heading_text(block: dict, text: str) -> str:
|
|
marker_type = str(((block.get("marker_signature") or {}).get("type")) or "none")
|
|
match = re.match(r"^(\d+)\s+(.+)$", text.strip())
|
|
if marker_type == "heading_numbered" and match:
|
|
return f"{match.group(1)}. {match.group(2)}"
|
|
return text
|
|
|
|
|
|
def _is_bogus_heading(text: str) -> bool:
|
|
t = text.strip()
|
|
# Numbered headings (e.g. "3. 3D printing Scaffolds...") can legitimately be long
|
|
if re.match(r"^\d+(?:\.\d+)*\.?\s", t):
|
|
return False
|
|
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
|
|
|
|
|
|
def _has_tail_role(block: dict) -> bool:
|
|
return block.get("role") in _TAIL_ROLES
|
|
|
|
|
|
def _can_apply_tail_segment_reorder(document_structure: DocumentStructure | None) -> bool:
|
|
if document_structure is None:
|
|
return False
|
|
if not document_structure.tail_reading_order or document_structure.spread_start is None:
|
|
return False
|
|
tail_score = getattr(document_structure, "tail_boundary_score", {}) or {}
|
|
if float(tail_score.get("score", 1.0)) < 0.4:
|
|
return False
|
|
page_layouts = getattr(document_structure, "page_layouts", None) or {}
|
|
for page in range(
|
|
document_structure.spread_start, (document_structure.spread_end or document_structure.spread_start) + 1
|
|
):
|
|
profile = page_layouts.get(page)
|
|
if profile is not None and float(getattr(profile, "confidence", 1.0)) < 0.4:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _heading_number_depth_text(text: str) -> int:
|
|
match = re.match(r"^(\d+(?:\.\d+)*)", text.strip())
|
|
if not match:
|
|
return 0
|
|
return match.group(1).count(".") + 1
|
|
|
|
|
|
def _table_html_to_lines(text: str) -> list[str]:
|
|
rows = re.findall(r"<tr[^>]*>(.*?)</tr>", text, flags=re.IGNORECASE | re.DOTALL)
|
|
lines: list[str] = []
|
|
for row in rows:
|
|
cells = [
|
|
unescape(re.sub(r"<[^>]+>", "", cell)).strip()
|
|
for cell in re.findall(r"<td[^>]*>(.*?)</td>", row, flags=re.IGNORECASE | re.DOTALL)
|
|
]
|
|
cells = [cell for cell in cells if cell]
|
|
if not cells:
|
|
continue
|
|
if len(cells) == 1:
|
|
lines.append(cells[0])
|
|
else:
|
|
lines.append(" | ".join(cells))
|
|
return lines
|
|
|
|
|
|
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)
|
|
body_width = body_bbox[2] - body_bbox[0]
|
|
|
|
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
|
|
horizontal_overlap = max(0.0, min(body_bbox[2], h_bbox[2]) - max(body_bbox[0], h_bbox[0]))
|
|
if body_width >= page_width * 0.45 and horizontal_overlap > 0:
|
|
col_penalty = 0.0
|
|
else:
|
|
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]
|
|
|
|
|
|
from paperforge.worker.ocr_profiles import extract_block_span_profile as _extract_style_profile
|
|
|
|
|
|
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,
|
|
role_profiles: dict | None = None,
|
|
) -> str | None:
|
|
profile = _extract_style_profile(block)
|
|
if profile is None:
|
|
return None
|
|
|
|
# Try role profiles first (persistent, aggregated across papers)
|
|
if role_profiles:
|
|
from paperforge.worker.ocr_profiles import compare_against_role_family
|
|
|
|
for candidate_role in (
|
|
"section_heading",
|
|
"subsection_heading",
|
|
"sub_subsection_heading",
|
|
"backmatter_heading",
|
|
):
|
|
fam = role_profiles.get(candidate_role)
|
|
if fam and fam.get("quality") in ("strong", "moderate"):
|
|
match = compare_against_role_family(profile, fam)
|
|
if match["size_compatible"] and match["match_score"] > 0.6:
|
|
return candidate_role
|
|
|
|
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 _attach_container_bodies(
|
|
body_pool: list[dict],
|
|
backmatter_sections: list[dict],
|
|
page_width: float,
|
|
ref_section: dict | None,
|
|
) -> list[dict]:
|
|
"""Attach container-regime bodies to their nearest heading above.
|
|
|
|
Bodies with ``_backmatter_regime == "container"`` are matched to the
|
|
nearest backmatter heading (boundary or child) via geometric ownership.
|
|
Bodies that cannot find a heading above them fall through to the boundary
|
|
heading section. Returns the remaining (non-container) bodies so the
|
|
caller can continue with generic Phase 4 processing.
|
|
"""
|
|
remaining: list[dict] = []
|
|
for body in body_pool:
|
|
if body.get("_backmatter_regime") != "container":
|
|
remaining.append(body)
|
|
continue
|
|
idx = _find_owning_heading(body, backmatter_sections, page_width)
|
|
if idx is not None:
|
|
backmatter_sections[idx]["bodies"].append(body)
|
|
else:
|
|
boundary_idx = next(
|
|
(
|
|
i
|
|
for i, s in enumerate(backmatter_sections)
|
|
if s["heading"].get("role") == "backmatter_boundary_heading"
|
|
),
|
|
None,
|
|
)
|
|
if boundary_idx is not None:
|
|
backmatter_sections[boundary_idx]["bodies"].append(body)
|
|
else:
|
|
remaining.append(body)
|
|
return remaining
|
|
|
|
|
|
def _ref_number_sort_key(block: dict) -> tuple:
|
|
text = str(block.get("text") or block.get("block_content") or "")
|
|
m = re.match(r"^\s*(?:(\d+)[\.\)]|\[(\d+)\]|\((\d+)\))", text)
|
|
if m:
|
|
num = m.group(1) or m.group(2) or m.group(3)
|
|
return (0, int(num))
|
|
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 _block_column(block: dict, page_width: float) -> int | None:
|
|
bbox = block.get("bbox") or block.get("block_bbox")
|
|
if not bbox or len(bbox) < 4:
|
|
return None
|
|
x_center = (bbox[0] + bbox[2]) / 2
|
|
return 0 if x_center < (page_width / 2.0) else 1
|
|
|
|
|
|
def _should_attach_reference_item_to_ref_section(
|
|
block: dict,
|
|
ref_heading: dict | None,
|
|
*,
|
|
page_width: float,
|
|
ref_bottom: float,
|
|
) -> bool:
|
|
bbox = block.get("bbox") or block.get("block_bbox")
|
|
if not bbox or len(bbox) < 4:
|
|
return True
|
|
if ref_heading is None:
|
|
return True
|
|
|
|
heading_bbox = ref_heading.get("bbox") or ref_heading.get("block_bbox")
|
|
if not heading_bbox or len(heading_bbox) < 4:
|
|
return True
|
|
|
|
block_col = _block_column(block, page_width)
|
|
heading_col = _block_column(ref_heading, page_width)
|
|
if block_col == heading_col:
|
|
return bbox[1] >= ref_bottom
|
|
return True
|
|
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,
|
|
band_estimate: LayoutBandEstimate | None = None,
|
|
page_width: float = 1200,
|
|
skip_section_grouping: bool = False,
|
|
) -> 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 ``skip_section_grouping=True`` (pages without a reference heading),
|
|
backmatter section grouping is bypassed entirely — blocks are emitted
|
|
in column-sorted order with reference items grouped at the end.
|
|
|
|
When blocks lack bbox data, falls back to FIFO matching for backward
|
|
compatibility.
|
|
"""
|
|
# Backward-compatible bridge: when no band_estimate is provided,
|
|
# create one from the raw header_band/footer_band parameters.
|
|
if band_estimate is None:
|
|
band_estimate = LayoutBandEstimate(
|
|
header_band=header_band,
|
|
footer_band=footer_band,
|
|
status="ACCEPT" if (header_band is not None or footer_band is not None) else "EMPTY",
|
|
method="runtime_selected",
|
|
accepted_candidates=[],
|
|
excluded_candidates=[],
|
|
support_pages=[],
|
|
warnings=[],
|
|
)
|
|
|
|
if not tail_blocks:
|
|
return tail_blocks, carried_ref, carried_backmatter
|
|
|
|
# Short-circuit: no section grouping for pages without reference heading.
|
|
# 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",
|
|
"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"]
|
|
|
|
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(
|
|
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] = []
|
|
footnote_blocks: 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":
|
|
ref_items.append(block)
|
|
elif role == "backmatter_body":
|
|
body_pool.append(block)
|
|
elif role == "body_paragraph":
|
|
if decide_usable_content(block, band_estimate, context="tail_render_body").usable:
|
|
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:
|
|
non_tail_pass.append(block)
|
|
elif role == "footnote":
|
|
footnote_blocks.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 2.5 — create synthetic ref section when no reference heading
|
|
# exists but reference items are present. This prevents ref items
|
|
# from scattering through body_pool → orphan_blocks on pages that
|
|
# lack an explicit "References" heading block.
|
|
_needs_synthetic_ref = not ref_heading and bool(ref_items)
|
|
if _needs_synthetic_ref:
|
|
ref_section = {"heading": None, "bodies": []}
|
|
|
|
# Phase 3 — assign ref items to zone, backmatter bodies to body pool
|
|
# Phase 3 — assign ref items to zone
|
|
rejected_ref_items: list[dict] = []
|
|
for block in ref_items:
|
|
if ref_heading:
|
|
if _should_attach_reference_item_to_ref_section(
|
|
block,
|
|
ref_heading,
|
|
page_width=page_width,
|
|
ref_bottom=ref_bottom,
|
|
):
|
|
ref_section["bodies"].append(block)
|
|
else:
|
|
rejected_ref_items.append(block)
|
|
elif _needs_synthetic_ref:
|
|
ref_section["bodies"].append(block)
|
|
else:
|
|
rejected_ref_items.append(block)
|
|
|
|
# Phase 4a — container regime body attachment (run before generic
|
|
# body loop so container bodies are matched to their specific child
|
|
# heading rather than falling through to ref-section absorption)
|
|
body_pool = _attach_container_bodies(body_pool, backmatter_sections, page_width, ref_section)
|
|
|
|
# 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")
|
|
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)
|
|
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]
|
|
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
|
|
|
|
decision = decide_usable_content(body, band_estimate, context="tail_render_backmatter_bodypool")
|
|
if not decision.usable:
|
|
non_tail_pass.append(body)
|
|
continue
|
|
|
|
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)
|
|
else:
|
|
ref_section["bodies"].append(body)
|
|
else:
|
|
orphan_blocks.append(body)
|
|
|
|
# Phase 5 — emit: non-tail pass (frontmatter), then backmatter
|
|
# sections sorted by y-position (boundary-level and sub-headings
|
|
# emit in reading order), then reference sections, then footnotes. The
|
|
# y-sort is applied only to backmatter_sections to avoid carrying
|
|
# cross-page relationship artifacts from previous pages' carried blocks.
|
|
result: list[dict] = []
|
|
result.extend(non_tail_pass)
|
|
result.extend(rejected_ref_items)
|
|
result.extend(carried_bodies)
|
|
sec_order = sorted(
|
|
backmatter_sections,
|
|
key=lambda s: [
|
|
(s["heading"].get("bbox") or s["heading"].get("block_bbox") or [0, 0, 0, 0])[1],
|
|
(s["heading"].get("bbox") or s["heading"].get("block_bbox") or [0, 0, 0, 0])[0],
|
|
],
|
|
)
|
|
for sec in sec_order:
|
|
h = sec["heading"]
|
|
result.append(h)
|
|
result.extend(sec["bodies"])
|
|
if ref_section is not None and ref_section is not carried_ref:
|
|
if ref_section.get("heading"):
|
|
result.append(ref_section["heading"])
|
|
if ref_section.get("bodies"):
|
|
ref_section["bodies"].sort(key=_ref_number_sort_key)
|
|
# Collision diagnostic: log duplicate ref numbers.
|
|
# Do NOT drop by number — different refs sharing a number is an OCR
|
|
# numbering error, not duplicate content. Dropping loses valid refs.
|
|
seen_numbers: set[int] = set()
|
|
for block in ref_section["bodies"]:
|
|
key = _ref_number_sort_key(block)
|
|
if key[0] == 0:
|
|
num = key[1]
|
|
if num in seen_numbers:
|
|
import logging
|
|
logging.warning(f"Duplicate ref number {num}: '{str(block.get('text',''))[:60]}'")
|
|
seen_numbers.add(num)
|
|
result.extend(ref_section["bodies"])
|
|
result.extend(tail_backmatter_blocks)
|
|
result.extend(footnote_blocks)
|
|
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 == "subsection_heading" and backmatter_sections:
|
|
# In FIFO fallback with active backmatter context, subsection
|
|
# headings (e.g. "Grant Disclosures") are container child
|
|
# headings, not generic non-tail pass blocks.
|
|
sec = {"heading": block, "bodies": []}
|
|
backmatter_sections.append(sec)
|
|
heading_queue.append(sec)
|
|
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:
|
|
orphan_ref_items.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:
|
|
h = sec["heading"]
|
|
result.append(h)
|
|
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 _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 _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]:
|
|
"""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.
|
|
|
|
Blocks are expected to already have normalized roles. Non-tail pages
|
|
are untouched.
|
|
"""
|
|
if not blocks:
|
|
return blocks
|
|
|
|
from paperforge.worker.ocr_banding import estimate_layout_bands, choose_runtime_bands, LayoutBandEstimate
|
|
|
|
legacy_header_band, legacy_footer_band = _estimate_noise_bands(blocks)
|
|
robust_estimate = estimate_layout_bands(blocks)
|
|
max_page_height = max((float(b.get("page_height") or 0) for b in blocks), default=0.0)
|
|
|
|
header_band, footer_band, runtime_band_source = choose_runtime_bands(
|
|
robust_estimate,
|
|
legacy_header_band,
|
|
legacy_footer_band,
|
|
max_page_height=max_page_height,
|
|
)
|
|
|
|
runtime_band_estimate = LayoutBandEstimate(
|
|
header_band=header_band,
|
|
footer_band=footer_band,
|
|
status="ACCEPT" if (header_band is not None or footer_band is not None) else "EMPTY",
|
|
method=f"runtime_{runtime_band_source}",
|
|
accepted_candidates=robust_estimate.accepted_candidates,
|
|
excluded_candidates=robust_estimate.excluded_candidates,
|
|
support_pages=robust_estimate.support_pages,
|
|
warnings=robust_estimate.warnings,
|
|
)
|
|
|
|
# Find pages that contain tail blocks and their page widths.
|
|
# Skip pages where body_paragraph blocks outnumber tail-role blocks —
|
|
# these are mixed pages (e.g. body continuation + backmatter on same
|
|
# page) where tail reordering would break reading order.
|
|
_body_count: dict[int, int] = {}
|
|
_tail_count: dict[int, int] = {}
|
|
for block in blocks:
|
|
p = block.get("page")
|
|
if p is None:
|
|
continue
|
|
if _has_tail_role(block):
|
|
_tail_count[p] = _tail_count.get(p, 0) + 1
|
|
elif block.get("role") == "body_paragraph":
|
|
_body_count[p] = _body_count.get(p, 0) + 1
|
|
|
|
tail_pages: set[int] = set()
|
|
page_widths: dict[int, int] = {}
|
|
for block in blocks:
|
|
page = block.get("page")
|
|
if page is None:
|
|
continue
|
|
if _has_tail_role(block):
|
|
if _body_count.get(page, 0) > _tail_count.get(page, 0):
|
|
continue
|
|
tail_pages.add(page)
|
|
pw = block.get("page_width") or 0
|
|
if pw:
|
|
page_widths.setdefault(page, pw)
|
|
|
|
if not tail_pages:
|
|
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]] = {}
|
|
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)
|
|
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,
|
|
carried_ref,
|
|
carried_backmatter,
|
|
header_band=header_band,
|
|
footer_band=footer_band,
|
|
band_estimate=runtime_band_estimate,
|
|
page_width=pw,
|
|
skip_section_grouping=_page_has_ref_items and not _page_has_ref_heading,
|
|
)
|
|
result.extend(ordered)
|
|
else:
|
|
result.extend(page_blocks)
|
|
|
|
# When a verified reference_zone artifact is available on
|
|
# the DocumentStructure, the caller handles reference ordering.
|
|
# Fall back to conventional non_ref + refs only when no such
|
|
# artifact exists (legacy callers without document structure).
|
|
if not has_verified_reference_zone:
|
|
ref_roles = frozenset({"reference_heading", "reference_item", "reference_body"})
|
|
non_ref = [b for b in result if b.get("role") not in ref_roles]
|
|
ref_headings = [b for b in result if b.get("role") == "reference_heading"]
|
|
ref_items = [b for b in result if b.get("role") in {"reference_item", "reference_body"}]
|
|
ref_items.sort(key=_ref_number_sort_key)
|
|
return non_ref + ref_headings + ref_items
|
|
return result
|
|
|
|
|
|
def _render_reader_figure_card(figure: dict) -> list[str]:
|
|
"""Render reader figure card. Returns empty when embed note covers it."""
|
|
if _reader_figure_embed_target(figure) is not None:
|
|
return []
|
|
caption_text = figure.get("caption_text", "")
|
|
fn = figure.get("figure_number")
|
|
if not caption_text:
|
|
return []
|
|
lines = [f"> **Figure {fn}**", f"> {caption_text}"]
|
|
return lines
|
|
|
|
|
|
def _reader_figure_embed_target(figure: dict) -> str | None:
|
|
status = str(figure.get("reader_status") or "")
|
|
if status not in {"EXACT_MATCH", "SEQUENCE_MATCH"}:
|
|
return None
|
|
figure_id = figure.get("figure_id", "")
|
|
if figure_id:
|
|
return figure_id
|
|
figure_number = figure.get("figure_number")
|
|
if figure_number is None:
|
|
return None
|
|
return f"figure_{int(figure_number):03d}"
|
|
|
|
|
|
def _reader_covered_figure_ids(reader_figures: list[dict]) -> set[str]:
|
|
covered: set[str] = set()
|
|
for figure in reader_figures:
|
|
embed_target = _reader_figure_embed_target(figure)
|
|
if embed_target:
|
|
covered.add(embed_target)
|
|
return covered
|
|
|
|
|
|
def _page_block_key(
|
|
block_or_page: dict | int | None, block_id: int | str | None = None
|
|
) -> tuple[int | None, int | str] | None:
|
|
if isinstance(block_or_page, dict):
|
|
page = block_or_page.get("page")
|
|
bid = block_or_page.get("block_id")
|
|
else:
|
|
page = block_or_page
|
|
bid = block_id
|
|
if bid is None:
|
|
return None
|
|
return (page, bid)
|
|
|
|
|
|
def _emit_page_objects(
|
|
lines: list[str],
|
|
page: int,
|
|
*,
|
|
figures_by_page: dict[int, list[dict]],
|
|
unresolved_clusters_by_page: dict[int, list[str]],
|
|
tables_by_page: dict[int, list[str]],
|
|
reader_figures_by_page: dict[int, list[dict]],
|
|
rendered_reader_figure_ids: set[str],
|
|
emitted_figure_captions: set[str],
|
|
) -> None:
|
|
"""Emit figure/table/reader objects for a single page.
|
|
|
|
Reader figures are primary: when present, legacy matched_figures and
|
|
unresolved_clusters are skipped for that page.
|
|
"""
|
|
has_reader = bool(reader_figures_by_page.get(page))
|
|
|
|
if not has_reader:
|
|
for fig in figures_by_page.get(page, []):
|
|
if str(fig["figure_id"]).startswith("unmatched_legend_"):
|
|
continue
|
|
lines.append(f"![[render/figures/{fig['figure_id']}.md]]")
|
|
lines.append("")
|
|
if not has_reader:
|
|
for cluster_id in unresolved_clusters_by_page.get(page, []):
|
|
if not cluster_id:
|
|
continue
|
|
lines.append(f"![[render/figures/{cluster_id}.md]]")
|
|
lines.append("")
|
|
|
|
for tbl_id in tables_by_page.get(page, []):
|
|
lines.append(f"![[render/tables/{tbl_id}.md]]")
|
|
lines.append("")
|
|
|
|
for rf in reader_figures_by_page.get(page, []):
|
|
rfid = rf.get("reader_figure_id")
|
|
if rfid and rfid not in rendered_reader_figure_ids:
|
|
rendered_reader_figure_ids.add(rfid)
|
|
lines.extend(_render_reader_figure_card(rf))
|
|
embed_target = _reader_figure_embed_target(rf)
|
|
if embed_target:
|
|
lines.append(f"![[render/figures/{embed_target}.md]]")
|
|
lines.append("")
|
|
|
|
|
|
def _emit_reader_figures_before_references(
|
|
lines: list[str],
|
|
*,
|
|
start_page: int,
|
|
reader_figures_by_page: dict[int, list[dict]],
|
|
rendered_reader_figure_ids: set[str],
|
|
) -> None:
|
|
for page in sorted(p for p in reader_figures_by_page if p >= start_page):
|
|
for rf in reader_figures_by_page.get(page, []):
|
|
rfid = rf.get("reader_figure_id")
|
|
if not rfid or rfid in rendered_reader_figure_ids:
|
|
continue
|
|
rendered_reader_figure_ids.add(rfid)
|
|
lines.extend(_render_reader_figure_card(rf))
|
|
embed_target = _reader_figure_embed_target(rf)
|
|
if embed_target:
|
|
lines.append(f"![[render/figures/{embed_target}.md]]")
|
|
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)))
|
|
|
|
|
|
_CONSUMED_FRONTMATTER_ROLES: frozenset[str] = frozenset(
|
|
{
|
|
"paper_title",
|
|
"authors",
|
|
"doi",
|
|
"affiliation",
|
|
"email",
|
|
"correspondence",
|
|
}
|
|
)
|
|
|
|
|
|
def _collect_frontmatter_fallback_fields(
|
|
structured_blocks: list[dict],
|
|
resolved_metadata: dict,
|
|
) -> dict[str, list[str] | str]:
|
|
"""Collect frontmatter blocks from pages 1-2 for roles where metadata is empty.
|
|
Returns fields, not lines — caller merges into the single metadata callout.
|
|
|
|
Returns:
|
|
title: str — only when resolved_metadata title is empty
|
|
authors: list[str] — only when both authors_display and metadata authors empty
|
|
affiliations: list[str] — when metadata has no equivalent
|
|
emails: list[str] — when metadata has no correspondence
|
|
doi: str — ONLY when resolved_metadata DOI is empty AND block looks like clean DOI
|
|
"""
|
|
from paperforge.worker.ocr_math import normalize_ocr_math_text
|
|
|
|
metadata_title = resolved_metadata.get("title", {}).get("value", "")
|
|
metadata_authors = resolved_metadata.get("authors", {}).get("value", []) or []
|
|
metadata_authors_display = resolved_metadata.get("authors_display", "")
|
|
metadata_doi = resolved_metadata.get("doi", {}).get("value", "")
|
|
|
|
has_title = bool(metadata_title)
|
|
has_any_author = bool(metadata_authors_display) or bool(metadata_authors)
|
|
has_doi = bool(metadata_doi)
|
|
|
|
fallback_title = ""
|
|
fallback_authors: list[str] = []
|
|
fallback_affiliations: list[str] = []
|
|
fallback_emails: list[str] = []
|
|
fallback_doi = ""
|
|
|
|
seen_text: set[str] = set()
|
|
|
|
for block in structured_blocks:
|
|
page = int(block.get("page", 0) or 0)
|
|
if page > 2:
|
|
continue
|
|
role = block.get("role", "")
|
|
if role not in _CONSUMED_FRONTMATTER_ROLES:
|
|
continue
|
|
|
|
text = str(block.get("text", "") or block.get("block_content", "") or "").strip()
|
|
if not text:
|
|
continue
|
|
norm = normalize_ocr_math_text(text)
|
|
norm_key = norm.strip().lower()
|
|
if norm_key in seen_text:
|
|
continue
|
|
seen_text.add(norm_key)
|
|
|
|
if role == "paper_title" and not has_title:
|
|
fallback_title = norm
|
|
elif role == "authors" and not has_any_author:
|
|
fallback_authors.append(norm)
|
|
elif role == "affiliation":
|
|
fallback_affiliations.append(norm)
|
|
elif role in {"email", "correspondence"}:
|
|
fallback_emails.append(norm)
|
|
elif role == "doi" and not has_doi:
|
|
# Only accept clean DOI lines
|
|
cleaned = norm.strip().lower()
|
|
if cleaned.startswith("doi:") or cleaned.startswith("doi "):
|
|
fallback_doi = norm
|
|
elif "/" in norm and not any(c in norm for c in ("\n", " ")):
|
|
fallback_doi = norm
|
|
|
|
result: dict[str, list[str] | str] = {}
|
|
if fallback_title:
|
|
result["title"] = fallback_title
|
|
if fallback_authors:
|
|
result["authors"] = fallback_authors
|
|
if fallback_affiliations:
|
|
result["affiliations"] = fallback_affiliations
|
|
if fallback_emails:
|
|
result["emails"] = fallback_emails
|
|
if fallback_doi:
|
|
result["doi"] = fallback_doi
|
|
return result
|
|
|
|
|
|
def render_fulltext_markdown(
|
|
*,
|
|
structured_blocks: list[dict],
|
|
resolved_metadata: dict,
|
|
figure_inventory: dict,
|
|
table_inventory: dict,
|
|
page_count: int | None = None,
|
|
document_structure: DocumentStructure | None = None,
|
|
reader_payload: dict | None = None,
|
|
) -> str:
|
|
lines: list[str] = []
|
|
|
|
emitted_figure_captions: set[str] = set()
|
|
|
|
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()
|
|
# 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")
|
|
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", []):
|
|
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
|
|
# 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")
|
|
block_id = item.get("block_id")
|
|
else:
|
|
block_id = item
|
|
page = None
|
|
if block_id is not None:
|
|
consumed_caption_keys.add((page, block_id))
|
|
if page is None:
|
|
consumed_caption_ids_unkeyed.add(block_id)
|
|
rendered_reader_figure_ids: set[str] = set()
|
|
|
|
_block_page_map: dict[int, int] = {}
|
|
for block in structured_blocks:
|
|
bid = block.get("block_id")
|
|
bp = block.get("page")
|
|
if bid is not None and bp is not None:
|
|
_block_page_map[bid] = bp
|
|
|
|
reader_figures_by_page: dict[int, list[dict]] = {}
|
|
for rf in reader_figures:
|
|
rfid = rf.get("reader_figure_id")
|
|
if not rfid:
|
|
continue
|
|
page = None
|
|
for item in rf.get("consumed_caption_block_ids", []):
|
|
p = item.get("page") if isinstance(item, dict) else None
|
|
if p is not None:
|
|
page = p
|
|
break
|
|
if page is None:
|
|
for item in rf.get("consumed_asset_block_ids", []):
|
|
p = item.get("page") if isinstance(item, dict) else None
|
|
if p is not None:
|
|
page = p
|
|
break
|
|
if page is None:
|
|
page = 1
|
|
reader_figures_by_page.setdefault(page, []).append(rf)
|
|
|
|
_block_text_by_bid: dict[str, str] = {}
|
|
for block in structured_blocks:
|
|
bid = block.get("block_id")
|
|
if bid is not None and block.get("text"):
|
|
_block_text_by_bid[str(bid)] = block.get("text", "")
|
|
|
|
for rf in reader_figures:
|
|
if not rf.get("caption_text"):
|
|
for cid in rf.get("consumed_caption_block_ids", []):
|
|
cid_str = str(cid)
|
|
if cid_str in _block_text_by_bid:
|
|
rf["caption_text"] = _block_text_by_bid[cid_str]
|
|
break
|
|
|
|
# --- fallback frontmatter ---
|
|
fallback = _collect_frontmatter_fallback_fields(structured_blocks, resolved_metadata)
|
|
|
|
# --- title ---
|
|
title = resolved_metadata.get("title", {}).get("value", "") or fallback.get("title", "")
|
|
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)
|
|
else:
|
|
fallback_authors = fallback.get("authors", [])
|
|
if fallback_authors:
|
|
authors_display = ", ".join(fallback_authors)
|
|
|
|
# --- metadata block ---
|
|
journal = resolved_metadata.get("journal", {}).get("value", "")
|
|
year = resolved_metadata.get("year", {}).get("value", 0)
|
|
doi = resolved_metadata.get("doi", {}).get("value", "") or fallback.get("doi", "")
|
|
|
|
has_any_meta = bool(authors_display) or bool(journal) or bool(year) or bool(doi)
|
|
fallback_affiliations = fallback.get("affiliations", [])
|
|
fallback_emails = fallback.get("emails", [])
|
|
if has_any_meta or fallback_affiliations or fallback_emails:
|
|
lines.append("> [!info]- Paper Metadata")
|
|
if authors_display:
|
|
lines.append(f"> **Authors:** {authors_display}")
|
|
if journal:
|
|
lines.append(f"> **Journal:** {journal}")
|
|
if year:
|
|
lines.append(f"> **Year:** {year}")
|
|
if doi:
|
|
lines.append(f"> **DOI:** {doi}")
|
|
for aff in fallback_affiliations:
|
|
lines.append(f"> **Affiliation:** {aff}")
|
|
for email in fallback_emails:
|
|
lines.append(f"> **Email:** {email}")
|
|
lines.append("")
|
|
|
|
# --- abstract ---
|
|
abstract_blocks: list[dict] = []
|
|
if document_structure is not None and getattr(document_structure, "abstract_span", None):
|
|
span = document_structure.abstract_span
|
|
block_by_id = {}
|
|
for block in structured_blocks:
|
|
block_id = block.get("block_id")
|
|
if block_id is not None:
|
|
block_by_id[block_id] = block
|
|
block_by_id[f"p{int(block.get('page', 0) or 0)}:{block_id}"] = block
|
|
abstract_ids = [span.get("heading_block_id"), *span.get("body_block_ids", [])]
|
|
abstract_ids = [bid for bid in abstract_ids if bid]
|
|
if abstract_ids:
|
|
abstract_blocks = [block_by_id[bid] for bid in abstract_ids if bid in block_by_id]
|
|
if not abstract_blocks:
|
|
_ABSTRACT_ROLES_FALLBACK = frozenset({"abstract_heading", "abstract_body"})
|
|
abstract_blocks = [
|
|
b for b in structured_blocks if b.get("role") in _ABSTRACT_ROLES_FALLBACK and b.get("render_default", True)
|
|
]
|
|
abstract_member_keys = {_page_block_key(block) for block in abstract_blocks if _page_block_key(block) is not None}
|
|
abstract_member_ids_unkeyed = {
|
|
block.get("block_id")
|
|
for block in abstract_blocks
|
|
if block.get("block_id") is not None and block.get("page") is None
|
|
}
|
|
abstract_heading_block_id = (getattr(document_structure, "abstract_span", None) or {}).get("heading_block_id")
|
|
if abstract_blocks:
|
|
lines.append("## Abstract")
|
|
lines.append("")
|
|
for block in abstract_blocks:
|
|
block_key = _page_block_key(block)
|
|
page_qualified_id = None
|
|
if block.get("block_id") is not None and block.get("page") is not None:
|
|
page_qualified_id = f"p{int(block.get('page', 0) or 0)}:{block.get('block_id')}"
|
|
if abstract_heading_block_id is not None and (
|
|
block.get("block_id") == abstract_heading_block_id or page_qualified_id == abstract_heading_block_id
|
|
):
|
|
continue
|
|
text = block.get("text", "")
|
|
if text:
|
|
# Skip highlight bullet continuations incorrectly labeled as abstract
|
|
stripped = text.strip()
|
|
if stripped.startswith(("\u2022", "- ")):
|
|
continue
|
|
for abstract_line in normalize_ocr_math_text(text).splitlines():
|
|
if abstract_line.strip():
|
|
lines.append(abstract_line)
|
|
lines.append("")
|
|
|
|
# Build per-page figure/table lookups
|
|
reader_covered_figure_ids = _reader_covered_figure_ids(reader_figures)
|
|
|
|
figures_by_page: dict[int, list[dict]] = {}
|
|
for i, fig in enumerate(figure_inventory.get("matched_figures", [])):
|
|
fig_id = fig.get("figure_id") or f"figure_{i + 1:03d}"
|
|
if fig_id in reader_covered_figure_ids:
|
|
continue
|
|
page = fig.get("page", 0) or 1
|
|
figures_by_page.setdefault(page, []).append(
|
|
{
|
|
"figure_id": fig_id,
|
|
"caption": str(fig.get("text") or ""),
|
|
}
|
|
)
|
|
|
|
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)
|
|
|
|
unresolved_clusters_by_page: dict[int, list[str]] = {}
|
|
for idx, cluster in enumerate(figure_inventory.get("unresolved_clusters", [])):
|
|
cluster_id = cluster.get("cluster_id") or f"unresolved_cluster_{idx + 1:03d}"
|
|
page = cluster.get("page", 0) or 1
|
|
unresolved_clusters_by_page.setdefault(page, []).append(cluster_id)
|
|
|
|
emitted_pages: set[int] = set()
|
|
_emitted_body_text_by_page: dict[int, list[str]] = {}
|
|
last_structured_insert_page: int | None = None
|
|
last_structured_insert_bbox: list[float] | None = None
|
|
pre_reference_reader_figures_emitted = False
|
|
|
|
# Compute heading levels from span metadata font sizes when available
|
|
_HEADING_ROLES = {"section_heading", "subsection_heading", "sub_subsection_heading"}
|
|
heading_font_sizes: dict[int, float] = {}
|
|
for i, block in enumerate(structured_blocks):
|
|
if block.get("role") not in _HEADING_ROLES:
|
|
continue
|
|
span = block.get("span_metadata") or {}
|
|
if isinstance(span, list):
|
|
for s in span:
|
|
sz = s.get("size")
|
|
if sz:
|
|
heading_font_sizes[id(block)] = float(sz)
|
|
break
|
|
elif isinstance(span, dict):
|
|
sz = span.get("size")
|
|
if sz:
|
|
heading_font_sizes[id(block)] = float(sz)
|
|
block_heading_prefix: dict[int, str] = {}
|
|
_heading_prefix_map: dict[tuple, str] = {}
|
|
if heading_font_sizes:
|
|
size_groups: dict[float, list[int]] = {}
|
|
bid_to_block: dict[int, dict] = {}
|
|
for bid, sz in heading_font_sizes.items():
|
|
bucket = round(sz * 2) / 2
|
|
size_groups.setdefault(bucket, []).append(bid)
|
|
for blk in structured_blocks:
|
|
bid_to_block[id(blk)] = blk
|
|
|
|
def _bold_font_block(block: dict) -> bool:
|
|
ff = str((block.get("span_signature") or {}).get("font_family_norm") or "")
|
|
bd = block.get("span_signature", {}).get("bold")
|
|
bd = bd if bd else ("-Bd" in ff or "-Bold" in ff)
|
|
return bd
|
|
|
|
_heading_prefix_map: dict[tuple, str] = {}
|
|
sorted_sizes = sorted(size_groups.keys(), reverse=True)
|
|
prefixes = ["##", "###", "####", "#####", "######"]
|
|
prefix_idx = 0
|
|
for bucket in sorted_sizes:
|
|
bids = size_groups[bucket]
|
|
bolds = [bid for bid in bids if _bold_font_block(bid_to_block.get(bid, {}))]
|
|
regulars = [bid for bid in bids if bid not in bolds]
|
|
for group in (bolds, regulars):
|
|
if not group:
|
|
continue
|
|
upper = set()
|
|
lower = set()
|
|
for bid in group:
|
|
blk = bid_to_block.get(bid)
|
|
t = str(blk.get("text") or "") if blk else ""
|
|
if t and sum(1 for c in t if c.isupper()) / max(len(t), 1) > 0.7:
|
|
upper.add(bid)
|
|
else:
|
|
lower.add(bid)
|
|
for subgroup in (upper, lower):
|
|
if not subgroup:
|
|
continue
|
|
p = prefixes[min(prefix_idx, len(prefixes) - 1)]
|
|
for bid in subgroup:
|
|
blk = bid_to_block.get(bid)
|
|
if blk:
|
|
_heading_prefix_map[(int(blk.get("page", 0) or 0), blk.get("block_id"))] = p
|
|
prefix_idx += 1
|
|
|
|
# --- 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
|
|
|
|
|
|
if document_structure is None:
|
|
# Fallback compatibility path — shared with test fixtures and legacy
|
|
# callers that pass blocks without pre-computed document structure.
|
|
# normalize_document_structure with sparse synthetic blocks can
|
|
# incorrectly demote backmatter headings (e.g. Funding → body_paragraph)
|
|
# because the activation gates lack full-paper context. Only run
|
|
# structural analysis for style profiling; do NOT overwrite roles.
|
|
from paperforge.worker.ocr_document import normalize_document_structure
|
|
|
|
_, new_blocks = normalize_document_structure([dict(b) for b in structured_blocks])
|
|
for i, block in enumerate(structured_blocks):
|
|
old_role = block.get("role")
|
|
new_role = new_blocks[i].get("role") if i < len(new_blocks) else old_role
|
|
# Never demote tail/candidate roles — those are explicit
|
|
# test-level decisions, not normalization targets.
|
|
_PROTECTED = frozenset(
|
|
{
|
|
"backmatter_heading",
|
|
"backmatter_boundary_heading",
|
|
"backmatter_body",
|
|
"reference_heading",
|
|
"reference_item",
|
|
"table_caption",
|
|
"table_html",
|
|
"figure_caption_candidate",
|
|
"backmatter_heading_candidate",
|
|
"backmatter_boundary_candidate",
|
|
"non_body_insert",
|
|
"structured_insert",
|
|
"subsection_heading",
|
|
"sub_subsection_heading",
|
|
"section_heading",
|
|
"authors",
|
|
"paper_title",
|
|
"abstract_heading",
|
|
"abstract_body",
|
|
}
|
|
)
|
|
if old_role in _PROTECTED:
|
|
continue
|
|
if old_role == "body_paragraph" and new_role in {"structured_insert", "non_body_insert"}:
|
|
continue
|
|
if old_role != new_role:
|
|
block["role"] = new_role
|
|
block["role_confidence"] = new_blocks[i].get("role_confidence", block.get("role_confidence", 0.5))
|
|
|
|
structured_blocks = _convert_footnotes_to_callouts(structured_blocks)
|
|
|
|
style_profiles = _build_heading_style_profiles(structured_blocks)
|
|
|
|
if _can_apply_tail_segment_reorder(document_structure):
|
|
col_indices = {seg.get("column_index", 0) for seg in document_structure.tail_reading_order}
|
|
if len(col_indices) > 1:
|
|
seg_priority: dict[int, int] = {}
|
|
seg_position: dict[int, int] = {}
|
|
for seg_idx, seg in enumerate(document_structure.tail_reading_order):
|
|
for pos, bi in enumerate(seg.get("block_indices", [])):
|
|
seg_priority[bi] = seg_idx
|
|
seg_position[bi] = pos
|
|
|
|
for i, block in enumerate(structured_blocks):
|
|
block["_ordered_original_index"] = i
|
|
|
|
has_ref_zone = bool(getattr(document_structure, "reference_zone", None))
|
|
ordered_blocks = _order_tail_blocks(
|
|
structured_blocks, style_profiles=style_profiles, has_verified_reference_zone=has_ref_zone
|
|
)
|
|
|
|
tail_spread_pages = set(range(document_structure.spread_start, document_structure.spread_end + 1))
|
|
page_groups: dict[int, list[dict]] = {}
|
|
page_order: list[int] = []
|
|
for block in ordered_blocks:
|
|
page = block.get("page")
|
|
if page is None:
|
|
page = -1
|
|
if page not in page_groups:
|
|
page_groups[page] = []
|
|
page_order.append(page)
|
|
page_groups[page].append(block)
|
|
|
|
reordered: list[dict] = []
|
|
for page in page_order:
|
|
page_blocks = page_groups[page]
|
|
if page not in tail_spread_pages:
|
|
reordered.extend(page_blocks)
|
|
continue
|
|
|
|
seg_blocks: list[tuple[int, int, int, dict]] = []
|
|
non_segment_blocks: list[tuple[int, dict]] = []
|
|
for pos, block in enumerate(page_blocks):
|
|
orig = block.get("_ordered_original_index", -1)
|
|
if orig in seg_priority:
|
|
seg_blocks.append((seg_priority[orig], seg_position[orig], orig, block))
|
|
else:
|
|
non_segment_blocks.append((pos, block))
|
|
|
|
seg_blocks.sort(key=lambda x: (x[0], x[1]))
|
|
reordered.extend([b for _, b in non_segment_blocks] + [t[3] for t in seg_blocks])
|
|
|
|
ordered_blocks = reordered
|
|
else:
|
|
has_ref_zone = bool(getattr(document_structure, "reference_zone", None))
|
|
ordered_blocks = _order_tail_blocks(
|
|
structured_blocks, style_profiles=style_profiles, has_verified_reference_zone=has_ref_zone
|
|
)
|
|
else:
|
|
has_ref_zone = bool(getattr(document_structure, "reference_zone", None))
|
|
ordered_blocks = _order_tail_blocks(
|
|
structured_blocks, style_profiles=style_profiles, has_verified_reference_zone=has_ref_zone
|
|
)
|
|
|
|
for block in ordered_blocks:
|
|
role = block.get("role", "")
|
|
block_page = block.get("page")
|
|
|
|
if block_page is not None and block_page != current_page:
|
|
if current_page is not None:
|
|
_emit_page_objects(
|
|
lines,
|
|
current_page,
|
|
figures_by_page=figures_by_page,
|
|
unresolved_clusters_by_page=unresolved_clusters_by_page,
|
|
tables_by_page=tables_by_page,
|
|
reader_figures_by_page=reader_figures_by_page,
|
|
rendered_reader_figure_ids=rendered_reader_figure_ids,
|
|
emitted_figure_captions=emitted_figure_captions,
|
|
)
|
|
emitted_pages.add(current_page)
|
|
first_new_page = (current_page or 0) + 1
|
|
for p in range(first_new_page, block_page):
|
|
lines.append(f"<!-- page {p} -->")
|
|
lines.append("")
|
|
_emit_page_objects(
|
|
lines,
|
|
p,
|
|
figures_by_page=figures_by_page,
|
|
unresolved_clusters_by_page=unresolved_clusters_by_page,
|
|
tables_by_page=tables_by_page,
|
|
reader_figures_by_page=reader_figures_by_page,
|
|
rendered_reader_figure_ids=rendered_reader_figure_ids,
|
|
emitted_figure_captions=emitted_figure_captions,
|
|
)
|
|
emitted_pages.add(p)
|
|
current_page = block_page
|
|
lines.append(f"<!-- page {block_page} -->")
|
|
lines.append("")
|
|
emitted_pages.add(block_page)
|
|
last_structured_insert_page = None
|
|
last_structured_insert_bbox = None
|
|
|
|
if role == "structured_insert":
|
|
pass # render as callout below
|
|
elif not block.get("render_default", True):
|
|
bm_start = getattr(document_structure, "spread_start", None) if document_structure else None
|
|
if role != "frontmatter_noise" or block_page is None or not bm_start or block_page < bm_start:
|
|
continue
|
|
if role in _CONSUMED_FRONTMATTER_ROLES and int(block.get("page", 0) or 0) <= 2:
|
|
continue
|
|
|
|
# Ownership skip first — table note removal by contract, not by role
|
|
block_id = block.get("block_id")
|
|
if block_id is not None and (
|
|
(block_page, block_id) in consumed_table_block_keys
|
|
or (block_page, str(block_id)) in consumed_table_block_keys
|
|
):
|
|
continue
|
|
|
|
# Object writeback ownership — skip consumed blocks from render
|
|
if block.get("_object_consumed"):
|
|
continue
|
|
|
|
_SKIPPED_BODY_ROLES = {
|
|
"abstract_heading",
|
|
"abstract_body",
|
|
"footnote",
|
|
"frontmatter_noise",
|
|
"frontmatter_support",
|
|
"table_html",
|
|
"table_caption_candidate",
|
|
"figure_caption",
|
|
"figure_inner_text",
|
|
}
|
|
if role in _SKIPPED_BODY_ROLES:
|
|
bm_start = getattr(document_structure, "spread_start", None) if document_structure else None
|
|
if role == "frontmatter_noise" and block_page is not None and bm_start and block_page >= bm_start:
|
|
pass
|
|
else:
|
|
continue
|
|
|
|
block_key = _page_block_key(block_page, block_id)
|
|
if block_id is not None and (block_key in consumed_caption_keys or block_id in consumed_caption_ids_unkeyed):
|
|
continue
|
|
if block_key in abstract_member_keys or block_id in abstract_member_ids_unkeyed:
|
|
continue
|
|
|
|
raw_text = block.get("text", "")
|
|
text = normalize_ocr_math_text(raw_text)
|
|
text = re.sub(r"<table[^>]*>.*?</table>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
if text.strip().lower().startswith("<table"):
|
|
continue
|
|
|
|
if role == "structured_insert":
|
|
pass # render as callout below
|
|
elif not block.get("render_default", True):
|
|
bm_start2 = getattr(document_structure, "spread_start", None) if document_structure else None
|
|
if role != "frontmatter_noise" or block_page is None or not bm_start2 or block_page < bm_start2:
|
|
continue
|
|
if role == "backmatter_boundary_heading" or role == "backmatter_heading":
|
|
last_structured_insert_page = None
|
|
last_structured_insert_bbox = None
|
|
if text and not _should_suppress_frontmatter_heading(text):
|
|
lines.append(f"**{text}**")
|
|
lines.append("")
|
|
elif role == "reference_heading":
|
|
last_structured_insert_page = None
|
|
last_structured_insert_bbox = None
|
|
if not pre_reference_reader_figures_emitted and block_page is not None:
|
|
_emit_reader_figures_before_references(
|
|
lines,
|
|
start_page=block_page,
|
|
reader_figures_by_page=reader_figures_by_page,
|
|
rendered_reader_figure_ids=rendered_reader_figure_ids,
|
|
)
|
|
pre_reference_reader_figures_emitted = True
|
|
# Emit matched figures and tables on this page before references
|
|
if block_page is not None:
|
|
for fig in figures_by_page.get(block_page, []):
|
|
if str(fig["figure_id"]).startswith("unmatched_legend_"):
|
|
continue
|
|
lines.append(f"![[render/figures/{fig['figure_id']}.md]]")
|
|
lines.append("")
|
|
figures_by_page.pop(block_page, None)
|
|
for tbl_id in tables_by_page.get(block_page, []):
|
|
lines.append(f"![[render/tables/{tbl_id}.md]]")
|
|
lines.append("")
|
|
tables_by_page.pop(block_page, None)
|
|
lines.append(f"## {text}")
|
|
lines.append("")
|
|
elif role in ("subsection_heading", "sub_subsection_heading", "section_heading"):
|
|
last_structured_insert_page = None
|
|
last_structured_insert_bbox = None
|
|
text = _display_heading_text(block, text)
|
|
if role == "section_heading":
|
|
if _should_suppress_frontmatter_heading(text):
|
|
continue
|
|
if _is_bogus_heading(text):
|
|
if text:
|
|
lines.append(text)
|
|
lines.append("")
|
|
continue
|
|
key = (int(block.get("page", 0) or 0), block.get("block_id"))
|
|
prefix = _heading_prefix_map.get(key)
|
|
if prefix is None:
|
|
depth = _heading_number_depth_text(text)
|
|
prefix = "##" if depth <= 1 else "###"
|
|
lines.append(f"{prefix} {text}")
|
|
lines.append("")
|
|
elif role == "structured_insert":
|
|
container_text = block.get("_container_text")
|
|
if container_text and isinstance(container_text, str):
|
|
source_text = normalize_ocr_math_text(" ".join(container_text.replace("\n", " ").split()))
|
|
else:
|
|
source_text = raw_text
|
|
callout_lines = (
|
|
_table_html_to_lines(source_text)
|
|
if source_text.strip().lower().startswith("<table")
|
|
else source_text.strip().split("\n")
|
|
)
|
|
callout_lines = [line for line in callout_lines if line.strip()]
|
|
if callout_lines:
|
|
bbox = block.get("bbox") or block.get("block_bbox") or [0, 0, 0, 0]
|
|
merge_with_previous = False
|
|
if (
|
|
last_structured_insert_page == block_page
|
|
and last_structured_insert_bbox is not None
|
|
and len(bbox) >= 4
|
|
):
|
|
prev = last_structured_insert_bbox
|
|
overlaps_x = bbox[0] <= prev[2] + 40 and bbox[2] >= prev[0] - 40
|
|
gap_y = bbox[1] - prev[3]
|
|
merge_with_previous = overlaps_x and gap_y <= 80
|
|
if merge_with_previous and lines and lines[-1] == "":
|
|
lines.pop()
|
|
if (
|
|
merge_with_previous
|
|
and callout_lines
|
|
and callout_lines[0].lstrip().startswith("•")
|
|
and lines
|
|
and lines[-1].startswith("> ")
|
|
and "•" in lines[-1][2:]
|
|
and not lines[-1].startswith("> •")
|
|
):
|
|
lines.pop()
|
|
if not merge_with_previous:
|
|
lines.append("> [!NOTE]")
|
|
for cl in callout_lines:
|
|
lines.append(f"> {cl}")
|
|
if not merge_with_previous:
|
|
lines.append("")
|
|
last_structured_insert_page = block_page
|
|
last_structured_insert_bbox = bbox if len(bbox) >= 4 else None
|
|
elif role == "table_caption":
|
|
tbl_ids_for_page = tables_by_page.get(block_page, [])
|
|
if tbl_ids_for_page:
|
|
tbl_id = tbl_ids_for_page.pop(0)
|
|
lines.append(f"![[render/tables/{tbl_id}.md]]")
|
|
lines.append("")
|
|
elif text:
|
|
lines.append(f"> **Table Caption:** {text}")
|
|
lines.append("")
|
|
elif role == "figure_caption":
|
|
if text:
|
|
lines.append(text)
|
|
lines.append("")
|
|
elif role in ("backmatter_body", "tail_candidate_body", "body_paragraph"):
|
|
if last_structured_insert_page is not None:
|
|
lines.append("")
|
|
last_structured_insert_page = None
|
|
last_structured_insert_bbox = None
|
|
if text:
|
|
if block_page is not None and block.get("_text_source") == "pdf_text_layer_fallback":
|
|
if any(
|
|
_backfill_coverage_in_existing(text, existing)
|
|
>= _BACKFILL_OVERLAP_REJECT_THRESHOLD
|
|
for existing in _emitted_body_text_by_page.get(block_page, [])
|
|
):
|
|
continue
|
|
_emitted_body_text_by_page.setdefault(block_page if block_page is not None else -1, []).append(text)
|
|
lines.append(text)
|
|
lines.append("")
|
|
else:
|
|
if last_structured_insert_page is not None:
|
|
lines.append("")
|
|
last_structured_insert_page = None
|
|
last_structured_insert_bbox = None
|
|
if text:
|
|
lines.append(text)
|
|
lines.append("")
|
|
|
|
# Emit objects for the last rendered page
|
|
if current_page is not None:
|
|
_emit_page_objects(
|
|
lines,
|
|
current_page,
|
|
figures_by_page=figures_by_page,
|
|
unresolved_clusters_by_page=unresolved_clusters_by_page,
|
|
tables_by_page=tables_by_page,
|
|
reader_figures_by_page=reader_figures_by_page,
|
|
rendered_reader_figure_ids=rendered_reader_figure_ids,
|
|
emitted_figure_captions=emitted_figure_captions,
|
|
)
|
|
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("")
|
|
_emit_page_objects(
|
|
lines,
|
|
p,
|
|
figures_by_page=figures_by_page,
|
|
unresolved_clusters_by_page=unresolved_clusters_by_page,
|
|
tables_by_page=tables_by_page,
|
|
reader_figures_by_page=reader_figures_by_page,
|
|
rendered_reader_figure_ids=rendered_reader_figure_ids,
|
|
emitted_figure_captions=emitted_figure_captions,
|
|
)
|
|
|
|
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")
|