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'] 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'{html.escape(line)}') 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('') 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='''漫画
''' reader+=''.join(f'' for i in range(1,8))+'
' (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))