Skip to content

Commit d1776a0

Browse files
feat: export a molecule read as a branded flavor-card PNG
Add /api/card — a self-contained 1200x630 (social/OG-sized) 'flavor card' PNG for a molecule: emblem + wordmark header, the 2D structure (RDKit draws it), name/IUPAC/SMILES, the 'reads as' pills (flavors/tastes/ aromas), the taste-model bars, and the shareable URL in the footer. RDKit renders the structure (MolDraw2DCairo) and Pillow composes — no new runtime deps. An 'Export' button in the modal downloads it. Verified with Playwright: the card renders for vanillin and Export downloads flavormancer-Vanillin.png. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent a02d24e commit d1776a0

2 files changed

Lines changed: 129 additions & 1 deletion

File tree

training/app.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,128 @@ def _static(fname: str):
324324
raise HTTPException(status_code=404)
325325

326326

327+
_TASTE_RGB = {"sweet": (232, 169, 74), "bitter": (168, 138, 224), "umami": (224, 128, 94),
328+
"sour": (191, 210, 78), "salty": (99, 166, 224), "tasteless": (184, 192, 198)}
329+
330+
331+
@app.get("/api/card")
332+
def api_card(q: str = "", dl: int = 0):
333+
"""A branded, shareable 'flavor card' PNG for a molecule — structure + the read + the
334+
share URL. Self-contained (RDKit draws the structure, Pillow composes)."""
335+
from fastapi import HTTPException
336+
from fastapi.responses import Response
337+
smi = _resolve(q)
338+
mol = Chem.MolFromSmiles(smi) if smi else None
339+
if mol is None:
340+
raise HTTPException(status_code=404)
341+
out = P.predict(smi, include_aroma=False)
342+
tags = _read_tags(smi, out)
343+
common, iupac = _names(smi)
344+
name = common or (q[:1].upper() + q[1:] if q else smi)
345+
346+
import io
347+
from PIL import Image, ImageDraw, ImageFont
348+
from rdkit.Chem.Draw import rdMolDraw2D
349+
350+
def font(path, size):
351+
try:
352+
return ImageFont.truetype(path, size)
353+
except Exception: # noqa: BLE001
354+
return ImageFont.load_default()
355+
DJ = "/usr/share/fonts/truetype/dejavu/"
356+
f_title = font("static/headerfont.ttf", 46)
357+
f_tag = font("static/wordmark.ttf", 17)
358+
f_name = font(DJ + "DejaVuSans-Bold.ttf", 34)
359+
f_body = font(DJ + "DejaVuSans.ttf", 17)
360+
f_mono = font(DJ + "DejaVuSansMono.ttf", 15)
361+
f_pill = font(DJ + "DejaVuSans-Bold.ttf", 16)
362+
f_lab = font(DJ + "DejaVuSans-Bold.ttf", 15)
363+
364+
W, H = 1200, 630
365+
ink, muted, cream, teal = (231, 237, 234), (148, 162, 169), (217, 171, 116), (43, 196, 196)
366+
img = Image.new("RGB", (W, H), (15, 19, 25))
367+
dr = ImageDraw.Draw(img)
368+
# corner aura
369+
for cx, cy, col in [(0, 0, (138, 107, 224)), (W, H, (43, 196, 196))]:
370+
glow = Image.new("RGB", (W, H), (15, 19, 25))
371+
gd = ImageDraw.Draw(glow)
372+
gd.ellipse([cx - 380, cy - 320, cx + 380, cy + 320], fill=col)
373+
img = Image.blend(img, glow, 0.06)
374+
dr = ImageDraw.Draw(img)
375+
376+
# header
377+
try:
378+
emblem = Image.open("static/logo.png").convert("RGBA").resize((58, 58))
379+
img.paste(emblem, (40, 30), emblem)
380+
except Exception: # noqa: BLE001
381+
pass
382+
dr.text((110, 30), "Flavormancer", font=f_title, fill=ink)
383+
dr.text((112, 82), "taste & aroma from chemical structure", font=f_tag, fill=cream)
384+
dr.line([40, 122, W - 40, 122], fill=(42, 50, 60), width=1)
385+
386+
# structure panel (white)
387+
dr.rounded_rectangle([40, 150, 470, 520], radius=14, fill=(245, 247, 245))
388+
d2 = rdMolDraw2D.MolDraw2DCairo(410, 350)
389+
d2.drawOptions().padding = 0.12
390+
d2.DrawMolecule(mol)
391+
d2.FinishDrawing()
392+
struct = Image.open(io.BytesIO(d2.GetDrawingText())).convert("RGBA")
393+
img.paste(struct, (50, 160), struct)
394+
395+
# right column
396+
x = 508
397+
dr.text((x, 158), name[:34], font=f_name, fill=ink)
398+
if iupac and iupac.lower() != name.lower():
399+
dr.text((x, 206), ("IUPAC " + iupac)[:64], font=f_body, fill=muted)
400+
dr.text((x, 232), smi[:58], font=f_mono, fill=muted)
401+
402+
# "reads as" pills
403+
dr.text((x, 280), "READS AS", font=f_lab, fill=muted)
404+
px, py = x, 306
405+
def pill(px, py, text, fg, border):
406+
w = dr.textlength(text, font=f_pill)
407+
if px + w + 22 > W - 40:
408+
px, py = x, py + 40
409+
dr.rounded_rectangle([px, py, px + w + 22, py + 30], radius=15, outline=border, width=2)
410+
dr.text((px + 11, py + 6), text, font=f_pill, fill=fg)
411+
return px + w + 30, py
412+
for fl in tags.get("flavors", [])[:4]:
413+
px, py = pill(px, py, fl, cream, cream)
414+
for t in tags.get("tastes", [])[:4]:
415+
c = _TASTE_RGB.get(t, teal)
416+
px, py = pill(px, py, t, c, c)
417+
for a in tags.get("aromas", [])[:5]:
418+
px, py = pill(px, py, a, teal, teal)
419+
420+
# taste-probability bars (the numeric heads)
421+
by = py + 58
422+
dr.text((x, by - 26), "TASTE MODEL", font=f_lab, fill=muted)
423+
bars = [(t, out.get(t)) for t in ("sweet", "bitter", "umami", "tasteless") if isinstance(out.get(t), (int, float))]
424+
bars.sort(key=lambda kv: -kv[1])
425+
for t, v in bars[:4]:
426+
c = _TASTE_RGB.get(t, teal)
427+
pct = int(round(v * 100))
428+
dr.text((x, by), t, font=f_body, fill=ink)
429+
dr.rounded_rectangle([x + 120, by + 4, x + 120 + 320, by + 16], radius=6, fill=(36, 44, 53))
430+
dr.rounded_rectangle([x + 120, by + 4, x + 120 + int(320 * v), by + 16], radius=6, fill=c)
431+
dr.text((x + 452, by), f"{pct}%", font=f_body, fill=muted)
432+
by += 30
433+
434+
# footer
435+
dr.line([40, 576, W - 40, 576], fill=(42, 50, 60), width=1)
436+
share = "flavormancer.echelonts.net/?q=" + (common or q or smi)
437+
dr.text((40, 590), share, font=f_mono, fill=teal)
438+
tw = dr.textlength("before you pour", font=f_tag)
439+
dr.text((W - 40 - tw, 588), "before you pour", font=f_tag, fill=cream)
440+
441+
buf = io.BytesIO()
442+
img.save(buf, "PNG")
443+
data = buf.getvalue()
444+
fn = "".join(ch for ch in (common or "molecule") if ch.isalnum() or ch in "-_") or "molecule"
445+
headers = {"Content-Disposition": f'attachment; filename="flavormancer-{fn}.png"'} if dl else {}
446+
return Response(content=data, media_type="image/png", headers=headers)
447+
448+
327449
class MixtureQuery(BaseModel):
328450
ingredients: list[str]
329451
processes: list[str] = []

