File size: 12,322 Bytes
c58c39d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
#!/usr/bin/env python3
"""
The paper side of a training sample: which assets exist, and what each is called.

Two things here are not what the earlier docling-based version did.

**Assets come from the LayoutLMv3 registry.** The poster side was derendered
with LayoutLMv3, so the paper side has to use it too or the crops never line up
-- that asymmetry is what made the first matching attempt useless. The registry
also carries page and bbox, which the caption logic needs.

**Captions are read out of the PDF at the caption box's own coordinates.**
Pairing the Nth crop with the Nth caption in a parsed-content dump is what put
"Figure 3"'s text on figure_4 earlier: any dropped detection on either side
shifts every later pairing silently. LayoutLMv3 already emits figure_caption /
table_caption boxes, so instead of counting, this finds the caption box nearest
*below* each asset on the same page and pulls the text inside that rectangle
straight from the PDF. A caption either has a box under its figure or the asset
goes uncaptioned; it can no longer be given someone else's.

    from paper_side import load_paper_assets
    assets = load_paper_assets(pid, paper_assets_root, pdf_path)
"""

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Dict, List, Optional

CAPTION_FOR = {'figure': 'figure_caption', 'table': 'table_caption',
               'isolate_formula': 'formula_caption'}
KIND_NAME = {'figure': 'figure', 'table': 'table', 'isolate_formula': 'formula'}
# A caption sits directly under (tables: often over) its asset. Anything further
# than this fraction of the page height away belongs to something else.
MAX_CAPTION_GAP = 0.12
MAX_CAPTION_CHARS = 300


def _overlap(a, b) -> float:
    """Horizontal overlap of two boxes as a fraction of the narrower one."""
    lo, hi = max(a[0], b[0]), min(a[2], b[2])
    if hi <= lo:
        return 0.0
    return (hi - lo) / max(min(a[2] - a[0], b[2] - b[0]), 1e-6)


def _clean(t: str) -> str:
    t = re.sub(r'\s+', ' ', t).strip()
    if len(t) > MAX_CAPTION_CHARS:
        t = t[:MAX_CAPTION_CHARS].rsplit(' ', 1)[0] + '…'
    return t


def load_paper_assets(pid: str, root: str, pdf_path: Optional[str] = None,
                      main_body_pages: int = 12) -> Optional[Dict]:
    """-> {'assets': [...], 'counts': {...}, 'n_pages': int} or None."""
    d = Path(root) / pid
    meta_p = d / 'assets.json'
    if not meta_p.exists():
        return None
    try:
        meta = json.loads(meta_p.read_text(encoding='utf-8'))
    except Exception:                                                # noqa: BLE001
        return None

    assets = []
    for a in meta.get('assets', []):
        f = d / 'assets' / f"{a['id']}.png"
        if not f.exists():
            continue
        assets.append({'id': a['id'], 'class': a['class'], 'kind': KIND_NAME[a['class']],
                       'page': a['page'], 'bbox': a['bbox'], 'aspect': a.get('aspect'),
                       'area': (a['bbox'][2] - a['bbox'][0]) * (a['bbox'][3] - a['bbox'][1]),
                       'file': str(f), 'caption': '',
                       'main_body': a['page'] <= main_body_pages})

    if pdf_path:
        try:
            _attach_captions(assets, d, pdf_path, meta.get('dpi', 200))
        except Exception:                                            # noqa: BLE001
            pass                       # captions are a nicety, assets are not

    return {'paper_id': pid, 'assets': assets, 'counts': meta.get('counts', {}),
            'n_pages': meta.get('n_pages', 0)}


