Files
argon-comic/compose_v2.py
2026-11-05 01:04:53 +08:00

109 lines
5.4 KiB
Python

from pathlib import Path
from io import BytesIO
import json, base64, html, re, hashlib
from PIL import Image, ImageDraw, ImageFont
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
ROOT=Path(__file__).resolve().parent
OUT=ROOT/'v2'
BOOK=json.loads((ROOT/'book-v2.json').read_text())
FONT='/System/Library/AssetsV2/com_apple_MobileAsset_Font7/8dc7805506cc9f233dcc19aabf593196842a47ae.asset/AssetData/Hannotate.ttc'
SCALE=2
W,H=1024,1536
for folder in ['pages','editable','qa']:(OUT/folder).mkdir(parents=True,exist_ok=True)
def font(size):return ImageFont.truetype(FONT,size*SCALE)
def wrap(text,size,width,draw):
tokens=re.findall(r'GPT-6 Astra|Opus 5\.5|Gemini 4\.1|3\.1 Pro|[A-Za-z0-9]+(?:[.%-][A-Za-z0-9]+)*|\s+|.',text)
closing=',。!?;:、)》”’…'
opening='(《“‘'
n=len(tokens);maxw=width*SCALE;face=font(size)
dp={n:(0,[])}
for i in range(n-1,-1,-1):
if tokens[i].isspace():
if i+1 in dp:dp[i]=dp[i+1]
continue
if tokens[i][0] in closing:continue
segment='';best=None
for j in range(i,n):
segment+=tokens[j];line=segment.strip()
measured=draw.textlength(line,font=face)
if measured>maxw:break
if not line or line[-1] in opening:continue
nxt=j+1
while nxt<n and tokens[nxt].isspace():nxt+=1
if nxt<n and tokens[nxt][0] in closing:continue
if nxt not in dp:continue
cost=1000000+100*((maxw-measured)/maxw)**2+dp[nxt][0]
candidate=(cost,[line]+dp[nxt][1])
if best is None or cost<best[0]:best=candidate
if best is not None:dp[i]=best
if 0 not in dp:raise ValueError('Cannot wrap dialogue')
return dp[0][1]
layout=[]
def compose(page):
n=page['number'];artnum=page['art']
artpath=ROOT/'art'/f'page-{artnum:02d}-final.png'
art=Image.open(artpath).convert('RGB').resize((W*SCALE,H*SCALE),Image.Resampling.LANCZOS)
draw=ImageDraw.Draw(art)
data=base64.b64encode(artpath.read_bytes()).decode()
svg=[f'<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1536" viewBox="0 0 1024 1536"><image width="1024" height="1536" href="data:image/png;base64,{data}"/>']
for k,utterances in enumerate(page['panels']):
col,row=k%2,k//2
x=col*512+34
top=18 if (artnum==2 and k>=2) or (artnum==6 and k==2) else 24
bottom=page['bottoms'][k]
fit=None
for size in [27,26,25,24,23]:
leading=round(size*1.2)
gap=9
groups=[(speaker,wrap(words,size,438,draw)) for speaker,words in utterances]
height=sum(len(lines)*leading for speaker,lines in groups)+(len(groups)-1)*gap
if top+height<=bottom:
fit=(size,leading,gap,groups,height);break
if fit is None:
raise ValueError(f'Page {n}, panel {k+1}: rewrite required, available={bottom-top}px')
size,leading,gap,groups,height=fit
y=row*768+top
for speaker,lines in groups:
color=BOOK['characters'][speaker]['color']
for line in lines:
draw.text((x*SCALE,y*SCALE),line,font=font(size),fill=color,anchor='lt')
svg.append(f'<text x="{x}" y="{y}" dominant-baseline="text-before-edge" font-family="Hannotate SC,sans-serif" font-size="{size}" fill="{color}">{html.escape(line)}</text>')
y+=leading
y+=gap
layout.append({'page':n,'art':artnum,'panel':k+1,'font':size,'lines':sum(len(ls) for _,ls in groups),'bottom':top+height,'limit':bottom})
svg.append('</svg>')
path=OUT/'pages'/f'page-{n:02d}.png'
art.save(path)
(OUT/'editable'/f'page-{n:02d}.svg').write_text('\n'.join(svg))
return path
paths=[compose(p) for p in BOOK['pages']]
pdfpath=OUT/'最强模型茶话会-对白重写版.pdf'
c=canvas.Canvas(str(pdfpath),pagesize=(512,768))
c.setTitle(BOOK['title']+' · 对白重写版');c.setAuthor('Codex')
for path in paths:
buf=BytesIO()
Image.open(path).convert('RGB').save(buf,format='JPEG',quality=96,subsampling=0)
buf.seek(0)
c.drawImage(ImageReader(buf),0,0,width=512,height=768);c.showPage()
c.save()
(OUT/'qa/layout.json').write_text(json.dumps(layout,ensure_ascii=False,indent=2))
# No headings, names, page numbers, explanatory notes, or legends in the reader.
reader='''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>漫画</title><style>html,body{margin:0;background:#e9e6ed}main{max-width:1000px;margin:auto}img{width:100%;height:auto;display:block;margin:0 0 20px}@media print{body{background:white}img{margin:0;break-after:page}}</style><main>'''
reader+=''.join(f'<img src="pages/page-{i:02d}.png" alt="">' for i in range(1,8))+'</main></html>'
(OUT/'index.html').write_text(reader)
sheet=Image.new('RGB',(1400,2110),'#E9E6ED')
for i,p in enumerate(paths):
im=Image.open(p);im.thumbnail((450,675))
sheet.paste(im,((i%3)*475,(i//3)*710))
sheet.save(OUT/'overview.jpg',quality=94)
old=json.loads((OUT/'art-sha256-before.json').read_text())
now={p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in (ROOT/'art').glob('*-final.png')}
assert old==now,'Original art changed'
print(json.dumps({'pages':len(paths),'art_unchanged':True,'font_min':min(x['font'] for x in layout),'font_max':max(x['font'] for x in layout),'layout':layout,'pdf_mb':round(pdfpath.stat().st_size/1e6,2)},ensure_ascii=False))