File size: 19,585 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
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
#!/usr/bin/env python3
"""
Section-aware paper text for the poster SFT input (v2 of paper_side.load_paper_text).

Why this exists.  load_paper_text reads the LayoutLMv3 text boxes in page order
and stops at 12,000 characters.  Measured on the built dataset that cut lands
inside Related Work / Preliminaries for almost every paper: only 7.5% of inputs
contained any Experiments/Results section and 0.6% a Conclusion, while the
posters the model is trained to emit are mostly Method / Results / Conclusion.
Only 13.6% of poster text blocks shared a 7-gram with the input.

What it does instead -- the way a person reads a paper before making a poster:

  1. read every text box (same LayoutLMv3 classes, same reading order) up to
     References / Acknowledgements / Appendix, never a fixed character cut;
  2. split the stream into sections at the `title` boxes, work out each
     heading's level (numbering) and *role* (introduction, background, method,
     experiments, conclusion, ...);
  3. spend a per-role character budget: Introduction, Method, Experiments and
     Conclusion get most of it, Related Work gets none, and inside a section
     every subsection keeps its opening paragraphs rather than the first
     subsection eating the whole budget;
  4. tell the model which section each figure/table lives in, so a figure in
     the Method section reads as the overview figure and one in Experiments as
     a result.

The output is still plain text with `## ` headings, now `## [role] heading`,
so the prompt format changes only inside the <paper> and <assets> blocks.
"""
from __future__ import annotations

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

from paper_side import TEXT_CLASSES, reading_order

# PyMuPDF returns the PDF's own characters: typographic ligatures (fi fl) and the
# line-break hyphens of justified text ("calibra- tion") come through verbatim.
# Measured on 800 built inputs: 6.5 hyphenation artefacts and ~3 ligatures per
# 1000 words -- text the model would otherwise learn to copy into posters.
_LIGATURES = {'\ufb00': 'ff', '\ufb01': 'fi', '\ufb02': 'fl', '\ufb03': 'ffi', '\ufb04': 'ffl', '\ufb05': 'st', '\ufb06': 'st'}
_PUA = re.compile('[\ue000-\uf8ff]')
_HYPHEN_BREAK = re.compile(r'\b([A-Za-z]+)- ([a-z]+)\b')


def clean_text(t: str) -> str:
    for k, v in _LIGATURES.items():               # not NFKC: that would also flatten x^2 and R^d
        t = t.replace(k, v)
    t = _PUA.sub('', t)                           # private-use glyphs (broken symbol fonts)
    return re.sub(r'\s+', ' ', t).strip()


def dehyphenate(texts: List[str]) -> List[str]:
    """Resolve line-break hyphens with the paper's own vocabulary.

    "calibra- tion" -> "calibration" (the joined word occurs elsewhere, or neither
    form does: a syllable break); "self- supervised" -> "self-supervised" (the
    hyphenated form occurs elsewhere in the paper: a real compound).
    """
    joined_vocab, hyphen_vocab = set(), set()
    for t in texts:
        for w in re.findall(r'[A-Za-z]+(?:-[A-Za-z]+)+', t):
            hyphen_vocab.add(w.lower())
        for w in re.findall(r'\b[A-Za-z]{4,}\b', t):
            joined_vocab.add(w.lower())

    def fix(m):
        a, b = m.group(1), m.group(2)
        if (a + b).lower() in joined_vocab:
            return a + b
        if (a + '-' + b).lower() in hyphen_vocab:
            return a + '-' + b
        return a + b
    return [_HYPHEN_BREAK.sub(fix, t) for t in texts]