def _attach_captions(assets: List[Dict], paper_dir: Path, pdf_path: str,
                     dpi: int) -> None:
    layout_p = paper_dir / 'layout.json'
    if not layout_p.exists():
        return
    layout = json.loads(layout_p.read_text(encoding='utf-8'))
    caps: Dict[int, List[Dict]] = {}
    for d in layout:
        if d.get('class_name') in CAPTION_FOR.values():
            caps.setdefault(d['page'], []).append(d)
    if not caps:
        return

    import fitz
    doc = fitz.open(pdf_path)
    scale = 72.0 / dpi              # rendered pixels back to PDF points
    try:
        for a in assets:
            want = CAPTION_FOR.get(a['class'])
            page_caps = [c for c in caps.get(a['page'], [])
                         if c.get('class_name') == want]
            if not page_caps or a['page'] > len(doc):
                continue
            page = doc[a['page'] - 1]
            ph = page.rect.height / scale       # page height in rendered pixels
            ax1, ay1, ax2, ay2 = a['bbox']
            best, best_gap = None, 1e9
            for c in page_caps:
                cx1, cy1, cx2, cy2 = c['bbox']
                if _overlap((ax1, ay1, ax2, ay2), (cx1, cy1, cx2, cy2)) < 0.3:
                    continue
                # below the asset, or above it (the usual place for a table)
                gap = cy1 - ay2 if cy1 >= ay2 else ay1 - cy2
                if gap < -5 or gap > MAX_CAPTION_GAP * ph:
                    continue
                if gap < best_gap:
                    best, best_gap = c, gap
            if best is None:
                continue
            x1, y1, x2, y2 = (v * scale for v in best['bbox'])
            txt = page.get_text('text', clip=fitz.Rect(x1 - 2, y1 - 2, x2 + 2, y2 + 2))
            a['caption'] = _clean(txt)
    finally:
        doc.close()


# --------------------------------------------------------------------------- #
# paper text
# --------------------------------------------------------------------------- #

# LayoutLMv3's own vocabulary. `abandon` is what it calls running heads, page
# numbers and footers -- exactly the text a poster never carries, and exactly
# what a raw page-level text dump would splice into the middle of a paragraph.
TEXT_CLASSES = {'title': 'title', 'plain text': 'text', 'plain_text': 'text'}
DROP_CLASSES = {'abandon', 'figure', 'table', 'isolate_formula',
                'figure_caption', 'table_caption', 'formula_caption',
                'table_footnote'}
MAX_BODY_CHARS = 12000


SPAN_FRAC = 0.75          # of the page's own text width, not the paper size


def is_two_column(boxes: List[Dict], mid: float) -> bool:
    """Does this page actually have two columns?

    Deciding by "is the centre left or right of the page middle" invents a
    gutter on single-column pages, and a wide-margin single-column layout is
    common enough (ICLR's template among them) that the invented gutter
    reordered abstracts after introductions. A real two-column page has boxes
    that finish before the middle *and* boxes that start after it.
    """
    return (any(b['bbox'][2] < mid for b in boxes)
            and any(b['bbox'][0] > mid for b in boxes))


def reading_order(boxes: List[Dict], page_width: float) -> List[Dict]:
    """Order text boxes the way the page is read.

    Two corrections over sorting by (column, y):

    Spanning is measured against the widest box on the page, not the paper
    size. A single-column ICLR page is 1700 px wide but its text column is
    1115 px, so a page-relative threshold classified ordinary body paragraphs
    as spanning and full-width titles as not.

    Column splitting only happens on pages that have two columns. Otherwise
    everything is read top to bottom, which is what a single-column page wants
    and what the gutter heuristic actively broke.

    On a genuine two-column page a spanning box acts as a barrier: pending
    column boxes are emitted left-then-right before it, so full-width headers
    stay in place and the columns beneath them stay in order.
    """
    if not boxes:
        return []
    body_w = max(b['bbox'][2] - b['bbox'][0] for b in boxes)
    span_w = SPAN_FRAC * body_w
    ordered = sorted(boxes, key=lambda b: b['bbox'][1])

    mid = page_width / 2
    narrow = [b for b in ordered if (b['bbox'][2] - b['bbox'][0]) <= span_w]
    if not is_two_column(narrow, mid):
        return ordered

    out: List[Dict] = []
    pending: List[Dict] = []

    def flush():
        if not pending:
            return
        left = [b for b in pending if (b['bbox'][0] + b['bbox'][2]) / 2 < mid]
        right = [b for b in pending if (b['bbox'][0] + b['bbox'][2]) / 2 >= mid]
        for group in (left, right):
            out.extend(sorted(group, key=lambda b: b['bbox'][1]))
        pending.clear()

    for b in ordered:
        if (b['bbox'][2] - b['bbox'][0]) > span_w:
            flush()
            out.append(b)
        else:
            pending.append(b)
    flush()
    return out


