99 lines
5.6 KiB
Python
99 lines
5.6 KiB
Python
from pathlib import Path
|
|
from io import BytesIO
|
|
from functools import lru_cache
|
|
from xml.etree import ElementTree as ET
|
|
import json,base64,html,re,zipfile,hashlib
|
|
from PIL import Image,ImageDraw,ImageFont
|
|
from reportlab.pdfgen import canvas
|
|
from reportlab.lib.utils import ImageReader
|
|
|
|
ROOT=Path(__file__).resolve().parent
|
|
BOOK=json.loads((ROOT/'book.json').read_text())
|
|
FONT='/System/Library/AssetsV2/com_apple_MobileAsset_Font7/8dc7805506cc9f233dcc19aabf593196842a47ae.asset/AssetData/Hannotate.ttc'
|
|
SCALE=2
|
|
@lru_cache(None)
|
|
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|Sonnet 5\.5|Gemini 4\.1|Sol 6\.1|3\.1 Pro|3\.8 Flash|\d+(?:\.\d+)?%|第[一二三四五六七八九十百千万0-9]+|[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: '+text)
|
|
return dp[0][1]
|
|
|
|
layout=[]
|
|
def compose(p):
|
|
n=p['number'];artpath=ROOT/'art'/f'page-{n:02d}.png'
|
|
source=Image.open(artpath).convert('RGB')
|
|
im=source.resize((2048,3072),Image.Resampling.LANCZOS);draw=ImageDraw.Draw(im)
|
|
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,{base64.b64encode(artpath.read_bytes()).decode()}"/>']
|
|
for k,panel in enumerate(p['panels']):
|
|
if not panel['dialogue']:continue
|
|
x,y,w,h=panel['text_box'];fit=None
|
|
for size in [30,29,28,27,26,25,24]:
|
|
step=round(size*1.2);gap=10
|
|
groups=[(speaker,wrap(text,size,w,draw)) for speaker,text in panel['dialogue']]
|
|
used=sum(len(lines)*step for _,lines in groups)+(len(groups)-1)*gap
|
|
if used<=h:fit=(size,step,groups,used);break
|
|
if fit is None:raise ValueError(f'Page {n} panel {k+1}: text needs more space')
|
|
size,step,groups,used=fit
|
|
start=y
|
|
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+=step
|
|
y+=gap
|
|
layout.append({'page':n,'panel':k+1,'size':size,'lines':sum(len(ls) for _,ls in groups),'text_bottom':start+used,'box_bottom':start+h})
|
|
svg.append('</svg>')
|
|
out=ROOT/'pages'/f'page-{n:02d}.png';im.save(out)
|
|
svgpath=ROOT/'editable'/f'page-{n:02d}.svg';svgpath.write_text('\n'.join(svg))
|
|
texts=[e.text or '' for e in ET.parse(svgpath).findall('{http://www.w3.org/2000/svg}text')]
|
|
expected=''.join(t for panel in p['panels'] for _,t in panel['dialogue'])
|
|
assert re.sub(r'\s+','',expected)==re.sub(r'\s+','',''.join(texts))
|
|
assert not any(t[0] in ',。!?;:、)》”’…' for t in texts)
|
|
return out
|
|
|
|
if __name__=='__main__':
|
|
import sys
|
|
requested=[int(x) for x in sys.argv[1:]]
|
|
pages=[p for p in BOOK['pages'] if not requested or p['number'] in requested]
|
|
paths=[compose(p) for p in pages]
|
|
(ROOT/'tmp/layout.json').write_text(json.dumps(layout,ensure_ascii=False,indent=2))
|
|
if not requested:
|
|
out=ROOT/'output/pdf';out.mkdir(parents=True,exist_ok=True)
|
|
pdf=out/'Argon讨论-第二组.pdf'
|
|
c=canvas.Canvas(str(pdf),pagesize=(512,768));c.setTitle(BOOK['title']);c.setAuthor('Codex')
|
|
for path in paths:
|
|
b=BytesIO();Image.open(path).convert('RGB').save(b,format='JPEG',quality=96,subsampling=0);b.seek(0)
|
|
c.drawImage(ImageReader(b),0,0,width=512,height=768);c.showPage()
|
|
c.save()
|
|
sheet=Image.new('RGB',(1840,1410),'#e9e6ed')
|
|
for i,p in enumerate(paths):
|
|
im=Image.open(p);im.thumbnail((440,660));sheet.paste(im,((i%4)*465,(i//4)*710))
|
|
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/art-sha256.json').write_text(json.dumps({p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in (ROOT/'art').glob('*.png')},indent=2))
|
|
print(json.dumps({'rendered':[p['number'] for p in pages],'layout':layout},ensure_ascii=False))
|