Download devanagari.py from Vishwasaidev/lipi: direct link, hf CLI and curl.
- Browser
- Download file 14.9 kB
-
https://huggingface.co/Vishwasaidev/lipi/resolve/main/devanagari.py
- Command line
-
hf download hf://Vishwasaidev/lipi/devanagari.py
-
curl -L -o devanagari.py https://huggingface.co/Vishwasaidev/lipi/resolve/main/devanagari.py
14.9 kB
| """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] | |