# --------------------------------------------------------------------------- #
# budgets (characters).  ~3.4 chars/token for this kind of English prose.
# --------------------------------------------------------------------------- #
#
# Measured with the Qwen3.8 tokenizer on the test split: this prose runs ~2.5
# chars/token (math, citations), so 22k chars ≈ 9k text tokens; with ~2.2k
# visual tokens and a ~3.8k-token target the whole sample sits ~11.5k tokens
# median, p90 ≈ 13.5k, inside a 16384 window with a small margin.
ROLE_BUDGET = {
    'abstract': 0,          # already carried in the <paper> header
    'introduction': 4000,
    'related': 0,           # posters never carry it
    'background': 1500,
    'method': 8000,
    'experiments': 7000,
    'conclusion': 2000,
    'other': 1200,
}
TOTAL_BUDGET = 22000
SUBSECTION_FLOOR = 500      # every subsection keeps at least its opening
SECTION_FLOOR = 800
STOP_ROLES = {'references', 'acknowledgements', 'appendix'}

_RULES: List[Tuple[str, str]] = [
    ('references', r'\b(references?|bibliography)\b'),
    ('acknowledgements', r'\backnowledg'),
    ('appendix', r'\b(appendix|appendices|supplementa)'),
    ('conclusion', r'\b(conclusions?|concluding|discussions?|limitations?|future work|'
                   r'summary|broader impacts?|societal impact|ethic)'),
    ('experiments', r'\b(experiments?|experimental|results?|evaluations?|empirical|'
                    r'benchmarks?|ablations?|case stud|applications?|findings)'),
    ('related', r'\b(related|prior|previous|existing) work|literature'),
    ('introduction', r'\bintroduction\b|\bmotivation\b'),
    ('abstract', r'\babstract\b'),
    ('background', r'\b(preliminar|background|notation|problem (setup|formulation|'
                   r'statement|definition|setting)|setup|setting|definitions?|formulation)'),
]
_NUM = re.compile(r'^\s*(\d+(?:\.\d+)*)[.\s]')
_ROMAN = re.compile(r'^\s*(?:[IVX]+)[.\s]+[A-Z]')
_LETTER = re.compile(r'^\s*[A-Z][.)]\s+[A-Z]')


def _role_of(text: str) -> Optional[str]:
    t = text.lower()
    for role, pat in _RULES:
        if re.search(pat, t):
            return role
    return None


def _level_of(text: str, role: Optional[str], have_l1: bool) -> int:
    m = _NUM.match(text)
    if m:
        return min(m.group(1).count('.') + 1, 3)
    if _ROMAN.match(text):
        return 1
    if _LETTER.match(text):
        return 2
    if role in ('abstract', 'introduction', 'related', 'conclusion') or role in STOP_ROLES:
        return 1
    return 2 if have_l1 else 1


def _looks_like_heading(text: str) -> bool:
    if len(text) > 140 or len(text) < 3:
        return False
    if re.match(r'^(theorem|lemma|corollary|proposition|definition|proof|remark|'
                r'assumption|example|algorithm|figure|table)\b', text.lower()):
        return False
    return True


# --------------------------------------------------------------------------- #
# extraction
# --------------------------------------------------------------------------- #

def read_items(pid: str, root: str, pdf_path: str, max_pages: int = 12) -> Tuple[List[Dict], Dict]:
    """Text boxes in reading order: [{'kind','text','page','bbox','key'}], plus page info.

    `key` is a sortable reading-order position (page, column, y) shared with
    assets so a figure can be attributed to the section it sits in.
    """
    d = Path(root) / pid
    layout_p = d / 'layout.json'
    if not layout_p.exists() or not pdf_path or not Path(pdf_path).exists():
        return [], {}
    layout = json.loads(layout_p.read_text(encoding='utf-8'))
    meta = json.loads((d / 'assets.json').read_text(encoding='utf-8'))
    scale = 72.0 / meta.get('dpi', 200)

    import fitz
    doc = fitz.open(pdf_path)
    items: List[Dict] = []
    pages: Dict[int, Dict] = {}
    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]
            page = doc[page_no - 1]
            pw = page.rect.width / scale
            ph = page.rect.height / scale
            pages[page_no] = {'w': pw, 'h': ph}
            if not boxes:
                continue
            ordered = reading_order(boxes, pw)
            body_w = max(b['bbox'][2] - b['bbox'][0] for b in boxes)
            for b in ordered:
                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 = clean_text(t)
                if len(t) < 3:
                    continue
                items.append({'kind': 'title' if TEXT_CLASSES[b['class_name']] == 'title' else 'text',
                              'text': t, 'page': page_no, 'bbox': b['bbox'],
                              'key': order_key(b['bbox'], page_no, pw, body_w)})
    finally:
        doc.close()
    fixed = dehyphenate([it['text'] for it in items])
    for it, t in zip(items, fixed):
        it['text'] = t
    return items, pages


