Files
2026-10-26 02:21:57 +01:00

120 lines
5.7 KiB
Python

import io,unicodedata,urllib.request
from pathlib import Path
import discord
from discord.ext import commands
from PIL import Image,ImageDraw,ImageFilter,ImageFont,ImageOps
from pilmoji import Pilmoji
from core.threads import run_blocking
W,H,AV,PAD,MAXC=1200,480,268,42,420
BG0,BG1,CARD,STROKE,ACC,TXT,SUB,MARK=(7,9,17),(15,20,36),(18,24,44),(46,64,108),(82,116,214),(236,242,255),(128,150,192),(52,80,160)
FONT=Path(__file__).resolve().parents[1]/"font"/"NotoSans-Regular.ttf"
def norm(t):return unicodedata.normalize("NFKC",t)
def font(n):
try:return ImageFont.truetype(str(FONT),n)
except Exception:return ImageFont.load_default()
def fetch(url):
with urllib.request.urlopen(urllib.request.Request(url,headers={"User-Agent":"Mozilla/5.0"}),timeout=20) as r:return r.read()
def names(t,users):
for u in {u.id:u for u in users}.values():
n="@"+(getattr(u,"display_name",None) or u.name);t=t.replace(f"<@{u.id}>",n).replace(f"<@!{u.id}>",n)
return t
def gradient():
im=Image.new("RGB",(W,H));d=ImageDraw.Draw(im)
for y in range(H):
t=y/(H-1);d.line((0,y,W,y),fill=tuple(int(a+(b-a)*t) for a,b in zip(BG0,BG1)))
return im.convert("RGBA")
def glow(im):
o=Image.new("RGBA",im.size,(0,0,0,0));d=ImageDraw.Draw(o);cx,cy,m=int(W*.74),int(H*.2),int(H*.7)
for i in range(18):
r=int(m*(1-i/18));d.ellipse((cx-r,cy-r,cx+r,cy+r),fill=(100,130,215,int(52*(1-i/18))))
o=o.filter(ImageFilter.GaussianBlur(12));im.paste(o,(0,0),o)
def tw(d,t,f):
l,_,r,_=d.textbbox((0,0),t,font=f);return r-l
def chop(d,w,f,mw):
out=[];cur=""
for c in w:
if cur and tw(d,cur+c,f)>mw:out.append(cur);cur=c
else:cur+=c
return out+([cur] if cur else [])
def wrap(d,t,f,mw):
lines=[]
for p in t.splitlines() or [t]:
ws=p.split()
if not ws:
if lines:lines.append("")
continue
cur=""
for w in ws:
for part in (chop(d,w,f,mw) if tw(d,w,f)>mw else [w]):
x=part if not cur else cur+" "+part
if tw(d,x,f)<=mw:cur=x
else:
if cur:lines.append(cur)
cur=part
if cur:lines.append(cur)
return lines
def lh(d,f):
_,t,_,b=d.textbbox((0,0),"Ag",font=f);h=b-t;return max(h+6,int(h*1.26))
def fit(d,t,mw,mh):
last=(["..."],font(15),lh(d,font(15)))
for s in range(110,14,-2):
f=font(s);l=wrap(d,t,f,mw);h=lh(d,f)
if len(l)*h<=mh:return l or ["..."],f,h
last=(l,f,h)
l,f,h=last;return (l or ["..."])[:max(1,mh//max(1,h))],f,h
def avatar(b):
a=ImageOps.fit(Image.open(io.BytesIO(b)).convert("RGB"),(AV,AV));m=Image.new("L",(AV,AV),0)
ImageDraw.Draw(m).ellipse((0,0,AV,AV),fill=255);r=Image.new("RGBA",(AV,AV),(0,0,0,0));r.paste(a,(0,0),m);return r
def build(ab,text,sig):
im=gradient();glow(im);d=ImageDraw.Draw(im)
ax,ay=PAD,(H-AV)//2;cl,ct,cr,cb=ax+AV+18,24,W-24,H-24
sh=Image.new("RGBA",im.size,(0,0,0,0));ImageDraw.Draw(sh).rounded_rectangle((cl+7,ct+10,cr+7,cb+10),radius=28,fill=(0,0,0,105))
sh=sh.filter(ImageFilter.GaussianBlur(10));im.paste(sh,(0,0),sh)
d.rounded_rectangle((cl,ct,cr,cb),radius=28,fill=CARD,outline=STROKE,width=2)
gd=AV+28;g=Image.new("RGBA",(gd,gd),(0,0,0,0));gg=ImageDraw.Draw(g);c=gd//2
for i in range(10):
r=c-i*4
if r<=0:break
gg.ellipse((c-r,c-r,c+r,c+r),fill=(*ACC,int(55*(1-i/10))))
g=g.filter(ImageFilter.GaussianBlur(4));im.paste(g,(ax-14,ay-14),g)
rd=AV+8;ring=Image.new("RGBA",(rd,rd),(0,0,0,0));ImageDraw.Draw(ring).ellipse((0,0,rd-1,rd-1),outline=(*ACC,175),width=3);im.paste(ring,(ax-4,ay-4),ring)
a=avatar(ab);im.paste(a,(ax,ay),a)
qx,zt,tr=cl+52,ct+44,cr-32;sf=font(27);sig=f"\u2014 {sig}";_,_,_,sh_=d.textbbox((0,0),sig,font=sf)
sy=cb-sh_-30;dy=sy-15;zh=max(48,dy-zt-16)
lines,qf,h=fit(d,text,tr-qx,zh)
d.text((cl+10,ct+6),"\u201c",font=font(max(54,min(100,int(h*1.55)))),fill=MARK)
cx=(qx+tr)//2;y=zt+max(0,(zh-len(lines)*h)//2)
with Pilmoji(im) as p:
for l in lines:
p.text((cx-tw(d,l,qf)//2,y),l,font=qf,fill=TXT,emoji_scale_factor=1.08,emoji_position_offset=(0,-2));y+=h
d.rectangle((qx,dy,tr,dy+1),fill=STROKE)
p.text((qx,sy),sig,font=sf,fill=SUB,emoji_scale_factor=1.0,emoji_position_offset=(0,-1))
out=io.BytesIO();im.convert("RGB").save(out,format="PNG");out.seek(0);return out
class q(commands.Cog):
def __init__(s,bot):s.bot=bot
@commands.command(name="q")
async def q(s,ctx,*args):
m=ctx.message;target=m.author;text=""
ref=m.reference
if not args and ref and ref.message_id:
r=ref.resolved if isinstance(ref.resolved,discord.Message) else None
if r is None:
try:r=await m.channel.fetch_message(ref.message_id)
except Exception:r=None
if r:
text=names((r.content or "").strip(),r.mentions)
if text:target=r.author
if not text:
rest=list(args)
if rest and rest[0].startswith("<@") and m.mentions:
target=m.mentions[0];rest=[x for x in rest if x not in(f"<@{target.id}>",f"<@!{target.id}>")]
text=names(" ".join(rest).strip(),m.mentions)
if not text:return await ctx.send("usage: ;q text | ;q @user text | reply with ;q")
text=norm(text[:MAXC])
url=str(target.display_avatar.replace(size=512,format="png").url)
ab=await run_blocking(fetch,url)
sig=norm(f"{getattr(target,'global_name',None) or target.display_name} (@{target.name})")
img=await run_blocking(build,ab,text,sig)
await ctx.send(file=discord.File(img,filename="quote.png"))
async def setup(bot):await bot.add_cog(q(bot))