"""Srijika — text-to-font studio for Indic scripts (public demo). Thin client for the private Srijika API. No model code or weights here: every render arrives as a PNG from the server. The demo key lives in a Space Secret (SRIJIKA_DEMO_KEY) and is never exposed to visitors. """ import io import os import time import gradio as gr import requests API = os.environ.get("SRIJIKA_API_URL", "https://loopdesk-ai--srijika-api-api.modal.run") KEY = os.environ.get("SRIJIKA_DEMO_KEY", "") HDRS = {"Authorization": f"Bearer {KEY}"} SCRIPTS = ["devanagari", "tamil", "bengali", "telugu", "kannada", "malayalam", "gujarati", "gurmukhi", "odia"] EXAMPLES = [ ["a heavy rounded poster font", "devanagari"], ["thin elegant headline serif", "devanagari"], ["playful comic lettering for kids", "bengali"], ["clean geometric UI font", "tamil"], ["warm rounded friendly font", "kannada"], ["sharp modern tech branding", "telugu"], ["traditional elegant bookish serif", "malayalam"], ["bold cinematic display font", "gujarati"], ["clean geometric sans", "gurmukhi"], ["warm rounded friendly font", "odia"], ["brush calligraphy with dramatic strokes", "devanagari"], ] CSS = """ .gradio-container {max-width: 1080px !important; margin: 0 auto;} #hero {text-align:center; padding: 26px 10px 6px;} #hero h1 {font-size: 2.5em; margin: 0; background: linear-gradient(90deg,#f59e0b,#ef4444,#a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent;} #hero p {color:#6b7280; margin-top:6px; font-size:1.05em;} .badge {display:inline-block; background:#111827; color:#e5e7eb; border-radius:999px; padding:3px 12px; margin:2px; font-size:.8em;} #donorcard {border:1px solid #e5e7eb; border-radius:14px; padding:14px; background:linear-gradient(180deg,#fffbeb,#ffffff);} #status {font-size:.95em;} footer {display:none !important;} """ HERO = """

Srijika · सृजिका

Describe a font in plain words → a diffusion model draws it, glyph by glyph, for Devanagari, Tamil, Bengali, Telugu, Kannada, Malayalam, Gujarati, Gurmukhi & Odia.

text → font glyph diffusion FontCLIP retrieval parametric axes draft = 30 glyphs · ~2 min
""" ABOUT = """ **How it works** — Your description is embedded by [Lipika-FontCLIP](https://huggingface.co/loopdesk-ai/lipika-fontclip) and matched against 1,700+ Indic font faces. The best match seeds a private glyph-diffusion model (Srijika) that redraws the alphabet in that style on a GPU. Drafts render 30 glyphs so you can iterate quickly; the full model draws complete Unicode coverage with conjuncts. **Parametric axes** — after generation, weight / counter / em-fill are geometric transforms applied server-side, so one generation yields a family. *Public demo: draft quality, rate-limited, shared queue. Fonts are derived from OFL-licensed donors.* """ def _get(path, **params): r = requests.get(f"{API}{path}", headers=HDRS, params=params, timeout=120) r.raise_for_status() return r def _png(resp): return resp.content if resp.headers.get( "content-type", "").startswith("image/") else None def api_ok(): try: return requests.get(f"{API}/v1/health", timeout=10).ok and bool(KEY) except Exception: return False # ---------------------------------------------------------------- search def do_search(q, script): if not q.strip(): return "Type a description first.", gr.update(visible=False) try: d = _get("/v1/search", q=q, script=script or "", k=6).json() except requests.HTTPError as e: return f"Search failed: {e.response.text[:200]}", gr.update(visible=False) rows, gallery = [], [] for r in d["results"]: rows.append(f"**{r['family']}** · score {r['score']:.3f}") if r.get("file"): try: png = _png(_get("/v1/donor-preview", file=r["file"], script=script or "devanagari")) if png: gallery.append((io.BytesIO(png).getvalue(), r["family"])) except Exception: pass import tempfile paths = [] for data, label in gallery: f = tempfile.NamedTemporaryFile(suffix=".png", delete=False) f.write(data) f.close() paths.append((f.name, label)) md = (f"**{d.get('n_candidates', len(d['results']))} candidate faces " f"searched** — top matches:\n\n" + "\n\n".join(rows)) return md, gr.update(value=paths, visible=bool(paths)) # -------------------------------------------------------------- generate def do_generate(text, script, progress=gr.Progress()): empty = gr.update(visible=False) if not text.strip(): yield "Type a description first.", None, None, empty, gr.update(visible=False) return progress(0.02, desc="Finding the closest real font…") try: r = requests.post(f"{API}/v1/generate", headers=HDRS, data={"text": text, "script": script}, timeout=300) r.raise_for_status() d = r.json() except requests.HTTPError as e: code = e.response.status_code msg = ("Rate limit reached — the shared demo allows a few " "generations per hour. Try again later." if code == 429 else f"Generate failed: {e.response.text[:300]}") yield msg, None, None, empty, gr.update(visible=False) return job, donor = d["job_id"], d["donor"] donor_md = (f"### 🎯 Donor matched: **{donor['family']}**\n" f"score {donor['score']:.3f} · " f"{d['n_candidates']} faces searched · " f"template `{d['params']['template']}`") yield (f"⏳ Drawing glyphs on GPU (draft, ~2 min)… job `{job}`", None, None, gr.update(value=donor_md, visible=True), gr.update(visible=False)) t0 = time.time() while time.time() - t0 < 420: progress(min(.05 + (time.time() - t0) / 140 * .9, .95), desc="Diffusing glyphs…") time.sleep(6) try: s = _get(f"/v1/jobs/{job}").json() except Exception: continue if s.get("status") == "done": c = s["critic"] cl = c.get("cluster") or {} n_g = s.get("stats", {}).get("replaced", "?") png = _png(_get(f"/v1/jobs/{job}/preview")) ttf = _get(f"/v1/jobs/{job}/font").content import tempfile pf = tempfile.NamedTemporaryFile(suffix=".png", delete=False) pf.write(png or b"") pf.close() tf = tempfile.NamedTemporaryFile( suffix=".ttf", delete=False, prefix=s["params"]["family"].replace(" ", "") + "-") tf.write(ttf) tf.close() acc = cl.get("acc") stat = (f"✅ **Done in {time.time()-t0:.0f}s** · " f"{n_g} glyphs drawn · " f"legible {c['legible_frac']:.2f}" + (f" · cluster acc {acc:.2f}" if acc is not None else "") + f"\n\njob `{job}`") yield (stat, pf.name, tf.name, gr.update(value=donor_md, visible=True), gr.update(visible=True)) return if s.get("status") == "failed": yield (f"❌ Job failed: {s.get('error','?')[:300]}", None, None, empty, gr.update(visible=False)) return yield ("⌛ Still running — press *Check again* in a minute.", None, None, gr.update(value=donor_md, visible=True), gr.update(visible=False)) def apply_axes(state_job, weight, counter, emfill): if not state_job: return None, None, "Generate a font first." try: png = _png(_get(f"/v1/jobs/{state_job}/preview", weight=weight, counter=counter, emfill=emfill)) ttf = _get(f"/v1/jobs/{state_job}/font", weight=weight, counter=counter, emfill=emfill) except requests.HTTPError as e: return None, None, f"Axis render failed: {e.response.text[:200]}" import tempfile pf = tempfile.NamedTemporaryFile(suffix=".png", delete=False) pf.write(png or b"") pf.close() disp = ttf.headers.get("content-disposition", "") name = disp.split("filename=")[-1].strip('"') or "srijika.ttf" tf = tempfile.NamedTemporaryFile(suffix=".ttf", delete=False, prefix=name.rsplit(".", 1)[0] + "-") tf.write(ttf.content) tf.close() rep = ttf.headers.get("x-srijika-axes", "") return pf.name, tf.name, f"Axes applied · `{rep}`" if rep else "Axes applied." # --------------------------------------------------------------- presets def load_presets(): try: d = _get("/v1/presets").json() except Exception as e: return gr.update(choices=[], value=None), f"Could not load presets: {e}" ids = [p["id"] for p in d["presets"]] return (gr.update(choices=ids, value=ids[0] if ids else None), f"{d['count']} preset styles available.") def show_preset(pid, weight, counter, emfill): if not pid: return None, None, "Pick a preset." try: png = _png(_get(f"/v1/presets/{pid}/preview", weight=weight, counter=counter, emfill=emfill)) ttf = _get(f"/v1/presets/{pid}/font", weight=weight, counter=counter, emfill=emfill).content except requests.HTTPError as e: return None, None, f"Preset failed: {e.response.text[:200]}" import tempfile pf = tempfile.NamedTemporaryFile(suffix=".png", delete=False) pf.write(png or b"") pf.close() tf = tempfile.NamedTemporaryFile(suffix=".ttf", delete=False, prefix=pid + "-") tf.write(ttf) tf.close() return pf.name, tf.name, f"**{pid}** ready." # ------------------------------------------------------------------- UI with gr.Blocks(css=CSS, title="Srijika — text to Indic font", theme=gr.themes.Soft(primary_hue="amber")) as demo: gr.HTML(HERO) if not api_ok(): gr.Markdown("> ⚠️ **Demo backend unreachable or key missing.** " "The API may be waking up — reload in a minute.") with gr.Tab("✨ Text → Font"): with gr.Row(): txt = gr.Textbox(label="Describe your font", placeholder="e.g. a heavy rounded poster font " "with warm friendly curves", scale=4) scr = gr.Dropdown(SCRIPTS, value="devanagari", label="Script", scale=1) gen_btn = gr.Button("Generate draft font", variant="primary") gr.Examples(EXAMPLES, inputs=[txt, scr], label="Try one of these") donor_card = gr.Markdown(visible=False, elem_id="donorcard") status = gr.Markdown(elem_id="status") with gr.Row(): preview = gr.Image(label="Specimen", type="filepath", interactive=False) ttf_out = gr.File(label="Download TTF") with gr.Group(visible=False) as axes_grp: gr.Markdown("#### 🎛 Parametric axes — restyle without re-generating") with gr.Row(): w = gr.Slider(-40, 80, 0, step=5, label="Weight") c = gr.Slider(0.8, 1.2, 1.0, step=0.05, label="Counter") e = gr.Slider(0.95, 1.06, 1.0, step=0.01, label="Em-fill") ax_btn = gr.Button("Apply axes") job_state = gr.State("") gen_btn.click( do_generate, [txt, scr], [status, preview, ttf_out, donor_card, axes_grp]) def grab_job(stat_md): import re m = re.findall(r"job `([^`]+)`", stat_md or "") return m[-1] if m else gr.update() status.change(grab_job, status, job_state) ax_btn.click(apply_axes, [job_state, w, c, e], [preview, ttf_out, status]) with gr.Tab("🎨 Preset gallery"): gr.Markdown("Pre-generated Srijika families — instant download, " "same parametric axes.") with gr.Row(): pre_dd = gr.Dropdown([], label="Preset", scale=3) pre_btn = gr.Button("Load presets", scale=1) pre_msg = gr.Markdown() with gr.Row(): pw = gr.Slider(-40, 80, 0, step=5, label="Weight") pc = gr.Slider(0.8, 1.2, 1.0, step=0.05, label="Counter") pe = gr.Slider(0.95, 1.06, 1.0, step=0.01, label="Em-fill") pre_show = gr.Button("Render specimen", variant="primary") pre_img = gr.Image(label="Specimen", type="filepath", interactive=False) pre_file = gr.File(label="Download TTF") pre_btn.click(load_presets, None, [pre_dd, pre_msg]) pre_show.click(show_preset, [pre_dd, pw, pc, pe], [pre_img, pre_file, pre_msg]) with gr.Tab("🔎 Donor search"): gr.Markdown("Peek at the retrieval layer: which **real** Indic fonts " "best match a description (Lipika-FontCLIP fc-v7: 48 style " "attributes incl. measured stroke contrast).") with gr.Row(): sq = gr.Textbox(label="Description", placeholder="thin elegant headline serif", scale=4) ss = gr.Dropdown([""] + SCRIPTS, value="", label="Script filter", scale=1) s_btn = gr.Button("Search corpus", variant="primary") s_md = gr.Markdown() s_gal = gr.Gallery(label="Top donor faces", columns=3, height=420, visible=False) s_btn.click(do_search, [sq, ss], [s_md, s_gal]) with gr.Tab("ℹ️ About"): gr.Markdown(ABOUT) if __name__ == "__main__": demo.queue(max_size=20).launch()