Skip to content

Commit ee706d8

Browse files
feat: export a molecule read as a branded flavor-card PNG (A)
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 the Export button downloads flavormancer-Vanillin.png. Second of the A features. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent a02d24e commit ee706d8

2 files changed

Lines changed: 121 additions & 1 deletion

File tree

training/app.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,120 @@ 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)); gd = ImageDraw.Draw(glow)
371+
gd.ellipse([cx - 380, cy - 320, cx + 380, cy + 320], fill=col)
372+
img = Image.blend(img, glow, 0.06); dr = ImageDraw.Draw(img)
373+
374+
# header
375+
try:
376+
emblem = Image.open("static/logo.png").convert("RGBA").resize((58, 58))
377+
img.paste(emblem, (40, 30), emblem)
378+
except Exception: # noqa: BLE001
379+
pass
380+
dr.text((110, 30), "Flavormancer", font=f_title, fill=ink)
381+
dr.text((112, 82), "taste & aroma from chemical structure", font=f_tag, fill=cream)
382+
dr.line([40, 122, W - 40, 122], fill=(42, 50, 60), width=1)
383+
384+
# structure panel (white)
385+
dr.rounded_rectangle([40, 150, 470, 520], radius=14, fill=(245, 247, 245))
386+
d2 = rdMolDraw2D.MolDraw2DCairo(410, 350); d2.drawOptions().padding = 0.12
387+
d2.DrawMolecule(mol); d2.FinishDrawing()
388+
struct = Image.open(io.BytesIO(d2.GetDrawingText())).convert("RGBA")
389+
img.paste(struct, (50, 160), struct)
390+
391+
# right column
392+
x = 508
393+
dr.text((x, 158), name[:34], font=f_name, fill=ink)
394+
if iupac and iupac.lower() != name.lower():
395+
dr.text((x, 206), ("IUPAC " + iupac)[:64], font=f_body, fill=muted)
396+
dr.text((x, 232), smi[:58], font=f_mono, fill=muted)
397+
398+
# "reads as" pills
399+
dr.text((x, 280), "READS AS", font=f_lab, fill=muted)
400+
px, py = x, 306
401+
def pill(px, py, text, fg, border):
402+
w = dr.textlength(text, font=f_pill)
403+
if px + w + 22 > W - 40:
404+
px, py = x, py + 40
405+
dr.rounded_rectangle([px, py, px + w + 22, py + 30], radius=15, outline=border, width=2)
406+
dr.text((px + 11, py + 6), text, font=f_pill, fill=fg)
407+
return px + w + 30, py
408+
for fl in tags.get("flavors", [])[:4]:
409+
px, py = pill(px, py, fl, cream, cream)
410+
for t in tags.get("tastes", [])[:4]:
411+
c = _TASTE_RGB.get(t, teal); px, py = pill(px, py, t, c, c)
412+
for a in tags.get("aromas", [])[:5]:
413+
px, py = pill(px, py, a, teal, teal)
414+
415+
# taste-probability bars (the numeric heads)
416+
by = py + 58
417+
dr.text((x, by - 26), "TASTE MODEL", font=f_lab, fill=muted)
418+
bars = [(t, out.get(t)) for t in ("sweet", "bitter", "umami", "tasteless") if isinstance(out.get(t), (int, float))]
419+
bars.sort(key=lambda kv: -kv[1])
420+
for t, v in bars[:4]:
421+
c = _TASTE_RGB.get(t, teal); pct = int(round(v * 100))
422+
dr.text((x, by), t, font=f_body, fill=ink)
423+
dr.rounded_rectangle([x + 120, by + 4, x + 120 + 320, by + 16], radius=6, fill=(36, 44, 53))
424+
dr.rounded_rectangle([x + 120, by + 4, x + 120 + int(320 * v), by + 16], radius=6, fill=c)
425+
dr.text((x + 452, by), f"{pct}%", font=f_body, fill=muted)
426+
by += 30
427+
428+
# footer
429+
dr.line([40, 576, W - 40, 576], fill=(42, 50, 60), width=1)
430+
share = "flavormancer.echelonts.net/?q=" + (common or q or smi)
431+
dr.text((40, 590), share, font=f_mono, fill=teal)
432+
tw = dr.textlength("before you pour", font=f_tag)
433+
dr.text((W - 40 - tw, 588), "before you pour", font=f_tag, fill=cream)
434+
435+
buf = io.BytesIO(); img.save(buf, "PNG"); data = buf.getvalue()
436+
fn = "".join(ch for ch in (common or "molecule") if ch.isalnum() or ch in "-_") or "molecule"
437+
headers = {"Content-Disposition": f'attachment; filename="flavormancer-{fn}.png"'} if dl else {}
438+
return Response(content=data, media_type="image/png", headers=headers)
439+
440+
327441
class MixtureQuery(BaseModel):
328442
ingredients: list[str]
329443
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)