"""Devanagari for Lipi (Hindi, Marathi, Sanskrit): a small rule-based shaper over hand-drawn pen strokes. Devanagari is not written letter by letter. Text is grouped into syllables (aksharas), and inside a syllable: i-matra ि is written BEFORE the consonant it follows in the text, and its loop reaches over to that consonant half forms a consonant with a vertical bar on its right drops the bar before another consonant (स्त, न्य, प्य) ligatures क्ष, त्र, ज्ञ and श्र have their own shapes reph र at the start of a cluster becomes a hook above the end of the syllable (धर्म, कार्य) rakar र at the end of a cluster becomes a slanted tick off the bar (प्र, क्र), or a caret below (ट्र) stacking a consonant with no bar (द ट ड ह ...) carries the next one small underneath it, the Sanskrit way (द्ध, द्व, ट्ट, ह्न) marks other vowel signs and ं ँ ः ् ़ hang off the last consonant's bar headline the shirorekha joins a whole word; it is written last, after the word's letters fallback anything else keeps a visible virama (ङ्क्ष -> ङ्‌क्ष), which is still correct text Units: headline y=0.4, baseline y=10 (see data/devanagari/strokes.txt). Non-Devanagari characters in the text are drawn with the Hershey futural letters at the same body height. """ import math import re import unicodedata import numpy as np HEAD_Y, BASE_Y = 0.4, 10.0 VIRAMA, NUKTA, ZWJ, ZWNJ = "्", "़", "‍", "‌" CONSONANTS = set(chr(c) for c in range(0x915, 0x93a)) | set("ॹॺॻॼॽॾॿ") MATRAS = set("ािीुूृॄॅॆेैॉॊोौ") MODS = set("ँंः") INDEPENDENT = {"आ": ["अ", "ा"], "ओ": ["अ", "ा", "@े"], "औ": ["अ", "ा", "@ै"], "ऑ": ["अ", "ा", "@ॅ"], "ॲ": ["अ", "@ॅ"], "ई": ["इ", "@reph:6.2"], "ऐ": ["ए", "@े:6.6"]} LIGATURES = ("क्ष", "त्र", "ज्ञ", "श्र") def is_deva(text): return any(0x900 <= ord(c) <= 0x97F for c in text) class Deva: """The hand-drawn table from weights.safetensors: key -> (advance, bar, head0, head1, span, strokes).""" def __init__(self, tensors, keys): pts, ss, gs = tensors["glyphs.deva.points"], tensors["glyphs.deva.stroke_start"], tensors["glyphs.deva.glyph_start"] self.glyphs, self.marks = {}, {} for i, (key, kind) in enumerate(keys): adv, bar, h0, h1, span = (float(v) for v in tensors["glyphs.deva.metrics"][i]) strokes = [pts[ss[k]:ss[k + 1]].astype(np.float64) for k in range(gs[i], gs[i + 1])] nan = lambda v: None if math.isnan(v) else v (self.glyphs if kind == "glyph" else self.marks)[key] = dict( adv=adv, bar=nan(bar), head=None if math.isnan(h0) else (h0, h1), span=nan(span), strokes=strokes) def halfable(self, key): g = self.glyphs.get(key) return g is not None and g["bar"] is not None and g["bar"] >= g["adv"] - 1.85 # float32 metrics: keep slack def stackable(self, key, nxt): return (key in self.glyphs and self.glyphs[key]["bar"] is None and len(key) == 1 and nxt in self.glyphs and len(nxt) == 1) # ---- shaping: text -> syllables ------------------------------------------------------------------------------------ def syllables(word): """-> list of dicts: cons [(consonant, nukta)], matra, mods, final_virama; or other=character.""" s = unicodedata.normalize("NFD", word).replace(ZWJ, "").replace(ZWNJ, "") out, i = [], 0 while i < len(s): c = s[i] if c in CONSONANTS: cons, final_virama = [], False while i < len(s) and s[i] in CONSONANTS: nukta = i + 1 < len(s) and s[i + 1] == NUKTA cons.append((s[i], nukta)) i += 1 + nukta if i < len(s) and s[i] == VIRAMA: if i + 1 < len(s) and s[i + 1] in CONSONANTS: i += 1 continue final_virama, i = True, i + 1 break matra = "" if i < len(s) and s[i] in MATRAS: matra, i = s[i], i + 1 mods = "" while i < len(s) and s[i] in MODS: mods, i = mods + s[i], i + 1 out.append(dict(cons=cons, matra=matra, mods=mods, final_virama=final_virama)) else: mods = "" i += 1 while c not in MODS and i < len(s) and s[i] in MODS: mods, i = mods + s[i], i + 1 out.append(dict(other=c, mods=mods)) return out # ---- layout: syllables -> placed strokes --------------------------------------------------------------------------- class Word: """Strokes of one word in writing order, in units, plus the headline spans.""" def __init__(self, deva): self.d, self.x, self.items, self.heads = deva, 0.0, [], [] def stroke(self, P, layer, pen=1.0, steady=False): self.items.append((P, layer, pen, steady)) def glyph(self, key, layer, half=False): g = self.d.glyphs[key] x0 = self.x for P in g["strokes"]: self.stroke(P + [x0, 0], layer) adv = g["adv"] if g["bar"] is not None and not half: self.stroke(np.array([[x0 + g["bar"], HEAD_Y], [x0 + g["bar"], BASE_Y]]), layer) if half: # the next letter tucks in where the body ends adv = max(2.0, max(P[:, 0].max() for P in g["strokes"]) - 1.0) if g["head"]: h0, h1 = g["head"] self.heads.append((x0 + h0, x0 + min(h1, adv), layer)) self.x += adv return dict(x=x0, adv=adv, bar=None if g["bar"] is None or half else x0 + g["bar"]) def stacked(self, key, over, layer, k=0.58): """Draw key small, hanging under the letter `over` (no headline of its own, no advance).""" g = self.d.glyphs[key] off = np.array([over["x"] + over["adv"] / 2 - g["adv"] * k / 2, 9.4 - HEAD_Y * k]) for P in g["strokes"]: self.stroke(P * k + off, layer, pen=0.75) # a smaller letter, a finer pen if g["bar"] is not None: self.stroke(np.array([[g["bar"], HEAD_Y], [g["bar"], BASE_Y]]) * k + off, layer, pen=0.75) def mark(self, key, ax, layer, span=None): m = self.d.marks[key] k = span / m["span"] if span and m["span"] else 1.0 for P in m["strokes"]: self.stroke(P * [k, 1] + [ax, 0], layer) def anchor(placed): """Where marks hang: the consonant's bar, or a little left of its right edge when it has none.""" return placed["bar"] if placed["bar"] is not None else placed["x"] + placed["adv"] * 0.55 def right_anchor(placed): """Where ि's loop lands: a right-hand bar, or near the right edge of a letter with a centre bar (क, फ).""" if placed["bar"] is not None and placed["bar"] >= placed["x"] + placed["adv"] - 2: return placed["bar"] return placed["bar"] + 2.6 if placed["bar"] is not None else placed["x"] + placed["adv"] - 2.4 def fallback_glyph(w, ch, layer, hershey): """A character Devanagari has no stroke for (Latin letters, digits, punctuation): Hershey futural, same body.""" left, right, strokes = hershey(ch) k = (BASE_Y - HEAD_Y) / 21.0 for s in strokes: P = s.copy() P[:, 0] = (P[:, 0] - left) * k + w.x P[:, 1] = (P[:, 1] - 9.0) * k + BASE_Y w.stroke(P, layer) w.x += (right - left) * k def lay_syllable(w, syl, layer, hershey): d = w.d if "other" in syl: c = syl["other"] seq = INDEPENDENT.get(c, [c]) placed = None for part in seq: if part.startswith("@"): key, _, at = part[1:].partition(":") w.mark(key, placed["x"] + float(at) if at else anchor(placed), layer) elif part in d.glyphs: placed = w.glyph(part, layer) else: fallback_glyph(w, part, layer, hershey) if placed is not None: for m in syl["mods"]: if m == "ः": w.glyph("ः", layer) else: w.mark(m, anchor(placed), layer) return cons = [c for c, _ in syl["cons"]] nuktas = [n for _, n in syl["cons"]] reph = len(cons) > 1 and cons[0] == "र" if reph: cons, nuktas = cons[1:], nuktas[1:] keys = [] # merge ligatures left to right i = 0 while i < len(cons): if i + 1 < len(cons) and cons[i] + VIRAMA + cons[i + 1] in LIGATURES: keys.append(cons[i] + VIRAMA + cons[i + 1]) i += 2 else: keys.append(cons[i]) i += 1 rakar = len(keys) > 1 and keys[-1] == "र" if rakar: keys = keys[:-1] matra = syl["matra"] if keys[-1] == "र" and matra in "ुू" and matra: # रु and रू have their own shapes keys[-1], matra = "र" + matra, "" stem = w.glyph("ि", layer) if matra == "ि" else None placed, j = [], 0 while j < len(keys): key, last = keys[j], j == len(keys) - 1 j += 1 if key not in d.glyphs: continue if j == len(keys) - 1 and d.stackable(key, keys[j]): # last two: द्ध, द्व, ट्ट, ह्न p = w.glyph(key, layer) w.stacked(keys[j], p, layer) placed.append(p) j += 1 continue if not last and key + VIRAMA in d.glyphs: # क् and फ् are drawn as their own half shapes p = w.glyph(key + VIRAMA, layer) elif not last and d.halfable(key): p = w.glyph(key, layer, half=True) else: p = w.glyph(key, layer) if not last: # no half form: keep the virama visible w.mark(VIRAMA, anchor(p), layer) if j - 1 < len(nuktas) and nuktas[j - 1]: w.mark(NUKTA, p["x"] + p["adv"] * 0.4, layer) placed.append(p) if not placed: return base = placed[-1] if rakar: if base["bar"] is not None: w.mark("rakar", base["bar"], layer) else: w.mark("rakar^", base["x"] + base["adv"] * 0.5, layer) if stem is not None: w.mark("ि", stem["bar"], layer, span=right_anchor(base) - stem["bar"]) top = base # the letter whose bar carries reph and ं if matra in "ाीोौॉ" and matra: top = w.glyph("ी" if matra == "ी" else "ा", layer) if matra in "ोौॉ": w.mark({"ो": "े", "ौ": "ै", "ॉ": "ॅ"}[matra], anchor(top), layer) elif matra in ("े", "ै", "ॅ"): w.mark(matra, anchor(base), layer) elif matra in ("ु", "ू", "ृ"): w.mark(matra, anchor(base), layer) if syl["final_virama"]: w.mark(VIRAMA, anchor(base), layer) if reph: w.mark("reph", anchor(top), layer) upper = bool(matra) and matra in "िीेैोौॉॅ" for m in syl["mods"]: if m == "ः": w.glyph("ः", layer) elif m == "ँ" and upper: # no room above: candrabindu is written as ं w.mark("ं", anchor(top), layer) else: w.mark(m, anchor(top) + (1.6 if reph else 0), layer) def lay_word(deva, word, hershey, initial_layer=None): """-> Word with strokes in writing order: letters, then the headline joining them.""" w = Word(deva) for i, syl in enumerate(syllables(word)): lay_syllable(w, syl, initial_layer if (i == 0 and initial_layer is not None) else 0, hershey) spans = sorted(w.heads) merged = [] for a, b, layer in spans: if merged and a <= merged[-1][1] + 0.3 and layer == merged[-1][2]: merged[-1][1] = max(merged[-1][1], b) else: merged.append([a, b, layer]) for a, b, layer in merged: w.stroke(np.array([[a - 0.2, HEAD_Y], [b + 0.2, HEAD_Y]]), layer, steady=True) return w def layout_deva(g, text, st, size, max_width, initial, chaikin): """Text -> marks (one per stroke, in writing order, pixels), line widths, line height. Same contract as a Latin layout. With initial, the first syllable is drawn larger in red, hanging from the shared headline.""" scale = size / (BASE_Y - HEAD_Y) hershey = lambda c: g.glyph("futural", c) space = 3.6 line_h = 2.1 * (BASE_Y - HEAD_Y) lines, first = [], initial for para in text.split("\n"): cur, cur_w = [], 0.0 for word in para.split(): w = lay_word(g.deva, word, hershey, 1 if first else None) k = 1.0 if first: k, first = st.get("initial_scale", 1.9), False w = scale_initial(w, k) ww = w.x if cur and (cur_w + space + ww) * scale > max_width: lines.append((cur, cur_w)) cur, cur_w = [], 0.0 cur.append((w, cur_w + (space if cur else 0))) cur_w += (space if len(cur) > 1 else 0) + ww lines.append((cur, cur_w)) widths = [lw * scale for _, lw in lines] marks = [] for li, (words, lw) in enumerate(lines): dx = (max(widths) / scale - lw) / 2 for w, x in words: for P, layer, pen, steady in w.items: P = chaikin(P.copy(), st["smooth"]) + [dx + x, li * line_h] marks.append(dict(P=P * scale, size=size * pen * (st.get("initial_scale", 1.9) if layer else 1.0), layer=layer, steady=steady)) return marks, widths, line_h * scale def scale_initial(w, k): """Scale the red first syllable about the headline so it hangs below it, and push the rest of the word right.""" red = [i for i, item in enumerate(w.items) if item[1] == 1] if not red: return w x_end = max(w.items[i][0][:, 0].max() for i in red) shift = x_end * (k - 1) items = [] for P, layer, pen, steady in w.items: P = P.copy() if layer == 1: P[:, 0] *= k P[:, 1] = HEAD_Y + (P[:, 1] - HEAD_Y) * k else: P[:, 0] += shift items.append((P, layer, pen, steady)) w.items = items w.x += shift return w def seal_strokes(g, text): """Seal text (e.g. ॐ, श्री) -> strokes in units, one word, headline included.""" w = lay_word(g.deva, re.sub(r"\s+", "", text), lambda c: g.glyph("futural", c)) return [item[0] for item in w.items]