Files
argon-comic/set-02/compose_layout.py
T
2026-11-05 01:04:53 +08:00

113 lines
6.6 KiB
Python

from pathlib import Path
from io import BytesIO
from xml.etree import ElementTree as ET
import json,html,base64,re,hashlib
from PIL import Image,ImageDraw
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from compose import BOOK,ROOT,font,wrap,SCALE
# Each newly generated panel is an illustration asset. Typeset and assemble the
# assets into the comic page, rather than placing letters over its characters.
RECTS=json.loads((ROOT/'tmp/source-rects.json').read_text())
LAYOUT=[]
OVERLAY={
(1,3):(450,896,537,200),
(2,0):(40,24,944,120),(2,1):(450,530,530,200),
(4,0):(40,32,580,150),(4,1):(40,566,450,100),
(5,0):(36,28,520,74),(5,1):(614,32,376,170),(5,2):(400,555,580,150),
(6,0):(40,36,620,240),(6,1):(40,650,944,120),
(7,0):(40,36,590,250),(7,1):(36,616,314,200),(7,2):(410,620,570,200),
(8,3):(40,1120,650,210)
}
def compose(page):
n=page['number'];sourcepath=ROOT/'art'/f'page-{n:02d}.png'
source=Image.open(sourcepath).convert('RGB')
rects=RECTS[f'page-{n:02d}'];assert len(rects)==len(page['panels'])
rows=[]
for k,rect in enumerate(rects):
if not rows or abs(rows[-1][0][1][1]-rect[1])>10:rows.append([])
rows[-1].append((k,rect))
scratch=ImageDraw.Draw(Image.new('RGB',(10,10)))
rowdata=[]
for row in rows:
entries=[]
for k,rect in row:
x0,y0,x1,y1=rect;w=x1-x0;pad=24 if w>500 else 20
overlay=OVERLAY.get((n,k))
size=30 if w>800 or overlay else 28
if overlay:
tx,ty,tw,th=overlay
for size in [30,29,28,27,26,25,24]:
step=round(size*1.2)
groups=[(speaker,wrap(text,size,tw,scratch)) for speaker,text in page['panels'][k]['dialogue']]
used=sum(len(lines)*step for _,lines in groups)+max(0,len(groups)-1)*12
if used<=th:break
else:raise ValueError(f'Overlay {n}/{k+1} does not fit')
else:
step=round(size*1.2)
groups=[(speaker,wrap(text,size,w-pad*2,scratch)) for speaker,text in page['panels'][k]['dialogue']]
h=sum(len(lines)*step for _,lines in groups)+max(0,len(groups)-1)*12
textheight=h+36 if groups and not overlay else 0
entries.append((k,rect,pad,size,step,groups,textheight,overlay))
rowdata.append((entries,max(e[-2] for e in entries),max(e[1][3]-e[1][1] for e in entries)))
gap=18;margin=12
H=margin*2+sum(band+arth for _,band,arth in rowdata)+gap*(len(rowdata)-1)
pageimg=Image.new('RGB',(1024*SCALE,H*SCALE),'white');d=ImageDraw.Draw(pageimg)
data=base64.b64encode(sourcepath.read_bytes()).decode()
svg=[f'<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="{H}" viewBox="0 0 1024 {H}"><defs><image id="source" width="1024" height="1536" href="data:image/png;base64,{data}"/></defs><rect width="1024" height="{H}" fill="white"/>']
y=margin
for entries,band,arth in rowdata:
for k,rect,pad,size,step,groups,textheight,overlay in entries:
x0,y0,x1,y1=rect;w=x1-x0;h=y1-y0
# Pure page composition: the illustration pixels remain untouched.
art=source.crop(tuple(rect)).resize((w*SCALE,h*SCALE),Image.Resampling.LANCZOS)
desty=y+band
pageimg.paste(art,(x0*SCALE,desty*SCALE))
clipid=f'p{n}f{k}'
svg.append(f'<defs><clipPath id="{clipid}"><rect x="{x0}" y="{desty}" width="{w}" height="{h}"/></clipPath></defs><g clip-path="url(#{clipid})"><use href="#source" transform="translate(0 {desty-y0})"/></g>')
if overlay:
tx,oy,tw,th=overlay;ty=desty+oy-y0
else:tx=x0+pad;ty=y+14
for speaker,lines in groups:
color=BOOK['characters'][speaker]['color']
for line in lines:
d.text((tx*SCALE,ty*SCALE),line,font=font(size),fill=color,anchor='lt')
svg.append(f'<text x="{tx}" y="{ty}" dominant-baseline="text-before-edge" font-family="Hannotate SC,sans-serif" font-size="{size}" fill="{color}">{html.escape(line)}</text>')
ty+=step
ty+=12
if groups and not overlay:assert ty-12<=desty
LAYOUT.append({'page':n,'panel':k+1,'source_rect':rect,'art_rect':[x0,desty,w,h],'text_size':size if groups else None,'lines':sum(len(ls) for _,ls in groups),'page_height':H,'lettering':'inside' if overlay else 'above'})
y+=band+arth+gap
svg.append('</svg>')
path=ROOT/'pages'/f'page-{n:02d}.png';pageimg.save(path)
svgpath=ROOT/'editable'/f'page-{n:02d}.svg';svgpath.write_text('\n'.join(svg))
actual=''.join(e.text or '' for e in ET.parse(svgpath).findall('{http://www.w3.org/2000/svg}text'))
expected=''.join(text for p in page['panels'] for _,text in p['dialogue'])
assert re.sub(r'\s+','',actual)==re.sub(r'\s+','',expected)
return path
if __name__=='__main__':
import sys
nums=[int(x) for x in sys.argv[1:]]
pages=[p for p in BOOK['pages'] if not nums or p['number'] in nums]
paths=[compose(p) for p in pages]
if not nums:
out=ROOT/'output/pdf';out.mkdir(parents=True,exist_ok=True)
pdf=out/'Argon讨论-第二组.pdf'
c=canvas.Canvas(str(pdf));c.setTitle(BOOK['title']);c.setAuthor('Codex')
for path in paths:
im=Image.open(path).convert('RGB');w,h=im.size;b=BytesIO();im.save(b,format='JPEG',quality=96,subsampling=0);b.seek(0)
c.setPageSize((512,512*h/w));c.drawImage(ImageReader(b),0,0,width=512,height=512*h/w);c.showPage()
c.save()
sheet=Image.new('RGB',(1840,2100),'#e9e6ed')
for i,path in enumerate(paths):
im=Image.open(path);im.thumbnail((440,1010));sheet.paste(im,((i%4)*465,(i//4)*1050))
sheet.save(ROOT/'overview.jpg',quality=95)
reader='''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>漫画</title><style>body{margin:0;background:#e9e6ed}main{max-width:1000px;margin:auto}img{width:100%;height:auto;display:block;margin-bottom:24px}@media print{img{margin:0;break-after:page}}</style><main>'''
reader+=''.join(f'<img src="pages/page-{i:02d}.png" alt="">' for i in range(1,9))+'</main></html>'
(ROOT/'index.html').write_text(reader)
(ROOT/'tmp/final-layout.json').write_text(json.dumps(LAYOUT,ensure_ascii=False,indent=2))
print(json.dumps({'rendered':[p['number'] for p in pages],'page_heights':sorted(set(x['page_height'] for x in LAYOUT)),'font_sizes':sorted(set(x['text_size'] for x in LAYOUT if x['text_size']))},ensure_ascii=False))