def order_key(bbox, page_no: int, page_w: float, body_w: float) -> Tuple[int, int, float]:
    x1, y1, x2, _ = bbox
    spanning = (x2 - x1) > 0.75 * body_w
    col = 0 if spanning or (x1 + x2) / 2 < page_w / 2 else 1
    return (page_no, col, float(y1))


def split_sections(items: List[Dict]) -> List[Dict]:
    """-> [{'heading','level','role','page','key','groups':[{'heading','level','paras':[str]}]}]

    Level-1 sections carry a role; deeper headings become groups inside the
    enclosing level-1 section.  Text before the first real heading (paper
    title, authors, affiliations, and the abstract when it has no heading box)
    is kept in a 'front' section that the budget step drops.
    """
    sections: List[Dict] = []
    cur: Optional[Dict] = None
    seen_intro = seen_exp = False
    front = {'heading': '', 'level': 1, 'role': 'front', 'page': 0, 'key': (0, 0, 0.0),
             'groups': [{'heading': '', 'level': 1, 'paras': []}]}

    def new_section(heading, level, role, it):
        return {'heading': heading, 'level': level, 'role': role, 'page': it['page'],
                'key': it['key'], 'groups': [{'heading': '', 'level': level, 'paras': []}]}

    for it in items:
        if it['kind'] == 'title' and _looks_like_heading(it['text']):
            role = _role_of(it['text'])
            level = _level_of(it['text'], role, cur is not None)
            if level == 1:
                if role is None:
                    # positional default: between intro and experiments = method,
                    # after experiments = still experiments (Analysis, Discussion...)
                    role = 'experiments' if seen_exp else ('method' if seen_intro else 'other')
                if role in STOP_ROLES:
                    break
                if not seen_intro and role not in ('abstract', 'introduction') and not _NUM.match(it['text']):
                    # paper title, author names, affiliations: front matter, not a section
                    role = 'front'
                seen_intro |= role in ('introduction', 'abstract')
                seen_exp |= role == 'experiments'
                cur = new_section(it['text'], 1, role, it)
                sections.append(cur)
            else:
                if cur is None:           # sub-heading before any section: treat as front text
                    front['groups'][0]['paras'].append(it['text'])
                    continue
                cur['groups'].append({'heading': it['text'], 'level': level, 'paras': []})
            continue
        target = cur if cur is not None else front
        target['groups'][-1]['paras'].append(it['text'])
    return [front] + sections


# --------------------------------------------------------------------------- #
# budgeting
# --------------------------------------------------------------------------- #

def _cut(text: str, n: int) -> str:
    """Cut at a sentence boundary at or before n characters."""
    if len(text) <= n:
        return text
    head = text[:n]
    m = max(head.rfind('. '), head.rfind('? '), head.rfind('! '))
    if m > n * 0.5:
        return head[:m + 1]
    m = head.rfind(' ')
    return (head[:m] if m > 0 else head) + ' …'


def _take_paras(paras: List[str], budget: int) -> Tuple[str, bool]:
    out, used, cut = [], 0, False
    for p in paras:
        if used + len(p) + 2 <= budget:
            out.append(p)
            used += len(p) + 2
            continue
        room = budget - used
        if room > 200:
            out.append(_cut(p, room))
        cut = True
        break
    return '\n\n'.join(out), cut


