Spaces:
Running on Zero
Running on Zero
File size: 9,951 Bytes
c58c39d 96e4fd5 c58c39d 77e6466 c58c39d 77e6466 9b03fbd c58c39d 033094c 96e4fd5 f1b7085 96e4fd5 c58c39d 9b03fbd 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 | """Paper-only prompt construction. No poster GT or external generation API."""
import json
import re
import unicodedata
from pathlib import Path
from PIL import Image
import fitz
from paper_side import load_paper_assets, select_for_prompt
from paper_sections import load_paper_body, clean_text, caption_fallback, section_by_reference, section_of_asset
SYSTEM = ('You are an expert academic poster designer. Given a paper and a design '
'instruction, produce the poster as a standalone HTML document: a <style> '
'block, then a fixed-size <div id="canvas"> containing optional decorative '
'<div class="deco"> panels followed by absolutely positioned <div class="block"> '
'elements (text-like or image-like) in reading order, each with explicit '
'left/top/width/height in px. Place a figure with <img src="ASSET_ID"/> using '
'exactly the asset ids offered; never invent ids.')
DEFAULT_INSTRUCTION = ('Create a clear academic poster with a strong title, a short motivation, '
'the main method, key quantitative results, and takeaways. Prioritize the most informative '
'figures. Use concise text, readable fonts, and generous spacing. Do not invent results.')
CANVASES = {'Landscape · 3:2': (2400,1600), 'Landscape · 16:9':(2400,1350),
'Portrait · 2:3':(1600,2400), 'Square':(2000,2000)}
def scrub(value):
return re.sub(r'<[^>]{0,80}>',lambda m:m.group(0).replace('<','‹').replace('>','›'),value or '')
def normalize_accent(value, default='#584287'):
"""Accept whatever the colour picker sends (#RGB, #RRGGBB, rgb()/rgba(), hsl()) and return #RRGGBB."""
v=str(value or '').strip()
m=re.fullmatch(r'#?([0-9a-fA-F]{6})',v)
if m: return '#'+m.group(1).lower()
m=re.fullmatch(r'#?([0-9a-fA-F]{3})',v)
if m: return '#'+''.join(c*2 for c in m.group(1)).lower()
m=re.fullmatch(r'rgba?\(([^)]*)\)',v,re.I)
if m:
try:
parts=[p.strip() for p in m.group(1).replace('/',',').split(',')][:3]
rgb=[max(0,min(255,round(float(p[:-1])*2.55 if p.endswith('%') else float(p)))) for p in parts]
if len(rgb)==3: return '#%02x%02x%02x'%tuple(rgb)
except ValueError: pass
m=re.fullmatch(r'hsla?\(([^)]*)\)',v,re.I)
if m:
try:
import colorsys
parts=[p.strip().rstrip('%') for p in m.group(1).replace('/',',').split(',')][:3]
h,sat,l=float(parts[0])/360.0,float(parts[1])/100.0,float(parts[2])/100.0
r,g,b=colorsys.hls_to_rgb(h,l,sat); return '#%02x%02x%02x'%(round(r*255),round(g*255),round(b*255))
except (ValueError,IndexError): pass
return default
def prepare(pdf, job, instruction, canvas, accent, columns, custom_spec=''):
from layout_runtime import extract
pdf=Path(pdf);job=Path(job)
if pdf.stat().st_size>40*1024**2:
raise ValueError('Please upload a PDF smaller than 40 MB.')
with pdf.open('rb') as f:
if b'%PDF-' not in f.read(1024):
raise ValueError('The uploaded file is not a PDF.')
if len(instruction)>4000 or len(custom_spec)>3000:
raise ValueError('Please shorten your design instruction / layout specification.')
accent=normalize_accent(accent)
columns=max(1,min(4,int(columns)))
m=re.match(r'\s*(\d{3,5})\s*[xX×]\s*(\d{3,5})\s*$',str(canvas or '')) # exact size such as 2500x1666, or a preset name
width,height=(int(m.group(1)),int(m.group(2))) if m else CANVASES.get(canvas,(2400,1600))
extracted=job/'paper'
extract(pdf, extracted)
body,stats,sections=load_paper_body('paper',str(job),str(pdf),abstract_missing=True,total=22000)
registry=load_paper_assets('paper',str(job),str(pdf))
if not body or len(body)<100:
raise ValueError('No usable paper text was found. Scanned PDFs are not supported in this demo.')
chosen=select_for_prompt(registry['assets'],[],16,{'figure':10,'table':5,'formula':3})
with fitz.open(pdf) as doc:
first=doc[0].get_text('dict')
lines=[[s for s in l['spans'] if s['text'].strip() and not re.search(r'arxiv:|preprint|under review',s['text'],re.I)]
for b in first['blocks'] if 'lines' in b for l in b['lines'] if abs(l.get('dir',(1,0))[1])<.1]
lines=[l for l in lines if l]
largest=max((s['size'] for l in lines for s in l),default=0)
# whole lines whose biggest span is title-sized: keeps small-caps titles (ICLR) intact instead of 'B F G L'
def join_line(l): # small-caps titles come as 'B'+'AYESIAN' spans: no space when the spans touch
out=''
for prev,s in zip([None]+l[:-1],l):
gap=s['bbox'][0]-prev['bbox'][2] if prev else 0
out+=('' if prev and gap<s['size']*.15 and not out.endswith(' ') and not s['text'].startswith(' ') else ' ')+s['text']
return out
title=clean_text(' '.join(join_line(l) for l in lines if max(s['size'] for s in l)>=largest*.85
and l[0]['bbox'][1]<doc[0].rect.height*.45))[:400]
meta_title=clean_text(doc.metadata.get('title',''))
if meta_title and len(meta_title)>8 and (len(title)<8 or len(title.split())>3 and sum(len(w) for w in title.split())/len(title.split())<2.5):
title=meta_title[:400]
# authors: the first-page lines between the title block and the abstract (PDF metadata is usually empty,
# and without an authors line the model may invent names)
title_lines=[l for l in lines if max(s['size'] for s in l)>=largest*.85 and l[0]['bbox'][1]<doc[0].rect.height*.45]
title_bottom=max((s['bbox'][3] for l in title_lines for s in l),default=0)
author_lines=[]
for l in sorted((l for l in lines if l[0]['bbox'][1]>=title_bottom-1 and l not in title_lines),key=lambda l:(round(l[0]['bbox'][1]),l[0]['bbox'][0])):
txt=clean_text(join_line(l))
if re.match(r'^\s*(abstract|a\s*b\s*s\s*t\s*r\s*a\s*c\s*t)\b',txt,re.I) or l[0]['bbox'][1]>doc[0].rect.height*.5: break
if re.search(r'published as a conference|under review|preprint|anonymous author|proceedings of|copyright|workshop',txt,re.I): continue
author_lines.append(txt)
authors=' '.join(author_lines)
authors=re.sub(r'\{[^}]*\}?|\S*@\S+','',authors) # e-mail lists
authors=re.sub(r'\s+([\u0300-\u036f])(\w)',r'\2\1',authors) # 'Balunovi ́c' -> 'Balunović' (mark belongs to the next letter)
authors=clean_text(unicodedata.normalize('NFC',authors))[:500] or clean_text(doc.metadata.get('author',''))[:500]
asset_lines=[];assets=[]
for a in chosen:
cap=clean_text(a.get('caption',''))
if not cap and a['kind'] in ('figure','table'):
cap=caption_fallback(doc,a['page'],a['bbox'],a['kind'],72/200)
sec=section_by_reference(sections,cap,a['kind'])
if sec is None:
pw=doc[a['page']-1].rect.width*200/72
sec=section_of_asset(sections,a['page'],a['bbox'],pw,pw*.9)
if sec is not None:a['section_role']=sec['role']
context=f", page {a['page']}"+(f', in section "{scrub(sec["heading"][:70])}" [{sec["role"]}]' if sec else '')
budget={'figure':448**2,'table':672**2,'formula':336**2}[a['kind']]
with Image.open(a['file']) as original:
im=original.convert('RGB');w,h=im.size
scale=min(1,(budget/(w*h))**.5)
tw,th=max(28,int(w*scale)//28*28),max(28,int(h*scale)//28*28)
thumb=job/'thumbs'/f'{a["id"]}.jpg';thumb.parent.mkdir(exist_ok=True)
im.resize((tw,th),Image.Resampling.LANCZOS).save(thumb,quality=88)
a['thumb']=str(thumb);a['caption']=cap
asset_lines.append(f' {a["id"]} ({a["kind"]}, aspect {tw/th:.2f}{context})'+(f' - {scrub(cap)}' if cap else ''))
assets.append(a)
instruction=f'Canvas {width}x{height}px, {columns} columns. '+(instruction.strip() or DEFAULT_INSTRUCTION)
fids=[a['id'] for a in assets if a['kind']=='figure']
figure_slots=' -> '.join(fids[:5]) or 'text-based key findings'
spec=(custom_spec.strip() or
f'Canvas {width}x{height} {"landscape" if width>=height else "portrait"}, {columns} columns, moderate density. '
f'Header band (14% tall): title {round(width*.028)}px white on {accent} band, authors line if provided, no invented logos. '
f'Reading order: Motivation and Contributions -> Method -> Results -> Conclusion. '
f'Available key figures: {figure_slots}. Keep each figure aspect ratio and give it a short caption. '
f'Style: white background, Arial, body {round(width*.012)}px, section headings {round(width*.017)}px {accent}, '
'24px gutters, balanced column heights, subtle light panels, no clipping or overlapping text.')
return {'title':title,'authors':authors,'body':body,'sections':sections,'assets':assets,
'asset_lines':'\n'.join(asset_lines) or ' (none)','instruction':instruction,'spec':spec,
'canvas':[width,height],'stats':stats,'job':str(job)}
def chat(prepared, body=None):
p=prepared
if p.get('messages_pre'): # benchmark case: the stored prompt, verbatim
return p['messages_pre']
paper=f'title: {scrub(p["title"])}\n'+(f'authors: {scrub(p["authors"])}\n' if p['authors'] else '')
paper+='body:\n'+scrub(p['body'] if body is None else body)
content=[{'type':'text','text':f'<paper>\n{paper}\n</paper>\n\n<assets>\n{p["asset_lines"]}\n</assets>\n'}]
content.extend({'type':'image','image':a['thumb']} for a in p['assets'])
content.append({'type':'text','text':f'\n\n<instruction>\n{scrub(p["instruction"])}\n</instruction>\n\n<layout_spec>\n{scrub(p["spec"])}\n</layout_spec>'})
return [{'role':'system','content':[{'type':'text','text':SYSTEM}]},{'role':'user','content':content}]
|