@@ -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+
327449class MixtureQuery (BaseModel ):
328450 ingredients : list [str ]
329451 processes : list [str ] = []
0 commit comments