training/workbench.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,8 @@
519519
.mol-share{position:absolute;top:19px;right:64px;z-index:4;font:inherit;font-size:11px;font-weight:600;
520520
padding:5px 11px;border-radius:16px;border:1px solid var(--line);background:var(--panel);color:var(--muted);cursor:pointer}
521521
.mol-share:hover{border-color:var(--brand-2);color:var(--ink)}
522-
@media(max-width:640px){.mol-share{right:60px;top:20px;padding:4px 9px;font-size:10px}}
522+
.mol-export{right:150px}
523+
@media(max-width:640px){.mol-share{right:56px;top:20px;padding:4px 9px;font-size:10px}.mol-export{right:132px}}
523524
.mol-loader{display:flex;flex-direction:column;align-items:center;gap:6px;padding:34px 16px 40px}
524525
.loader-flask{width:150px;height:150px}
525526
.loader-ring{transform-origin:70px 70px;animation:ringspin 1.15s linear infinite}
@@ -762,6 +763,7 @@ <h1>Flavormancer</h1>
762763
<div class="mol-modal-inner">
763764
<div class="scroll-rod scroll-rod-top" aria-hidden="true"></div>
764765
<div class="scroll-rod scroll-rod-bottom" aria-hidden="true"></div>
766+
<button type="button" id="molExport" class="mol-share mol-export" title="Download a shareable flavor card (PNG)">Export</button>
765767
<button type="button" id="molShare" class="mol-share" title="Copy a shareable link to this read">Copy link</button>
766768
<button type="button" id="molClose" class="mol-close" aria-label="Close"></button>
767769
<div id="molLoader" class="mol-loader">
@@ -1046,6 +1048,10 @@ <h4>Software &amp; type</h4>
10461048
if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(url).then(done,done); }
10471049
else { const ta=document.createElement('textarea'); ta.value=url; document.body.appendChild(ta); ta.select(); try{document.execCommand('copy');}catch(_){}; ta.remove(); done(); }
10481050
});
1051+
$('molExport').addEventListener('click', ()=>{
1052+
const a=document.createElement('a'); a.href='/api/card?q='+encodeURIComponent(_shareQ||q.value.trim())+'&dl=1';
1053+
a.download=''; document.body.appendChild(a); a.click(); a.remove();
1054+
});
10491055

10501056
async function run(){
10511057
const text = q.value.trim();

0 commit comments

Comments
 (0)