def _spend(groups: List[Dict], budget: int) -> str:
    """Distribute a section's budget over its subsection groups, floor first."""
    lens = [sum(len(p) + 2 for p in g['paras']) for g in groups]
    total = sum(lens)
    if total == 0:
        return ''
    if total <= budget:
        alloc = lens
    else:
        n = len(groups)
        floor = min(SUBSECTION_FLOOR, budget // max(n, 1))
        alloc = [min(l, floor) for l in lens]
        rest = budget - sum(alloc)
        extra_need = [l - a for l, a in zip(lens, alloc)]
        need_total = sum(extra_need)
        if rest > 0 and need_total > 0:
            alloc = [a + int(rest * e / need_total) for a, e in zip(alloc, extra_need)]
    parts = []
    for g, b in zip(groups, alloc):
        mark = '#' * max(g['level'] + 1, 3)          # section = ##, subsections = ###, ####
        if not g['paras']:
            if g['heading']:
                parts.append(mark + ' ' + g['heading'])
            continue
        body, cut = _take_paras(g['paras'], b)
        if not body:
            continue
        head = (mark + ' ' + g['heading'] + '\n\n') if g['heading'] else ''
        parts.append(head + body + (' […]' if cut else ''))
    return '\n\n'.join(parts)


def budget_body(sections: List[Dict], total: int = TOTAL_BUDGET,
                role_budget: Dict[str, int] = ROLE_BUDGET,
                abstract_missing: bool = False) -> Tuple[str, Dict]:
    """Render the sections into the body text under the per-role budgets."""
    if total < TOTAL_BUDGET:
        # long poster / many figures: shrink every role's share proportionally
        # so the section mix stays the same and only the depth per section drops
        f = total / TOTAL_BUDGET
        role_budget = {k: int(v * f) for k, v in role_budget.items()}
    keep = [s for s in sections if s['role'] not in ('front', 'related', 'abstract')]
    if abstract_missing:
        for s in sections:
            if s['role'] in ('abstract', 'front'):
                keep.insert(0, dict(s, role='abstract'))
                break
    lens = {id(s): sum(len(p) + 2 for g in s['groups'] for p in g['paras']) for s in keep}
    by_role: Dict[str, List[Dict]] = {}
    for s in keep:
        by_role.setdefault(s['role'], []).append(s)

    alloc: Dict[int, int] = {}
    for role, secs in by_role.items():
        b = role_budget.get(role, role_budget['other'])
        if role == 'abstract':
            b = 1800
        need = [lens[id(s)] for s in secs]
        if sum(need) <= b:
            for s, n in zip(secs, need):
                alloc[id(s)] = n
        else:
            floor = min(SECTION_FLOOR, b // max(len(secs), 1))
            a = [min(n, floor) for n in need]
            rest = b - sum(a)
            extra = [n - x for n, x in zip(need, a)]
            et = sum(extra)
            for i, s in enumerate(secs):
                alloc[id(s)] = a[i] + (int(rest * extra[i] / et) if et > 0 and rest > 0 else 0)
    # leftover (no conclusion, short intro...) goes to experiments, then method
    used = sum(alloc.values())
    for role in ('experiments', 'method', 'introduction'):
        rest = total - used
        if rest <= 0:
            break
        for s in by_role.get(role, []):
            room = lens[id(s)] - alloc[id(s)]
            if room > 0:
                add = min(room, rest)
                alloc[id(s)] += add
                used += add
                rest -= add
    # hard cap
    if used > total:
        scale = total / used
        alloc = {k: int(v * scale) for k, v in alloc.items()}

    parts, stats = [], {}
    for s in keep:
        text = _spend(s['groups'], alloc[id(s)])
        if not text.strip():
            continue
        parts.append(f"## [{s['role']}] {s['heading']}".rstrip() + '\n\n' + text)
        stats[s['role']] = stats.get(s['role'], 0) + len(text)
    body = '\n\n'.join(parts)
    stats['total'] = len(body)
    stats['roles'] = sorted(by_role)
    return body, stats


def load_paper_body(pid: str, root: str, pdf_path: str, abstract_missing: bool = False,
                    max_pages: int = 12, total: int = TOTAL_BUDGET) -> Tuple[str, Dict, List[Dict]]:
    items, _ = read_items(pid, root, pdf_path, max_pages)
    if not items:
        return '', {'total': 0, 'roles': []}, []
    sections = split_sections(items)
    body, stats = budget_body(sections, total=min(total, TOTAL_BUDGET), abstract_missing=abstract_missing)
    stats['budget'] = min(total, TOTAL_BUDGET)
    return body, stats, sections


# --------------------------------------------------------------------------- #
# assets: section attribution + caption fallback
# --------------------------------------------------------------------------- #

def section_of_asset(sections: List[Dict], page: int, bbox, page_w: float, body_w: float) -> Optional[Dict]:
    key = order_key(bbox, page, page_w, body_w)
    best = None
    for s in sections:
        if s['role'] == 'front':
            continue
        if s['key'] <= key:
            best = s
    return best


_CAP_LINE = re.compile(r'^\s*(Figure|Fig\.|Table)\s*(\d+|[IVX]+)\s*[:.]', re.I)


def section_by_reference(sections: List[Dict], caption: str, kind: str) -> Optional[Dict]:
    """The section whose text first cites this figure/table ("Figure 3", "Tab. 1").

    Figures sit where the page layout allows (often at the top of the next
    column, under the next heading); they are *discussed* in the section that
    cites them, and that is the section a poster would place them in.
    """
    m = _CAP_LINE.match(caption or '')
    if not m or kind not in ('figure', 'table'):
        return None
    num = m.group(2)
    if kind == 'table':
        pat = re.compile(r'\b(Table|Tab\.)\s*' + re.escape(num) + r'(?!\d)')
    else:
        pat = re.compile(r'\b(Figure|Fig\.|Figs?\.)\s*' + re.escape(num) + r'(?!\d)')
    for s in sections:
        if s['role'] in ('front', 'related'):
            continue
        for g in s['groups']:
            for p in g['paras']:
                if pat.search(p):
                    return s
    return None


def caption_fallback(doc, page_no: int, bbox, kind: str, scale: float, max_chars: int = 300) -> str:
    """When no LayoutLMv3 caption box matched: the nearest 'Figure N:'/'Table N:'
    text block on the page, below the asset (tables: above or below)."""
    if page_no > len(doc):
        return ''
    page = doc[page_no - 1]
    ph = page.rect.height / scale
    ax1, ay1, ax2, ay2 = bbox
    want = 'table' if kind == 'table' else 'figure'
    best, best_gap = '', 1e9
    for blk in page.get_text('dict').get('blocks', []):
        lines = blk.get('lines') or []
        if not lines:
            continue
        text = ' '.join(s['text'] for l in lines for s in l['spans'])
        text = re.sub(r'\s+', ' ', text).strip()
        m = _CAP_LINE.match(text)
        if not m:
            continue
        if (m.group(1).lower().startswith('tab')) != (want == 'table'):
            continue
        bx1, by1, bx2, by2 = (v / scale for v in blk['bbox'])
        lo, hi = max(ax1, bx1), min(ax2, bx2)
        if hi <= lo:
            continue
        gap = by1 - ay2 if by1 >= ay2 else ay1 - by2
        if gap < -5 or gap > 0.2 * ph:
            continue
        if gap < best_gap:
            best, best_gap = text, gap
    if len(best) > max_chars:
        best = best[:max_chars].rsplit(' ', 1)[0] + '…'
    return best