def load_paper_text(pid: str, root: str, pdf_path: str,
                    max_pages: int = 12, max_chars: int = MAX_BODY_CHARS) -> str:
    """Body text in reading order, taken from the detected text boxes.

    Reading text box by box rather than page by page buys three things the raw
    dump cannot: running heads and page numbers are dropped because LayoutLMv3
    labels them `abandon`; captions and equations are dropped because they are
    already carried as assets and would otherwise appear twice; and two-column
    papers come out in column order instead of interleaved across the gutter.

    Ordering is by column then vertical position, which is what a two-column
    layout needs and what a single-column layout is unaffected by.
    """
    d = Path(root) / pid
    layout_p = d / 'layout.json'
    if not layout_p.exists() or not pdf_path:
        return ''
    try:
        layout = json.loads(layout_p.read_text(encoding='utf-8'))
        meta = json.loads((d / 'assets.json').read_text(encoding='utf-8'))
    except Exception:                                                # noqa: BLE001
        return ''
    scale = 72.0 / meta.get('dpi', 200)

    try:
        import fitz
        doc = fitz.open(pdf_path)
    except Exception:                                                # noqa: BLE001
        return ''

    parts: List[str] = []
    try:
        for page_no in range(1, min(max_pages, len(doc)) + 1):
            boxes = [b for b in layout
                     if b.get('page') == page_no
                     and b.get('class_name') in TEXT_CLASSES
                     and b.get('class_name') not in DROP_CLASSES]
            if not boxes:
                continue
            page = doc[page_no - 1]
            pw = page.rect.width / scale
            for b in reading_order(boxes, pw):
                x1, y1, x2, y2 = (v * scale for v in b['bbox'])
                t = page.get_text('text', clip=fitz.Rect(x1 - 1, y1 - 1, x2 + 1, y2 + 1))
                t = re.sub(r'\s+', ' ', t).strip()
                if len(t) < 3:
                    continue
                parts.append(('## ' + t) if TEXT_CLASSES[b['class_name']] == 'title' else t)
                if sum(len(p) for p in parts) > max_chars:
                    raise StopIteration
    except StopIteration:
        pass
    finally:
        doc.close()

    out = '\n\n'.join(parts)
    return out[:max_chars]


def select_for_prompt(assets: List[Dict], required: List[str],
                      max_total: int, per_kind: Dict[str, int]) -> List[Dict]:
    """Choose which assets the model is shown.

    Anything the target program references is mandatory: dropping it would make
    the sample ask for a figure that was never offered, teaching the model to
    hallucinate ids. Whatever budget is left goes to the largest remaining
    assets, since a poster overwhelmingly draws on a paper's headline figures.
    """
    by_id = {a['id']: a for a in assets}
    out = [by_id[i] for i in required if i in by_id]
    have = {a['id'] for a in out}
    kind_n: Dict[str, int] = {}
    for a in out:
        kind_n[a['kind']] = kind_n.get(a['kind'], 0) + 1

    for a in sorted(assets, key=lambda x: -x['area']):
        if len(out) >= max_total:
            break
        if a['id'] in have or not a['main_body']:
            continue
        k = a['kind']
        if kind_n.get(k, 0) >= per_kind.get(k, 99):
            continue
        out.append(a)
        have.add(a['id'])
        kind_n[k] = kind_n.get(k, 0) + 1

    # Stable, readable order for the prompt: figures, then tables, then formulas.
    order = {'figure': 0, 'table': 1, 'formula': 2}
    return sorted(out, key=lambda a: (order.get(a['kind'], 9), a['id']))