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