Spaces:
Running on Zero
Running on Zero
Enhanced choreography, screen and device sizes, orientation, key and piano roll positions
3ba3768 verified Download ling_map.py from taellinglin/HatchLing-TuneBox: direct link, hf CLI and curl.
- Browser
- Download file 85.3 kB
-
https://huggingface.co/spaces/taellinglin/HatchLing-TuneBox/resolve/main/ling_map.py
- Command line
-
hf download hf://spaces/taellinglin/HatchLing-TuneBox/ling_map.py
-
curl -L -o ling_map.py https://huggingface.co/spaces/taellinglin/HatchLing-TuneBox/resolve/main/ling_map.py
85.3 kB
| #!/usr/bin/env python3 | |
| """Ling map: Ling programs as functional maps, and SVG files as Ling programs. | |
| Ling -> SVG A .zip, a folder or single .ling files become one sheet in the Ling style. | |
| Each file is a framed panel. Each function is a dial: the hub says what it is, the rings | |
| read outward through its statements (ring 1), what they contain (ring 2) and what those | |
| contain (ring 3), and the outer ring spells its name. Every cell takes one of the seven | |
| colours by what the code does, and is hatched with that colour's signature family at a | |
| density that says how much code it holds. Globals and types are tiles. Calls and imports | |
| are connectors. The whole source travels inside the SVG, signed with ML-DSA-87. | |
| SVG -> Ling A map made here gives its program back, byte for byte, after the signature is checked. | |
| Any other SVG becomes a Ling program that redraws it with Ling's svg_* builtins. | |
| python ling_map.py project.zip --out maps # Ling -> SVG, white and black | |
| python ling_map.py map.svg --out restored # SVG -> Ling (the embedded program) | |
| python ling_map.py drawing.svg --out drawn # SVG -> Ling (a program that redraws it) | |
| """ | |
| from __future__ import annotations | |
| import argparse, base64, glob, hashlib, io, json, math, os, re, subprocess, unicodedata, zipfile | |
| import xml.etree.ElementTree as ET | |
| from dataclasses import dataclass, asdict, field, fields | |
| from pathlib import Path | |
| import cv2 | |
| import numpy as np | |
| import ling_hatch as lh | |
| import sizes | |
| import ling_tiles as lt | |
| from ling_hatch import KEYS, EMPTY, THEMES | |
| HERE = Path(__file__).resolve().parent | |
| ENTRY_NAMES = {'start', 'main', '启', 'เริ่ม', '시작', '始め', '始', 'начать', 'начало', 'inicio', 'comenzar', 'début', | |
| 'commencer', 'anfang', 'starten', 'início'} | |
| SHAPE = {'asm': 'octagon', 'lit': 'cross', 'bind': 'circle', 'ctl': 'triangle', 'call': 'diamond', 'fn': 'hexagon', 'mod': 'square'} | |
| DENSITY_TOKENS = [8, 24, 72] # a cell's hatch density says how many tokens it holds: up to 8, 24, 72, more | |
| FORMAT = 'ling-map/1' | |
| class MapOptions: | |
| paper: str = 'a4' # a4 | a3 | a2 | auto (auto: as large as it needs for print-size lettering) | |
| orientation: str = 'auto' # auto | portrait | landscape | |
| margin_mm: float = 6.0 | |
| rings: int = 3 # how many levels of structure each dial shows | |
| edges: bool = True # draw calls and imports as connectors | |
| title: str = '' # title plate text; empty: the project name | |
| dpi: int = 1440 | |
| line_mm: float = 0.123 | |
| spacing_mm: float = 0.28 # densest hatch spacing; the four densities are every 1st, 2nd, 3rd and 5th line | |
| quality: str = 'standard' | |
| key_phrase: str = '' # empty: fresh ML-DSA-87 key per run; set: the same program gives the same sheet | |
| black_ground: bool = True | |
| # =========================================================================== reading programs | |
| def load_sources(paths) -> dict: | |
| """.zip files, folders and .ling files -> {relative path: bytes}, sorted.""" | |
| out = {} | |
| for p in [Path(p) for p in ([paths] if isinstance(paths, (str, Path)) else paths)]: | |
| if p.is_dir(): | |
| for f in sorted(p.rglob('*.ling')): out[f.relative_to(p).as_posix()] = f.read_bytes() | |
| elif p.suffix.lower() == '.zip': | |
| with zipfile.ZipFile(p) as z: | |
| for n in sorted(z.namelist()): | |
| if n.lower().endswith('.ling') and not n.endswith('/') and '__MACOSX' not in n: out[n] = z.read(n) | |
| else: | |
| out[p.name] = p.read_bytes() | |
| names = list(out) | |
| if len(names) > 1: # drop a folder prefix every file shares | |
| pre = os.path.commonprefix([n.rsplit('/', 1)[0] + '/' if '/' in n else '' for n in names]) | |
| if pre: out = {n[len(pre):]: b for n, b in out.items()} | |
| return dict(sorted(out.items())) | |
| _TABLE: dict = {} | |
| def table() -> dict: | |
| if not _TABLE: | |
| _TABLE.update(json.loads((HERE / 'ling_keywords.json').read_text(encoding='utf-8'))) | |
| return _TABLE | |
| _OPS = ['->', '=>', '==', '!=', '<=', '>=', '&&', '||', '..', '::', '+=', '-=', '*=', '/=', '<<'] | |
| def tokenize(src: str) -> list: | |
| """Ling's lexer rules: // and # comments, "strings" with escapes, numbers, words in any script, operators. | |
| -> [(kind, value, start, end)] with kind in kw, id, num, str, bool, op.""" | |
| kwt = table()['keywords']; out = []; i = 0; n = len(src) | |
| while i < n: | |
| c = src[i] | |
| if c in ' \t\r\n\x0c': i += 1; continue | |
| if c == '#' or src.startswith('//', i): | |
| j = src.find('\n', i); i = n if j < 0 else j; continue | |
| if c == '"': | |
| j = i + 1 | |
| while j < n and src[j] != '"': j += 2 if src[j] == '\\' else 1 | |
| out.append(('str', src[i + 1:j], i, min(j + 1, n))); i = min(j + 1, n); continue | |
| if c.isdigit(): | |
| j = i; dot = False | |
| while j < n and (src[j].isdigit() or (src[j] == '.' and not dot and j + 1 < n and src[j + 1].isdigit())): | |
| dot = dot or src[j] == '.'; j += 1 | |
| out.append(('num', src[i:j], i, j)); i = j; continue | |
| if c.isalnum() or c == '_' or unicodedata.category(c) in ('Mn', 'Mc'): | |
| j = i | |
| while j < n and (src[j].isalnum() or src[j] == '_' or unicodedata.category(src[j]) in ('Mn', 'Mc')): j += 1 | |
| w = src[i:j]; k = kwt.get(w) | |
| if k in ('bool_true', 'bool_false') or w in ('true', 'false'): out.append(('bool', w, i, j)) | |
| elif k: out.append(('kw', k, i, j)) | |
| else: out.append(('id', w, i, j)) | |
| i = j; continue | |
| op = next((o for o in _OPS if src.startswith(o, i)), c) | |
| out.append(('op', op, i, i + len(op))); i += len(op) | |
| return out | |
| class Node: | |
| """One piece of program structure. cat is one of the seven keys.""" | |
| __slots__ = ('kind', 'cat', 'name', 's', 'e', 'kids', 'tokens', 'target', 'extra') | |
| def __init__(self, kind, cat, name='', s=0, e=0, kids=None): | |
| self.kind, self.cat, self.name, self.s, self.e = kind, cat, name, s, e | |
| self.kids = kids or []; self.tokens = 1; self.target = None; self.extra = {} | |
| def walk(self): | |
| yield self | |
| for k in self.kids: yield from k.walk() | |
| class Parser: | |
| """A tolerant recursive-descent parser following Ling's own (src/parser/mod.rs). It keeps structure, not values: | |
| statements, the blocks and calls inside them, and where literals sit.""" | |
| def __init__(self, src): | |
| self.src = src; self.t = tokenize(src); self.i = 0; self.errors = [] | |
| # -- token helpers | |
| def peek(self, k=0): | |
| j = self.i + k | |
| return self.t[j] if j < len(self.t) else ('eof', '', len(self.src), len(self.src)) | |
| def at(self, kind, val=None, k=0): | |
| t = self.peek(k); return t[0] == kind and (val is None or t[1] == val) | |
| def adv(self): | |
| t = self.peek(); self.i += 1; return t | |
| def expect(self, kind, val): | |
| if not self.at(kind, val): raise SyntaxError('expected %s at %d, found %r' % (val, self.peek()[2], self.peek()[1])) | |
| return self.adv() | |
| def name(self): | |
| t = self.adv() | |
| if t[0] in ('id', 'kw'): return self.src[t[2]:t[3]] | |
| raise SyntaxError('expected a name at %d' % t[2]) | |
| def node(self, kind, cat, name, i0, kids=None): | |
| n = Node(kind, cat, name, self.t[i0][2] if i0 < len(self.t) else len(self.src), self.t[self.i - 1][3] if self.i else 0, kids) | |
| n.tokens = max(1, self.i - i0); return n | |
| # -- items | |
| def program(self): | |
| items = [] | |
| while not self.at('eof'): | |
| i0 = self.i | |
| try: | |
| items.append(self.item()) | |
| except (SyntaxError, IndexError) as ex: # skip to the next top-level item and carry on | |
| self.errors.append(str(ex)); self.i = max(self.i, i0 + 1); depth = 0 | |
| while not self.at('eof'): | |
| if self.at('op', '{'): depth += 1 | |
| elif self.at('op', '}'): depth = max(0, depth - 1) | |
| elif depth == 0 and self.at('kw') and self.peek()[1] in ('fn', 'bind', 'mod', 'use', 'form', 'choose', 'type'): break | |
| self.i += 1 | |
| return items | |
| def item(self): | |
| i0 = self.i | |
| if self.at('kw', 'async'): self.adv() | |
| k = self.peek() | |
| if k[0] == 'kw' and k[1] == 'bind': | |
| self.adv(); name = self.name(); self.skip_type(); self.expect('op', '=') | |
| block = self.at('kw', 'do') or self.at('op', '{') or self.at('op', '|') or self.at('op', '||') | |
| parts, lits, simple = self.expr() | |
| kind = 'entry' if block and name in ENTRY_NAMES else 'fn' if block else 'bind' | |
| n = self.node(kind, 'mod' if kind == 'entry' else 'fn' if kind == 'fn' else 'bind', name, i0, parts) | |
| n.extra['lits'] = lits; n.extra['value'] = self.src[self.t[i0 + 3][2]:n.e].strip() if kind == 'bind' and i0 + 3 < len(self.t) else '' | |
| return n | |
| if k[0] == 'kw' and k[1] == 'fn': | |
| self.adv(); name = self.name(); self.skip_generics(); self.expect('op', '('); params = [] | |
| while not self.at('op', ')') and not self.at('eof'): | |
| while self.at('op', '&') or (self.at('kw') and self.peek()[1] in ('own', 'lend')): self.adv() | |
| params.append(self.name()); self.skip_type(stop=(',', ')')) | |
| if self.at('op', ','): self.adv() | |
| self.expect('op', ')') | |
| while not (self.at('op') and self.peek()[1] in ('{', ';', '}')) and not self.at('eof'): self.adv() # return type, where clause | |
| if self.at('op', '{'): self.adv(); body = self.block(); self.expect('op', '}') | |
| else: | |
| body = [] | |
| if self.at('op', ';'): self.adv() | |
| n = self.node('entry' if name in ENTRY_NAMES else 'fn', 'mod' if name in ENTRY_NAMES else 'fn', name, i0, body) | |
| n.extra['params'] = params; return n | |
| if k[0] == 'kw' and k[1] == 'mod': | |
| self.adv(); name = self.name(); self.skip_generics(); self.expect('op', '{'); kids = [] | |
| while not self.at('op', '}') and not self.at('eof'): | |
| j0 = self.i | |
| try: | |
| kids.append(self.item()) | |
| except SyntaxError: # a field or other member: step over it | |
| self.i = max(self.i, j0 + 1); depth = 0 | |
| while not self.at('eof') and not (depth == 0 and (self.at('op', '}') or (self.at('kw') and self.peek()[1] in ('fn', 'bind', 'mod')))): | |
| if self.at('op') and self.peek()[1] in ('{', '(', '['): depth += 1 | |
| elif self.at('op') and self.peek()[1] in ('}', ')', ']'): depth -= 1 | |
| self.i += 1 | |
| self.expect('op', '}'); return self.node('mod', 'mod', name, i0, kids) | |
| if k[0] == 'kw' and k[1] in ('type', 'form', 'choose'): | |
| self.adv(); name = self.name(); self.skip_generics(); members = [] | |
| if self.at('op', '{'): | |
| self.adv(); depth = 0 | |
| while not self.at('eof') and not (depth == 0 and self.at('op', '}')): | |
| t = self.adv() | |
| if t[1] in ('(', '{', '['): depth += 1 | |
| elif t[1] in (')', '}', ']'): depth -= 1 | |
| elif depth == 0 and t[0] in ('id', 'kw') and not self.at('op', '::'): | |
| if not members or self.t[self.i - 2][1] in (',', ';', '{') or self.t[self.i - 2][0] != 'op': members.append(self.src[t[2]:t[3]]) | |
| self.expect('op', '}') | |
| else: | |
| while not self.at('eof') and not (self.at('kw') and self.peek()[1] in ('fn', 'bind', 'mod', 'use', 'form', 'choose', 'type')): self.adv() | |
| n = self.node('type', 'bind', name, i0); n.extra['members'] = members; n.extra['form'] = k[1]; return n | |
| if k[0] == 'kw' and k[1] == 'use': | |
| self.adv(); t = self.adv(); path = t[1] if t[0] == 'str' else self.src[t[2]:t[3]] | |
| if self.at('kw', 'as'): self.adv(); self.name() | |
| return self.node('use', 'mod', path, i0) | |
| # a bare statement at top level (scripts) | |
| st = self.stmt() | |
| st.extra['loose'] = True | |
| return st | |
| def skip_generics(self): | |
| if self.at('op', '<'): | |
| depth = 0 | |
| while not self.at('eof'): | |
| t = self.adv() | |
| if t[1] == '<': depth += 1 | |
| elif t[1] == '>': | |
| depth -= 1 | |
| if depth <= 0: break | |
| def skip_type(self, stop=('=',)): | |
| if self.at('op', ':'): | |
| depth = 0 | |
| while not self.at('eof'): | |
| t = self.peek() | |
| if depth == 0 and t[0] == 'op' and t[1] in stop: break | |
| if t[1] in ('<', '(', '['): depth += 1 | |
| elif t[1] in ('>', ')', ']'): | |
| if depth == 0: break | |
| depth -= 1 | |
| self.adv() | |
| # -- statements | |
| def block(self): | |
| out = [] | |
| while not self.at('op', '}') and not self.at('eof'): | |
| i0 = self.i | |
| try: | |
| out.append(self.stmt()) | |
| except SyntaxError as ex: | |
| self.errors.append(str(ex)); self.i = max(self.i, i0 + 1) | |
| depth = 0 | |
| while not self.at('eof') and not (depth == 0 and (self.at('op', '}') or self.at('op', ';'))): | |
| if self.at('op', '{'): depth += 1 | |
| elif self.at('op', '}'): depth -= 1 | |
| self.i += 1 | |
| if self.at('op', ';'): self.adv() | |
| return out | |
| def stmt(self): | |
| i0 = self.i | |
| if self.at('kw', 'bind'): | |
| self.adv(); name = self.name(); self.skip_type(); self.expect('op', '=') | |
| parts, lits, _ = self.expr() | |
| return self.node('bind', 'bind', name, i0, self.with_lits(parts, lits, i0)) | |
| if self.at('kw', 'return') or self.at('kw', 'give'): | |
| self.adv() | |
| parts, lits = ([], 0) if self.at('op', ';') or self.at('op', '}') else self.expr()[:2] | |
| return self.node('return', 'ctl', 'return', i0, self.with_lits(parts, lits, i0)) | |
| if self.at('kw') and self.peek()[1] in ('stop', 'again') and not self.at('op', '(', 1) and not self.at('op', '=', 1): | |
| self.adv(); return self.node('jump', 'ctl', self.t[i0][1], i0) | |
| parts, lits, simple = self.expr() | |
| if simple and len(parts) == 1 and lits == 0: | |
| return parts[0] | |
| if not parts and lits: | |
| return self.node('value', 'lit', '', i0) | |
| if simple and len(parts) == 1: | |
| parts[0].kids = self.with_lits(parts[0].kids, lits, i0); return parts[0] | |
| return self.node('expr', 'fn', '', i0, self.with_lits(parts, lits, i0)) | |
| def with_lits(self, parts, lits, i0): | |
| if lits: | |
| n = Node('value', 'lit', '', self.t[i0][2], self.t[self.i - 1][3]); n.tokens = lits; return parts + [n] | |
| return parts | |
| # -- expressions: return (child nodes, literal count, is one simple node) | |
| def expr(self): | |
| i0 = self.i | |
| parts, lits, simple = self.binary(0) | |
| if self.at('op') and self.peek()[1] in ('=', '+=', '-=', '*=', '/='): | |
| target = self.src[self.t[i0][2]:self.t[self.i - 1][3]] | |
| self.adv(); p2, l2, _ = self.expr() | |
| n = self.node('assign', 'bind', target[:40], i0, self.with_lits(parts + p2, lits + l2, i0)) | |
| return [n], 0, True | |
| return parts, lits, simple | |
| LEVELS = [('||',), ('&&',), ('==', '!=', '<', '>', '<=', '>='), ('+', '-', '^', '<<'), ('*', '/', '%')] | |
| def binary(self, level): | |
| if level == len(self.LEVELS): return self.unary() | |
| parts, lits, simple = self.binary(level + 1) | |
| while self.at('op') and self.peek()[1] in self.LEVELS[level]: | |
| op = self.adv()[1] | |
| if op == '>' and self.at('op', '>') and self.peek()[2] == self.t[self.i - 1][3]: self.adv() # >> | |
| p2, l2, _ = self.binary(level + 1); parts, lits, simple = parts + p2, lits + l2, False | |
| return parts, lits, simple | |
| def unary(self): | |
| if self.at('op') and self.peek()[1] in ('&', '!', '-'): | |
| self.adv(); p, l, _ = self.postfix(); return p, l, False | |
| if self.at('kw') and self.peek()[1] in ('wait', 'own', 'lend', 'share', 'move', 'copy') and not self.at('op', '(', 1): | |
| self.adv(); return self.unary() | |
| return self.postfix() | |
| def postfix(self): | |
| i0 = self.i | |
| parts, lits, simple, name = self.primary() | |
| while True: | |
| if self.at('op', '.'): | |
| self.adv(); m = self.name() | |
| if self.at('op', '('): | |
| self.adv(); a, al = self.args(); self.expect('op', ')') | |
| n = self.node('call', 'call', m, i0, self.with_lits(parts + a, lits + al, i0)); n.extra['method'] = True | |
| parts, lits, simple, name = [n], 0, True, None | |
| else: | |
| name = (name + '.' + m) if name else m | |
| elif self.at('op', '::'): | |
| self.adv(); seg = self.name(); name = (name + '::' + seg) if name else seg | |
| elif self.at('op', '('): | |
| self.adv(); a, al = self.args(); self.expect('op', ')') | |
| n = self.node('call', 'call', name or '', i0, self.with_lits(parts + a, lits + al, i0)) | |
| parts, lits, simple, name = [n], 0, True, None | |
| elif self.at('op', '['): | |
| self.adv(); p2, l2, _ = self.expr(); self.expect('op', ']'); parts, lits, simple = parts + p2, lits + l2, False | |
| elif self.at('op', '..'): | |
| self.adv(); p2, l2, _, _n = self.primary(); parts, lits, simple = parts + p2, lits + l2, False | |
| else: | |
| break | |
| return parts, lits, simple | |
| def args(self): | |
| parts, lits = [], 0 | |
| while not self.at('op', ')') and not self.at('eof'): | |
| p, l, _ = self.expr(); parts += p; lits += l | |
| if self.at('op', ','): self.adv() | |
| return parts, lits | |
| def primary(self): | |
| """-> (parts, lits, simple, name-if-identifier)""" | |
| i0 = self.i; t = self.peek() | |
| if t[0] in ('str', 'num', 'bool'): self.adv(); return [], 1, False, None | |
| if t[0] == 'kw': | |
| k = t[1] | |
| if k == 'do' and self.at('op', '{', 1): | |
| self.adv(); self.adv(); b = self.block(); self.expect('op', '}'); return b, 0, False, None | |
| if k == 'if': | |
| self.adv(); cp, cl, _ = self.binary(0); self.expect('op', '{'); then = self.block(); self.expect('op', '}') | |
| kids = self.with_lits(cp, cl, i0) + then | |
| while self.at('kw', 'else'): | |
| j0 = self.i; self.adv() | |
| if self.at('kw', 'if'): | |
| self.adv(); cp, cl, _ = self.binary(0); self.expect('op', '{'); b = self.block(); self.expect('op', '}') | |
| kids.append(self.node('if', 'ctl', 'else if', j0, self.with_lits(cp, cl, j0) + b)) | |
| else: | |
| self.expect('op', '{'); b = self.block(); self.expect('op', '}') | |
| kids.append(self.node('else', 'ctl', 'else', j0, b)); break | |
| return [self.node('if', 'ctl', 'if', i0, kids)], 0, True, None | |
| if k == 'while': | |
| self.adv(); cp, cl, _ = self.expr(); self.expect('op', '{'); b = self.block(); self.expect('op', '}') | |
| return [self.node('loop', 'ctl', 'while', i0, self.with_lits(cp, cl, i0) + b)], 0, True, None | |
| if k == 'for': | |
| self.adv(); var = self.name(); self.expect('kw', 'in'); ip, il, _ = self.postfix() | |
| self.expect('op', '{'); b = self.block(); self.expect('op', '}') | |
| return [self.node('loop', 'ctl', var, i0, self.with_lits(ip, il, i0) + b)], 0, True, None | |
| if k == 'match': | |
| self.adv(); sp, sl, _ = self.postfix(); self.expect('op', '{'); kids = self.with_lits(sp, sl, i0) | |
| while not self.at('op', '}') and not self.at('eof'): | |
| j0 = self.i | |
| while not self.at('op', '=>') and not self.at('eof') and not self.at('op', '}'): self.adv() # pattern | |
| self.expect('op', '=>'); p, l, _ = self.expr() | |
| kids.append(self.node('arm', 'ctl', '', j0, self.with_lits(p, l, j0))) | |
| if self.at('op', ','): self.adv() | |
| self.expect('op', '}') | |
| return [self.node('match', 'ctl', 'match', i0, kids)], 0, True, None | |
| if k in ('return', 'give'): | |
| self.adv() | |
| p, l = ([], 0) if (self.at('op', ';') or self.at('op', '}') or self.at('eof')) else self.expr()[:2] | |
| return [self.node('return', 'ctl', 'return', i0, self.with_lits(p, l, i0))], 0, True, None | |
| if k == 'async': self.adv(); p, l, s_ = self.expr(); return p, l, s_, None | |
| if k == 'asm': | |
| self.adv() | |
| if self.at('op', '!'): self.adv() | |
| self.expect('op', '('); self.adv(); self.expect('op', ')') | |
| return [self.node('asm', 'asm', 'asm', i0)], 0, True, None | |
| self.adv(); return [], 0, False, self.src[t[2]:t[3]] # a keyword used as a name | |
| if t[0] == 'id': | |
| self.adv() | |
| if self.at('str') and self.peek()[2] == t[3]: self.adv(); return [], 1, False, None # f"..." | |
| return [], 0, False, t[1] | |
| if t[0] == 'op': | |
| if t[1] == '{': self.adv(); b = self.block(); self.expect('op', '}'); return b, 0, False, None | |
| if t[1] == '[': | |
| self.adv(); parts, lits = [], 0 | |
| while not self.at('op', ']') and not self.at('eof'): | |
| p, l, _ = self.expr(); parts += p; lits += l | |
| if self.at('op', ','): self.adv() | |
| elif self.at('op', ';'): self.adv() | |
| elif not self.at('op', ']'): raise SyntaxError('expected , or ] at %d' % self.peek()[2]) | |
| self.expect('op', ']'); return parts, lits, False, None | |
| if t[1] in ('|', '||'): | |
| self.adv() | |
| if t[1] == '|': | |
| while not self.at('op', '|') and not self.at('eof'): self.adv() | |
| self.adv() | |
| p, l, _ = self.expr() | |
| return [self.node('lambda', 'fn', 'λ', i0, self.with_lits(p, l, i0))], 0, True, None | |
| if t[1] == '(': | |
| self.adv() | |
| if self.at('op', ')'): self.adv(); return [], 0, False, None | |
| parts, lits = [], 0 | |
| while True: | |
| p, l, _ = self.expr(); parts += p; lits += l | |
| if self.at('op', ','): self.adv(); continue | |
| break | |
| self.expect('op', ')'); return parts, lits, False, None | |
| raise SyntaxError('unexpected %r at %d' % (t[1], t[2])) | |
| def parse(src: str): | |
| p = Parser(src); items = p.program(); return items, p.errors, len(p.t) | |
| # =========================================================================== analysis | |
| def _builtin_hue(name: str) -> str: | |
| """Colour of a builtin by what it does: hardware red, media orange, data yellow, timing green, I/O blue, maths indigo.""" | |
| t = table(); canon = t['builtins'].get(name, name); h = t['hue'].get(canon) | |
| if h: return h | |
| n = canon.lower() | |
| if re.match(r'(a?(sin|cos|tan)h?2?|arc|sqrt|cbrt|exp|log|pow|abs|min|max|floor|ceil|round|neg|wrap|lerp|clamp|blake|sha|aes|argon|ed25519|x25519|base64|hash|vec|mat|dot|cross|norm)', n): return 'fn' | |
| return '' | |
| class Module: | |
| path: str | |
| name: str | |
| fns: list = field(default_factory=list) | |
| globals_: list = field(default_factory=list) | |
| types: list = field(default_factory=list) | |
| uses: list = field(default_factory=list) | |
| errors: list = field(default_factory=list) | |
| tokens: int = 0 | |
| def analyse(files: dict) -> dict: | |
| """-> project model: modules, the functions in them with their statement trees, the call graph and totals.""" | |
| mods = []; fn_index = {} | |
| for path, raw in files.items(): | |
| try: src = raw.decode('utf-8') | |
| except UnicodeDecodeError: src = raw.decode('utf-8', 'replace') | |
| if path.replace('\\', '/').split('/')[0] == 'lexicons' or re.search(r'^\s*\[keywords\]', src, re.M): | |
| continue # a lexicon table, not a program | |
| items, errors, ntok = parse(src) | |
| m = Module(path=path, name=Path(path).stem, errors=errors, tokens=ntok); loose = [] | |
| def take(items, prefix=''): | |
| for it in items: | |
| if it.kind in ('fn', 'entry'): it.extra['module'] = m; it.extra['qual'] = prefix + it.name; m.fns.append(it) | |
| elif it.kind == 'bind': m.globals_.append(it) | |
| elif it.kind == 'type': m.types.append(it) | |
| elif it.kind == 'use': m.uses.append(it) | |
| elif it.kind == 'mod': take(it.kids, prefix + it.name + '::') | |
| else: loose.append(it) | |
| take(items) | |
| if loose: # top-level statements of a script act as its entry | |
| n = Node('entry', 'mod', m.name, loose[0].s, loose[-1].e, loose); n.tokens = sum(k.tokens for k in loose) | |
| n.extra.update(module=m, qual=m.name, script=True); m.fns.insert(0, n) | |
| for f in m.fns: fn_index.setdefault(f.name, f) | |
| mods.append(m) | |
| # colour every call: a call to one of the project's own functions is a call (blue) and an edge; a builtin takes | |
| # the colour of what it does; anything unknown stays a call | |
| edges = {} | |
| for m in mods: | |
| for f in m.fns: | |
| for n in f.walk(): | |
| if n.kind != 'call': continue | |
| base = n.name.split('::')[-1].split('.')[-1] | |
| tgt = fn_index.get(base) | |
| if tgt is not None and not n.extra.get('method'): | |
| n.target = tgt; n.cat = 'call' | |
| if tgt is not f: edges[(id(f), id(tgt))] = (f, tgt, edges.get((id(f), id(tgt)), (0, 0, 0))[2] + 1) | |
| else: | |
| n.cat = _builtin_hue(base) or 'call' | |
| by_stem = {} | |
| for m in mods: by_stem.setdefault(m.name, m) | |
| imports = [] | |
| for m in mods: | |
| for u in m.uses: | |
| t = by_stem.get(Path(u.name).stem) | |
| if t is not None and t is not m: imports.append((m, t)) | |
| share = dict.fromkeys(KEYS, 0) | |
| for m in mods: | |
| for f in m.fns: | |
| for n in f.walk(): share[n.cat] += 1 if n.kids else n.tokens | |
| share['bind'] += sum(g.tokens for g in m.globals_) + sum(t.tokens for t in m.types); share['mod'] += len(m.uses) + 1 | |
| tot = max(1, sum(share.values())) | |
| return {'modules': mods, 'edges': list(edges.values()), 'imports': imports, 'share': {k: v / tot for k, v in share.items()}, | |
| 'counts': {'files': len(mods), 'functions': sum(len(m.fns) for m in mods), 'globals': sum(len(m.globals_) for m in mods), | |
| 'types': sum(len(m.types) for m in mods), 'calls': sum(e[2] for e in edges.values()), | |
| 'tokens': sum(m.tokens for m in mods), 'parse_errors': sum(len(m.errors) for m in mods)}} | |
| # =========================================================================== lettering | |
| _FONTS: dict = {'files': None, 'open': {}, 'char': {}} | |
| _FONT_HINTS = ['NotoSansCJK-Regular', 'NotoSansSC', 'NotoSansJP', 'NotoSansKR', 'NotoSansThai', 'Loma.', 'DejaVuSans.ttf', 'NotoSans-Regular', | |
| 'NotoSansArabic', 'NotoSansHebrew', 'Arial Unicode', 'msyh', 'malgun', 'LeelawUI', 'tahoma', 'PingFang', 'AppleSDGothicNeo', | |
| 'Thonburi', 'segoeui', 'FreeSans.', 'unifont'] | |
| def _font_files(): | |
| if _FONTS['files'] is None: | |
| dirs = ['/usr/share/fonts', '/usr/local/share/fonts', os.path.expanduser('~/.fonts'), os.path.expanduser('~/.local/share/fonts'), | |
| '/System/Library/Fonts', '/Library/Fonts', os.path.expanduser('~/Library/Fonts'), 'C:/Windows/Fonts', str(HERE / 'fonts')] | |
| found = [] | |
| for d in dirs: | |
| if os.path.isdir(d): | |
| for ext in ('ttf', 'otf', 'ttc'): found += glob.glob(os.path.join(d, '**', '*.' + ext), recursive=True) | |
| rank = lambda f: next((i for i, h in enumerate(_FONT_HINTS) if h.lower() in os.path.basename(f).lower()), 999) | |
| _FONTS['files'] = [f for f in sorted(found, key=rank) if rank(f) < 999] | |
| return _FONTS['files'] | |
| def script_glyph(ch: str): | |
| """Outline of a character that Daemon and Chesilin do not have, from a system font. -> (contours y-down, advance, | |
| units per em) or None. Marks that sit on a base letter (Thai vowels and tones) keep their offsets.""" | |
| if ch in _FONTS['char']: return _FONTS['char'][ch] | |
| from fontTools.ttLib import TTFont, TTCollection | |
| res = None | |
| for f in _font_files(): | |
| if f not in _FONTS['open']: | |
| try: | |
| fonts = TTCollection(f, lazy=True).fonts if f.lower().endswith('.ttc') else [TTFont(f, lazy=True)] | |
| if f.lower().endswith('.ttc'): | |
| fonts = sorted(fonts, key=lambda t: 0 if ' SC' in (t['name'].getDebugName(1) or '') else 1)[:1] | |
| _FONTS['open'][f] = [(t, t.getBestCmap() or {}, t.getGlyphSet(), t['head'].unitsPerEm) for t in fonts] | |
| except Exception: | |
| _FONTS['open'][f] = [] | |
| for t, cmap, gs, upm in _FONTS['open'][f]: | |
| g = cmap.get(ord(ch)) | |
| if g is None: continue | |
| pen = lt.RecordingPen(); gs[g].draw(pen); cs, cur = [], []; last = (0, 0) | |
| for op, a in pen.value: | |
| if op == 'moveTo': cur = [a[0]]; last = a[0] | |
| elif op == 'lineTo': cur.append(a[0]); last = a[0] | |
| elif op == 'qCurveTo': | |
| pts = [last] + list(a) | |
| if pts[-1] is None: pts = pts[:-1] + [pts[1]] | |
| for i in range(1, len(pts) - 1): | |
| p0 = pts[0] if i == 1 else ((pts[i - 1][0] + pts[i][0]) / 2, (pts[i - 1][1] + pts[i][1]) / 2) | |
| p2 = pts[-1] if i == len(pts) - 2 else ((pts[i][0] + pts[i + 1][0]) / 2, (pts[i][1] + pts[i + 1][1]) / 2) | |
| for q in (0.34, 0.67, 1.0): | |
| cur.append(((1 - q) ** 2 * p0[0] + 2 * (1 - q) * q * pts[i][0] + q * q * p2[0], | |
| (1 - q) ** 2 * p0[1] + 2 * (1 - q) * q * pts[i][1] + q * q * p2[1])) | |
| last = pts[-1] | |
| elif op == 'curveTo': | |
| p0, (c1, c2, p3) = last, a | |
| for q in (0.2, 0.4, 0.6, 0.8, 1.0): | |
| cur.append(tuple((1 - q) ** 3 * p0[k] + 3 * (1 - q) ** 2 * q * c1[k] + 3 * (1 - q) * q * q * c2[k] + q ** 3 * p3[k] for k in (0, 1))) | |
| last = p3 | |
| elif op in ('closePath', 'endPath'): | |
| if len(cur) > 2: cs.append(np.array(cur, float) * [1, -1]) | |
| cur = [] | |
| res = (cs, gs[g].width, upm); break | |
| if res: break | |
| _FONTS['char'][ch] = res | |
| return res | |
| def clusters(text: str) -> list: | |
| """Split into letters with their combining marks, so a Thai vowel stays with its consonant.""" | |
| out = [] | |
| for ch in text: | |
| if out and unicodedata.category(ch) in ('Mn', 'Mc', 'Me'): out[-1] += ch | |
| else: out.append(ch) | |
| return out | |
| def cluster_outline(cl: str): | |
| """Contours of one cluster in its own bounding box (y down) -> (width, height, contours) or None.""" | |
| x = 0.0; cs = [] | |
| for ch in cl: | |
| g = script_glyph(ch) | |
| if g is None: return None | |
| cont, adv, upm = g | |
| cs += [c + [x, 0] for c in cont]; x += adv | |
| if not cs: return None | |
| allp = np.vstack(cs); mn = allp.min(0); mx = allp.max(0) | |
| return float(mx[0] - mn[0]) or 1.0, float(mx[1] - mn[1]) or 1.0, [c - mn for c in cs] | |
| def ring_glyph(ch: str): | |
| """What goes in one ring cell: Chesilin if it has the letter, else the letter's own script. -> (w, h, contours, face)""" | |
| c = '.' if ch == '_' else ch | |
| g = lt.ling_glyph('chesilin', c) | |
| if g: return g[0], g[1], g[2], 'chesilin' | |
| o = cluster_outline(ch) | |
| return (o[0], o[1], o[2], 'script') if o else None | |
| def daemon_ok(text: str) -> bool: | |
| return all(c == ' ' or lt.ling_glyph('daemon', c) for c in text) | |
| # =========================================================================== layout (design units, u) | |
| HUB, BAND, NAME_BAND, BEZEL, GAP = 13.0, 14.0, 14.0, 4.0, 12.0 | |
| TILE = 16.0 # label tile, the screen floor of the design system | |
| def tree_depth(n: Node, limit: int) -> int: | |
| return 1 if (limit <= 1 or not n.kids) else 1 + max(tree_depth(k, limit - 1) for k in n.kids) | |
| def dial_radius(f: Node, o: MapOptions) -> dict: | |
| levels = max(1, min(o.rings, max((tree_depth(k, o.rings) for k in f.kids), default=1))) | |
| hub = HUB + 1.6 * math.sqrt(len(f.kids)) | |
| need = (len(clusters(f.name)) + 1) * 9.5 / (2 * math.pi) + NAME_BAND / 2 # the name ring must hold the whole name | |
| inner = max(hub + levels * BAND, need - NAME_BAND / 2) | |
| band = (inner - hub) / levels | |
| return {'hub': hub, 'levels': levels, 'band': band, 'name_r0': inner, 'R': inner + NAME_BAND + BEZEL} | |
| def pack_circles(radii: list, aspect: float, gap: float = GAP) -> list: | |
| """Place circles one after another, each as near the centre as it fits. The first is the root. -> [(x, y)] | |
| aspect: the shape of the box the cluster should fill (width / height); None: a round cluster about the root.""" | |
| pos = [] | |
| for i, r in enumerate(radii): | |
| if not pos: pos.append((0.0, 0.0)); continue | |
| P = np.array(pos); Rr = np.array(radii[:i]); C = np.zeros((0, 2)) | |
| nearest = np.argsort(np.hypot(P[:, 0] / (aspect or 1.0), P[:, 1]))[:40] if len(pos) > 40 else range(len(pos)) | |
| nearest_list = list(nearest) | |
| for near in (nearest_list, list(range(len(pos)))): # the 40 nearest first; if every spot they offer is taken, all of them | |
| cand = [] | |
| for a in near: # touching one placed circle | |
| for k in range(24): | |
| th = 2 * math.pi * k / 24; d = Rr[a] + r + gap | |
| cand.append((P[a, 0] + d * math.cos(th), P[a, 1] + d * math.sin(th))) | |
| for ia in range(len(near) if near is nearest_list else 0): # touching two placed circles (not on the retry: too many pairs) | |
| for ib in range(ia + 1, len(near)): | |
| a, b = near[ia], near[ib] | |
| da, db = Rr[a] + r + gap, Rr[b] + r + gap; dx, dy = P[b] - P[a]; d = math.hypot(dx, dy) | |
| if d == 0 or d > da + db or d < abs(da - db): continue | |
| x = (da * da - db * db + d * d) / (2 * d); h2 = da * da - x * x | |
| if h2 < 0: continue | |
| h = math.sqrt(h2); ux, uy = dx / d, dy / d | |
| cand.append((P[a, 0] + x * ux - h * uy, P[a, 1] + x * uy + h * ux)) | |
| cand.append((P[a, 0] + x * ux + h * uy, P[a, 1] + x * uy - h * ux)) | |
| C = np.array(cand).reshape(-1, 2) | |
| C = C[(np.hypot(C[:, None, 0] - P[None, :, 0], C[:, None, 1] - P[None, :, 1]) >= (Rr[None, :] + r + gap - 1e-6)).all(1)] | |
| if len(C) or len(near) == len(pos): break | |
| if not len(C): # still nothing (a crowded or odd cluster): go round the outside of everything | |
| d = (np.hypot(P[:, 0], P[:, 1]) + Rr).max() + r + gap | |
| C = np.array([(d * math.cos(2 * math.pi * k / 72), d * math.sin(2 * math.pi * k / 72)) for k in range(72)]) | |
| bx0, bx1 = (P[:, 0] - Rr).min(), (P[:, 0] + Rr).max(); by0, by1 = (P[:, 1] - Rr).min(), (P[:, 1] + Rr).max() | |
| w = np.maximum(bx1, C[:, 0] + r) - np.minimum(bx0, C[:, 0] - r); h = np.maximum(by1, C[:, 1] + r) - np.minimum(by0, C[:, 1] - r) | |
| if aspect: score = np.maximum(w / aspect, h) + 0.02 * np.hypot(C[:, 0], C[:, 1]) # smallest box, then nearest the root | |
| else: score = np.hypot(C[:, 0], C[:, 1]) + r # smallest circle about the root | |
| pos.append(tuple(C[int(score.argmin())])) | |
| return pos | |
| def word_w(n: int, size: float = TILE) -> float: | |
| return n * size + max(0, n - 1) * size * 0.14 | |
| def daemon_clean(text: str) -> str: | |
| out = '' | |
| for ch in text: | |
| out += next((c for c in (ch, ch.upper(), ch.lower()) if c == ' ' or lt.ling_glyph('daemon', c)), '') | |
| return out.strip() | |
| def foot_spec(model: dict, o: MapOptions, project: str, nst: int = 4) -> dict: | |
| """Sizes of the foot of the sheet: legend with hatch swatches, share dial, digest, title plate. In a row on a | |
| landscape sheet, stacked in two rows on a portrait one.""" | |
| S, M, G = TILE, 22.0, 16.0; c = model['counts'] | |
| title = daemon_clean((o.title or project)[:12]) or 'LING'; sub = '%d files %d fn' % (c['files'], c['functions']); rev = 'rev 2026.1' | |
| ts = 30.0; plate_w = max(word_w(len(title), ts), word_w(len(sub), S), word_w(len(rev), S)) + 40; plate_h = ts + S + 8 + S + 14 + 40 | |
| rowA = 4 * S * 1.7 - S * 0.7 + 2 * M; rowB = max(4 * S + 3 * S * 0.14 + 2 * M, plate_h) | |
| labw = S * 2.2 + word_w(4, S); leg_min = 2 * (labw + 12 + nst * 18) + 28 + 2 * M; grid_w = word_w(16, S) + 2 * M; key_h = S * 0.75 + 10 | |
| return {'title': title, 'sub': sub, 'rev': rev, 'ts': ts, 'plate_w': plate_w, 'plate_h': plate_h, 'rowA': rowA, 'rowB': rowB, 'labw': labw, | |
| 'grid_w': grid_w, 'key_h': key_h, 'G': G, 'M': M, | |
| 'row': {'F': leg_min + rowA + grid_w + plate_w + 3 * G, 'h': key_h + max(rowA, plate_h) + 24}, | |
| 'stack': {'F': max(leg_min + G + rowA, grid_w + G + plate_w), 'h': key_h + rowA + G + rowB + 24}} | |
| def layout(model: dict, o: MapOptions, project: str = 'project') -> dict: | |
| """-> every drawn thing with its place, in design units, and the sheet size that holds them.""" | |
| panels = [] | |
| for m in model['modules']: | |
| fns = list(m.fns) | |
| # the root first, then by distance along calls from it, then by size: the map reads outward from the root | |
| entry = [f for f in fns if f.kind == 'entry']; rest = [f for f in fns if f.kind != 'entry'] | |
| calls = {} | |
| for a, b, _n in model['edges']: calls.setdefault(id(a), []).append(b) | |
| order, seen, queue = [], set(), list(entry) or rest[:1] | |
| while queue: | |
| f = queue.pop(0) | |
| if id(f) in seen or f.extra.get('module') is not m: continue | |
| seen.add(id(f)); order.append(f); queue += calls.get(id(f), []) | |
| order += sorted([f for f in fns if id(f) not in seen], key=lambda f: -f.tokens) | |
| dials = [dict(dial_radius(f, o), fn=f) for f in order] | |
| ncell = sum(1 for _ in m.globals_) + len(m.types) | |
| pos = pack_circles([d['R'] for d in dials], 1.25) if dials else [] | |
| for d, p in zip(dials, pos): d['x'], d['y'] = p | |
| if dials: | |
| x0 = min(d['x'] - d['R'] for d in dials); x1 = max(d['x'] + d['R'] for d in dials) | |
| y0 = min(d['y'] - d['R'] for d in dials); y1 = max(d['y'] + d['R'] for d in dials) | |
| else: | |
| x0 = y0 = 0.0; x1 = y1 = 0.0 | |
| # globals and types: a table of tiles under the dials | |
| rows = [] | |
| for g in m.globals_: rows.append(('bind', g, g.name, g.extra.get('value', ''))) | |
| for t in m.types: rows.append(('type', t, t.name, ' '.join(t.extra.get('members', [])[:4]))) | |
| label = lambda s, n: ''.join(clusters(s)[:n]) | |
| cells = [(kind, node, label(name, 14), label(re.sub(r'\s+', ' ', val), 10)) for kind, node, name, val in rows] | |
| colw = max([TILE * 1.8 + word_w(len(clusters(a))) + (TILE * 0.9 + word_w(len(clusters(b))) if b else 0) for _, _, a, b in cells], default=0) + TILE * 1.5 | |
| width = max(x1 - x0, colw, word_w(len(clusters(m.name)) + 2) + 90, 220.0) | |
| ncol = max(1, int(width // colw)) if cells else 1 | |
| nrow = math.ceil(len(cells) / ncol) if cells else 0 | |
| table_h = nrow * TILE * 1.6 + (TILE if cells else 0) | |
| pad = 26.0 | |
| panels.append({'mod': m, 'dials': dials, 'cells': cells, 'ncol': ncol, 'colw': colw, 'x0': x0, 'y0': y0, | |
| 'w': width + 2 * pad, 'h': (y1 - y0) + table_h + 2 * pad + 6, 'pad': pad, 'dial_w': x1 - x0, 'dial_h': y1 - y0}) | |
| # panels on shelves, tried against the paper's shape; a small program is enlarged to the width the foot needs | |
| foot = foot_spec(model, o, project); best = None | |
| for land in ([False, True] if o.orientation == 'auto' else [o.orientation == 'landscape']): | |
| asp = (297 / 210) if land else (210 / 297) | |
| tot = sum(p['w'] * (p['h'] + 40) for p in panels) or 1.0 | |
| for grow in (1.0, 1.15, 1.35, 1.6, 2.0, 2.6): | |
| mode = 'row' if land else 'stack'; F = foot[mode]['F'] | |
| Wt = max(max((p['w'] for p in panels), default=300), math.sqrt(tot * asp) * grow) | |
| x = y = 0.0; shelf = 0.0; places = [] | |
| for p in sorted(panels, key=lambda p: -p['h']): | |
| if x > 0 and x + p['w'] > Wt: x = 0.0; y += shelf + 44; shelf = 0.0 | |
| places.append((p, x, y)); x += p['w'] + 20; shelf = max(shelf, p['h']) | |
| Wc = max((px + p['w'] - 20 * 0 for p, px, _ in places), default=Wt); Hp = y + shelf + 44 | |
| zoom = min(max(1.0, F / Wc), 3.0) | |
| Wtot = max(Wc * zoom, F); Htot = Hp * zoom + foot[mode]['h'] | |
| K = min((297 if land else 210) / Wtot, (210 if land else 297) / Htot) | |
| score = K * math.sqrt(zoom) | |
| if best is None or score > best[0]: best = (score, land, places, Wc, Hp, zoom, Wtot, Htot, mode) | |
| _, land, places, Wc, Hp, zoom, Wtot, Htot, mode = best | |
| for p, px, py in places: p['x'], p['y'] = px, py + 30 # room for the title tab above each frame | |
| return {'panels': panels, 'W': Wtot, 'H': Htot, 'landscape': land, 'zoom': zoom, 'map_w': Wc, 'map_h': Hp, 'foot': foot, 'mode': mode} | |
| # =========================================================================== drawing | |
| def _poly_d(pts, closed=True): | |
| p = np.rint(np.asarray(pts)).astype(int); d = np.diff(p, axis=0) | |
| return 'M%d %d' % (p[0, 0], p[0, 1]) + ''.join('l%d %d' % (a, b) for a, b in d if a or b) + ('z' if closed else '') | |
| def _sector_pts(cx, cy, r0, r1, a0, a1): | |
| n = max(2, int(math.ceil((a1 - a0) / math.radians(4)))) | |
| A = np.linspace(a0, a1, n + 1) | |
| outer = [(cx + r1 * math.sin(a), cy - r1 * math.cos(a)) for a in A] | |
| inner = [(cx + r0 * math.sin(a), cy - r0 * math.cos(a)) for a in A[::-1]] | |
| return outer + inner | |
| def density_step(tokens: int, nsteps: int) -> int: | |
| s = sum(tokens > t for t in DENSITY_TOKENS) | |
| return min(s, nsteps - 1) | |
| class Sheet: | |
| """Collects what one sheet holds: exact outlines per colour, a label raster for the signature hatch, component | |
| calls for the Ling bundle, and the per-node data that ties each drawn thing to its source.""" | |
| def __init__(self, W_u, H_u, pxu, geo, theme): | |
| self.pxu, self.geo, self.theme = pxu, geo, theme; self.sc = geo['sc'] | |
| self.Wpx, self.Hpx = int(round(W_u * pxu)), int(round(H_u * pxu)) | |
| self.raster = np.full((self.Hpx // self.sc + 1, self.Wpx // self.sc + 1), EMPTY, np.uint8) | |
| self.lines = {k: {} for k in list(KEYS) + ['ink']} # colour -> stroke width (px) -> [d] | |
| self.k = pxu # pixels per unit for what is being drawn now | |
| self.calls = {} # scale -> Ling bundle components, in design units | |
| self.groups = [] # (attrs, colour, width, d): traceable pieces | |
| self.nsteps = len(geo['mults']) | |
| def px(self, v): return v * self.k | |
| def add(self, color, d, width=None): | |
| w = int(round(width if width is not None else self.geo['line'])) | |
| self.lines[color].setdefault(max(1, w), []).append(d) | |
| def region(self, pts_px, hue, step): | |
| """A hatched region: fill its label on the raster, then clear a margin along its edge so the hatch stays | |
| inside its own outline and never runs into a neighbour of the same colour.""" | |
| q = np.rint(np.asarray(pts_px) / self.sc).astype(np.int32) | |
| cv2.fillPoly(self.raster, [q], int(KEYS.index(hue) * 4 + step)) | |
| cv2.polylines(self.raster, [q], True, int(EMPTY), max(2, int(round(self.geo['line'] * 2.2 / self.sc)))) | |
| def component(self, name, **opts): | |
| self.calls.setdefault(self.k, []).append({'c': name, 'o': opts}) | |
| def ring_name(sh: Sheet, cx, cy, r0, r1, text, color): | |
| """A name set round a ring, one glyph per annular cell, repeated to fill the ring, with an empty cell between.""" | |
| band = r1 - r0; rm = (r0 + r1) / 2; unit = clusters(text) + [' '] | |
| n = max(len(unit), int(2 * math.pi * rm / (sh.px(1) * 9.5) + 0.01)) | |
| reps = max(1, n // len(unit)); txt = unit * reps; m = len(txt); dth = 2 * math.pi / m | |
| A = 2 * math.pi * rm / m; line = sh.geo['line']; dw = max(3, round(0.75 * line)) | |
| for i, cl in enumerate(txt): | |
| a = i * dth | |
| sh.add(color, 'M%d %dL%d %d' % (round(cx + r0 * math.sin(a)), round(cy - r0 * math.cos(a)), | |
| round(cx + r1 * math.sin(a)), round(cy - r1 * math.cos(a))), dw) | |
| if cl == ' ': continue | |
| g = ring_glyph(cl) | |
| if g is None: continue | |
| w, h, polys, face = g; k = min(0.7 * A / w, 0.62 * band / h); th = (i + 0.5) * dth | |
| tw = line if h * k >= 10 * line else max(4, round(0.7 * line)) | |
| for c in polys: | |
| u = (c[:, 0] - w / 2) * k; v = (h / 2 - c[:, 1]) * k | |
| if face == 'chesilin': # the ring face bends with its ring | |
| pts = [] | |
| for j in range(len(c)): | |
| pts += lt._polar(cx, cy, rm, th, (u[j], v[j]), (u[(j + 1) % len(c)], v[(j + 1) % len(c)])) | |
| else: # other scripts keep their shape and only turn to face the ring | |
| ct, st = math.cos(th), math.sin(th) | |
| pts = [(cx + rm * st + uu * ct + vv * st, cy - rm * ct + uu * st - vv * ct) for uu, vv in zip(u, v)] | |
| sh.add(color, _poly_d(pts), tw) | |
| def script_label(sh: Sheet, text, x, y, size, color, align='left'): | |
| """A straight label in the text's own script, drawn in outline. (x, y) is the top-left of its line box.""" | |
| cls = clusters(text); outs = [cluster_outline(c) if c != ' ' else None for c in cls] | |
| hs = [o[1] for o in outs if o] | |
| if not hs: return 0.0 | |
| k = 0.72 * size / max(hs); adv = [(o[0] * k + size * 0.12) if o else size * 0.4 for o in outs]; total = sum(adv) | |
| x0 = x - (total if align == 'right' else total / 2 if align == 'center' else 0) | |
| for o, a in zip(outs, adv): | |
| if o: | |
| for c in o[2]: | |
| sh.add(color, _poly_d(np.stack([x0 + c[:, 0] * k, y + (size - o[1] * k) / 2 + c[:, 1] * k], 1)), max(4, round(0.8 * sh.geo['line']))) | |
| x0 += a | |
| return total | |
| def label(sh: Sheet, text, x_u, y_u, color, size=TILE, align='left'): | |
| """A label: Daemon tiles when Daemon has every letter, else the text's own script in outline. -> width in u""" | |
| if not text: return 0.0 | |
| if daemon_ok(text): | |
| sh.component('Word', text=text, x=x_u, y=y_u, size=size, color=color, align=align) | |
| return word_w(len(text), size) | |
| return script_label(sh, text, sh.px(x_u), sh.px(y_u), sh.px(size), color, align) / sh.k | |
| def draw_dial(sh: Sheet, d: dict, ox, oy, o: MapOptions, path: str): | |
| """One function. Hub: its node shape (the root holds 灵). Rings outward: its statements, what they contain, and | |
| so on. Outer ring: its name.""" | |
| f = d['fn']; cx, cy = sh.px(ox + d['x']), sh.px(oy + d['y']); R = sh.px(d['R']); hub = sh.px(d['hub']); band = sh.px(d['band']) | |
| col = f.cat; line = sh.geo['line'] | |
| sh.add(col, lt._circle_d(round(cx), round(cy), round(R)), 2 * line) # bezel | |
| sh.add(col, lt._circle_d(round(cx), round(cy), round(R - sh.px(BEZEL) * 0.75))) | |
| r_in = sh.px(d['name_r0']); ring_name(sh, cx, cy, r_in, r_in + sh.px(NAME_BAND), f.name, col) | |
| sh.add(col, lt._circle_d(round(cx), round(cy), round(r_in))) | |
| sh.add(col, lt._circle_d(round(cx), round(cy), round(hub))) | |
| if f.kind == 'entry': | |
| g = json.loads((HERE / 'hub_glyph.json').read_text(encoding='utf-8')); s = 1.25 * hub | |
| for c in g['contours']: sh.add('mod', _poly_d([(cx + x * s, cy + y * s) for x, y in c]), max(4, round(0.8 * line))) | |
| else: | |
| sh.add(col, lt._node(SHAPE[col], cx, cy, 0.6 * hub)) | |
| def cells(nodes, a0, a1, level): | |
| if not nodes or level >= d['levels']: return | |
| wts = [math.sqrt(n.tokens) for n in nodes]; tot = sum(wts); a = a0 | |
| r0 = hub + level * band; r1 = r0 + band | |
| for n, wt in zip(nodes, wts): | |
| b = a + (a1 - a0) * wt / tot | |
| if (b - a) * r0 >= 2.2 * line: | |
| pts = _sector_pts(cx, cy, r0, r1, a, b) | |
| sh.region(pts, n.cat, density_step(n.tokens, sh.nsteps)) | |
| sh.groups.append(({'data-ling': '%s:%d-%d' % (path, n.s, n.e), 'data-kind': n.kind, 'data-name': n.name[:60]}, n.cat, line, _poly_d(pts))) | |
| cells(n.kids, a, b, level + 1) | |
| a = b | |
| cells(f.kids, 0.0, 2 * math.pi, 0) | |
| return cx, cy, R | |
| def _clip_segment(p, q, circles, skip, rects=()): | |
| """Parts of the segment p-q outside every circle and rectangle (so a connector seems to pass behind them).""" | |
| p, q = np.array(p, float), np.array(q, float); dq = q - p; L2 = float(dq @ dq) | |
| if L2 == 0: return [] | |
| cuts = [] | |
| for x0, y0, x1, y1 in rects: # slab test | |
| t0, t1 = 0.0, 1.0 | |
| for a, d, lo, hi in ((p[0], dq[0], x0, x1), (p[1], dq[1], y0, y1)): | |
| if abs(d) < 1e-9: | |
| if a < lo or a > hi: t0, t1 = 1.0, 0.0 | |
| else: | |
| ta, tb = sorted(((lo - a) / d, (hi - a) / d)); t0, t1 = max(t0, ta), min(t1, tb) | |
| if t1 > t0: cuts.append((t0, t1)) | |
| for i, (cx, cy, r) in enumerate(circles): | |
| if i in skip: continue | |
| f = p - (cx, cy); b = 2 * float(f @ dq); c = float(f @ f) - (r * 1.03) ** 2; disc = b * b - 4 * L2 * c | |
| if disc <= 0: continue | |
| s = math.sqrt(disc); t0, t1 = (-b - s) / (2 * L2), (-b + s) / (2 * L2) | |
| if t1 > 0 and t0 < 1: cuts.append((max(0.0, t0), min(1.0, t1))) | |
| cuts.sort(); out = []; t = 0.0 | |
| for a, b in cuts: | |
| if a > t: out.append((p + dq * t, p + dq * a)) | |
| t = max(t, b) | |
| if t < 1: out.append((p + dq * t, q)) | |
| return out | |
| def render(files: dict, model: dict, lay: dict, o: MapOptions, theme: str, params: dict, bundle_meta: dict) -> str: | |
| geo = lh.geometry(lh.Options(dpi=o.dpi, line_mm=o.line_mm, spacing_mm=o.spacing_mm, quality=o.quality)); u = geo['u'] | |
| geo['mults'] = [5, 3, 2, 1]; geo['cover'] = [1 - (1 - geo['line'] / (geo['base2'] * m)) ** 2 for m in geo['mults']] # cells are small: no very open hatch | |
| paper = sizes.size_mm(o.paper, 'portrait') if (o.paper in sizes.PAPERS or o.paper in sizes.SCREENS) else None | |
| if paper: | |
| pw, ph = (max(paper), min(paper)) if lay['landscape'] else (min(paper), max(paper)) | |
| K = min((pw - 2 * o.margin_mm) / lay['W'], (ph - 2 * o.margin_mm) / lay['H']) | |
| else: # as large as print-size lettering needs: 16u tile -> 5.85 mm | |
| K = 5.85 / TILE; pw, ph = lay['W'] * K + 2 * o.margin_mm, lay['H'] * K + 2 * o.margin_mm | |
| pxu = K * u; W_u, H_u = pw / K, ph / K | |
| ox0, oy0 = (W_u - lay['W']) / 2, (H_u - lay['H']) / 2 | |
| sh = Sheet(W_u, H_u, pxu, geo, theme); th = THEMES[theme]; line = geo['line'] | |
| centre = {}; circles = []; rect_of = {}; Z = lay['zoom']; sh.k = pxu * Z | |
| ox0, oy0 = (ox0 + (lay['W'] - lay['map_w'] * Z) / 2) / Z, oy0 / Z # the map's origin, in map units | |
| # ---- panels | |
| for p in lay['panels']: | |
| m = p['mod']; x, y = ox0 + p['x'], oy0 + p['y'] | |
| title = m.name if daemon_ok(m.name) else '' | |
| sh.component('Frame', x=x, y=y, w=p['w'], h=p['h'], color='mod', title=title[:22], titleSize=TILE, titleColor='mod') | |
| if not title: script_label(sh, m.name, sh.px(x + 40), sh.px(y - TILE * 1.6), sh.px(TILE * 1.3), 'mod') | |
| rect_of[id(m)] = (sh.px(x), sh.px(y), sh.px(x + p['w']), sh.px(y + p['h'])) | |
| dx, dy = x + p['pad'] + (p['w'] - 2 * p['pad'] - p['dial_w']) / 2 - p['x0'], y + p['pad'] - p['y0'] | |
| for d in p['dials']: | |
| cx, cy, R = draw_dial(sh, d, dx, dy, o, m.path) | |
| centre[id(d['fn'])] = len(circles); circles.append((cx, cy, R)) | |
| # the table of globals and types | |
| ty = y + p['pad'] + p['dial_h'] + (TILE if p['dials'] else 0) | |
| for i, (kind, node, name, val) in enumerate(p['cells']): | |
| cxu = x + p['pad'] + (i % p['ncol']) * p['colw']; cyu = ty + (i // p['ncol']) * TILE * 1.6 | |
| sh.component('Node', kind='circle' if kind == 'bind' else 'square', x=cxu + TILE * 0.55, y=cyu + TILE / 2, r=TILE * 0.5, | |
| color='bind', set='grid' if kind == 'type' else 'cross') | |
| w = label(sh, name, cxu + TILE * 1.5, cyu, 'bind') | |
| if val: label(sh, val, cxu + TILE * 1.5 + w + TILE * 0.7, cyu, 'lit' if kind == 'bind' else 'ink') | |
| sh.groups.append(({'data-ling': '%s:%d-%d' % (m.path, node.s, node.e), 'data-kind': node.kind, 'data-name': node.name[:60]}, 'bind', line, | |
| 'M%d %dh%d' % (sh.px(cxu), sh.px(cyu + TILE * 1.25), sh.px(TILE * 1.1)))) | |
| # ---- connectors: calls between functions, imports between files | |
| if o.edges: | |
| for a, b, n in model['edges']: | |
| if id(a) not in centre or id(b) not in centre: continue | |
| ia, ib = centre[id(a)], centre[id(b)]; (ax, ay, ar), (bx, by, br) = circles[ia], circles[ib] | |
| dx, dy = bx - ax, by - ay; dist = math.hypot(dx, dy) | |
| if dist <= ar + br: continue | |
| ux, uy = dx / dist, dy / dist; p0 = (ax + ux * ar, ay + uy * ar); p1 = (bx - ux * br, by - uy * br) | |
| ma, mb = a.extra['module'], b.extra['module'] | |
| behind = [r for k, r in rect_of.items() if k not in (id(ma), id(mb))] # a call between files passes behind the other files | |
| wpx = max(line, sh.px(1.5 if n > 1 else 0.9) / math.sqrt(Z)) | |
| for s0, s1 in _clip_segment(p0, p1, circles, {ia, ib}, behind): | |
| sh.add('call', 'M%d %dL%d %d' % (round(s0[0]), round(s0[1]), round(s1[0]), round(s1[1])), wpx) | |
| t = sh.px(3.0) / math.sqrt(Z); ex, ey = p1 # the call's diamond sits at the callee | |
| sh.add('call', _poly_d([(ex, ey), (ex - ux * t - uy * t * 0.6, ey - uy * t + ux * t * 0.6), (ex - ux * 2 * t, ey - uy * 2 * t), | |
| (ex - ux * t + uy * t * 0.6, ey - uy * t - ux * t * 0.6)]), max(line, sh.px(0.9) / math.sqrt(Z))) | |
| for a, b in model['imports']: # an import joins two files across the gutter between them | |
| ra, rb = rect_of[id(a)], rect_of[id(b)] | |
| A = ((ra[0] + ra[2]) / 2, (ra[1] + ra[3]) / 2); B = ((rb[0] + rb[2]) / 2, (rb[1] + rb[3]) / 2) | |
| for s0, s1 in _clip_segment(A, B, [], set(), list(rect_of.values())): | |
| sh.add('mod', 'M%d %dL%d %d' % (round(s0[0]), round(s0[1]), round(s1[0]), round(s1[1])), sh.px(2.0)) | |
| for e in (s0, s1): sh.add('mod', _poly_d([(e[0] - sh.px(2.5), e[1] - sh.px(2.5)), (e[0] + sh.px(2.5), e[1] - sh.px(2.5)), | |
| (e[0] + sh.px(2.5), e[1] + sh.px(2.5)), (e[0] - sh.px(2.5), e[1] + sh.px(2.5))]), sh.px(1.0)) | |
| # ---- foot: legend with each colour's hatch at its densities, the share dial, the digest, the title plate | |
| sh.k = pxu; ox0, oy0 = (W_u - lay['W']) / 2, (H_u - lay['H']) / 2 | |
| ft = lay['foot']; mode = lay['mode']; S = TILE; M = ft['M']; G = ft['G']; FW = lay['W']; nst = len(geo['mults']); rowA = ft['rowA'] | |
| fx = ox0; fy = oy0 + lay['H'] - ft[mode]['h'] + 24 | |
| label(sh, 'tokens ' + ' '.join('%d' % t for t in DENSITY_TOKENS[:nst - 1]) + ' +', fx + M, fy, 'ink', S * 0.75) # what each density stands for | |
| fy += ft['key_h'] | |
| if mode == 'row': | |
| leg_w = FW - 3 * G - rowA - ft['grid_w'] - ft['plate_w']; dx_ = fx + leg_w + G; gx = dx_ + rowA + G; gy = fy; gw = ft['grid_w']; gh = rowA | |
| px_, py_ = fx + FW - ft['plate_w'], fy + rowA - ft['plate_h'] | |
| else: | |
| leg_w = FW - G - rowA; dx_ = fx + leg_w + G; gx = fx; gy = fy + rowA + G; gw = FW - G - ft['plate_w']; gh = ft['rowB'] | |
| px_, py_ = fx + FW - ft['plate_w'], gy + ft['rowB'] - ft['plate_h'] | |
| sh.component('Frame', x=fx, y=fy, w=leg_w, h=rowA, color='ink') | |
| cols = [KEYS[:4], KEYS[4:]]; cw = (leg_w - 2 * M - 28) / 2; labw = ft['labw'] | |
| sw = max(10.0, min(34.0, (cw - labw - 12 - (nst - 1) * 4) / nst)); x_off = (cw - (labw + 12 + nst * (sw + 4) - 4)) / 2 | |
| for ci, keys in enumerate(cols): | |
| cxu = fx + M + ci * (cw + 28) + x_off | |
| sh.component('Legend', x=cxu, y=fy + M, size=S, keys=keys) | |
| for r, k in enumerate(keys): | |
| for s_i in range(nst): | |
| x0, y0 = sh.px(cxu + labw + 12 + s_i * (sw + 4)), sh.px(fy + M + r * S * 1.7); w, h = sh.px(sw), sh.px(S) | |
| pts = [(x0, y0), (x0 + w, y0), (x0 + w, y0 + h), (x0, y0 + h)] | |
| sh.region(pts, k, s_i); sh.add(k, _poly_d(pts), max(line, sh.px(0.8))) | |
| dcx, dcy = dx_ + rowA / 2, fy + rowA / 2; Rd = rowA / 2 - 14 | |
| sh.component('Frame', x=dx_, y=fy, w=rowA, h=rowA, color='ink') | |
| ud = max(0.5, Rd / 150); rc = Rd - 9 * ud - 14 - 2.5; a = 0.0; rh = rc - 16 - 2.5 | |
| sh.component('Dial', cx=dcx, cy=dcy, r=Rd, diamonds=False, ticks=0, rings=[{'text': 'ml dsa 87 ', 'cells': 12, 'band': 14, 'color': 'ink'}], | |
| hub={'color': 'mod', 'r': rh}) | |
| g = json.loads((HERE / 'hub_glyph.json').read_text(encoding='utf-8')) | |
| for cc in g['contours']: sh.add('mod', _poly_d([(sh.px(dcx + x * rh * 1.25), sh.px(dcy + y * rh * 1.25)) for x, y in cc]), max(4, round(0.8 * line))) | |
| for k in KEYS: | |
| v = model['share'][k] | |
| if v <= 0.004: continue | |
| b = a + 2 * math.pi * v; pts = _sector_pts(sh.px(dcx), sh.px(dcy), sh.px(rc - 16), sh.px(rc), a, b) | |
| sh.region(pts, k, max(0, nst - 2)); sh.add(k, _poly_d(pts)); a = b | |
| sh.component('Frame', x=gx, y=gy, w=gw, h=gh, color='ink') | |
| hexd = bundle_meta['sha3_256'].upper(); grid_h = 4 * S + 3 * S * 0.14 | |
| for q in range(4): | |
| sh.component('Word', text=hexd[q * 16:q * 16 + 16], x=gx + (gw - word_w(16, S)) / 2, y=gy + (gh - grid_h) / 2 + q * S * 1.14, size=S, color='bind') | |
| sh.component('TitlePlate', x=px_, y=py_, title=ft['title'], subtitle=ft['sub'], rev=ft['rev'], size=ft['ts'], small=S) | |
| c = model['counts'] | |
| # ---- assemble | |
| hatch, st = lh.hatch_picture(sh.raster, params, theme, geo) | |
| hatch = re.sub(r'<g id="\w+-outline">.*?</g>', '', hatch, flags=re.S) | |
| hatch = re.sub(r'id="(\w+?)(-hatch)?"', lambda mm: 'id="hatch-%s%s"' % (mm.group(1), mm.group(2) or ''), hatch) | |
| comp = ''.join('<g transform="scale(%.5f)">%s</g>' % (k, lh.LingJS.components(calls)) for k, calls in sh.calls.items()) | |
| comp = re.sub(r'class="lg lg-(\w+)"', lambda mm: 'stroke="%s"' % th.get(mm.group(1), th['ink']), comp) | |
| body = ['<g id="hatch" stroke-width="%d">%s</g>' % (line, hatch)] | |
| for k in list(KEYS) + ['ink']: | |
| if sh.lines[k]: | |
| body.append('<g id="line-%s" stroke="%s">%s</g>' % (k, th[k], ''.join( | |
| '<path d="%s" stroke-width="%d"/>' % (''.join(ds), w) for w, ds in sorted(sh.lines[k].items())))) | |
| body.append('<g id="structure">%s</g>' % ''.join( | |
| '<path %s stroke="%s" stroke-width="%d" d="%s"/>' % (' '.join('%s="%s"' % (a, lh._esc(v).replace('"', '"')) for a, v in at.items()), th[col], w, d) | |
| for at, col, w, d in sh.groups)) | |
| body.append('<g id="instruments">%s</g>' % comp) | |
| cap_mm = float(0.6 * TILE * K); pw, ph = float(pw), float(ph) | |
| meta = dict(bundle_meta, sheet={'size_mm': [round(pw, 1), round(ph, 1)], 'ground': theme, 'user_units': 'device pixels at %d dpi' % o.dpi, | |
| 'label_cap_height_mm': round(cap_mm, 2), 'print_floor_mm': 3.5, 'labels_meet_print_floor': bool(cap_mm >= 3.5), | |
| 'density': 'hatch density = tokens in the cell: up to %s, then more' % ', '.join(map(str, DENSITY_TOKENS[:nst - 1])), | |
| 'counts': model['counts'], 'share': {k: round(v, 4) for k, v in model['share'].items()}, | |
| 'hatch_runs': st.get('hatch_runs', 0), 'options': asdict(o) | {'key_phrase': bool(o.key_phrase)}}) | |
| mj = json.dumps(meta, ensure_ascii=True).replace('&', '&').replace('<', '<').replace('>', '>') | |
| ground = '<rect id="ground" width="%d" height="%d" fill="%s" stroke="none"/>\n' % (sh.Wpx, sh.Hpx, th['ground']) if theme == 'black' and o.black_ground else '' | |
| desc = ('A functional map of the Ling program %s: %d files, %d functions, %d globals, %d calls between functions. Each framed panel is a ' | |
| 'file. Each dial is a function: its rings read outward through its statements and what they contain, coloured by what the code does ' | |
| '(red hardware, orange media and literals, yellow data, green control, blue calls and I/O, indigo computation, violet modules), ' | |
| 'hatched more densely the more code a cell holds, with its name round the outside. Tiles list globals and types. Lines join callers ' | |
| 'to the functions they call. The program itself is stored in this file.' % ( | |
| lh._esc(bundle_meta['project']), c['files'], c['functions'], c['globals'], c['calls'])) | |
| return ('<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="%gmm" height="%gmm" viewBox="0 0 %d %d" fill="none" ' | |
| 'stroke-linecap="butt" stroke-linejoin="miter" role="img" aria-labelledby="t d">\n<title id="t">%s: Ling map</title>\n<desc id="d">%s</desc>\n' | |
| '<metadata id="ling-source">%s</metadata>\n%s%s\n</svg>\n' % (round(pw, 2), round(ph, 2), sh.Wpx, sh.Hpx, lh._esc(bundle_meta['project']), desc, mj, ground, '\n'.join(body))) | |
| # =========================================================================== Ling -> SVG | |
| def bundle(files: dict) -> bytes: | |
| """The exact bytes the signature covers: every file's path and content, length-prefixed, in path order.""" | |
| out = io.BytesIO(); out.write(FORMAT.encode() + b'\n') | |
| for path, raw in sorted(files.items()): | |
| p = path.encode('utf-8'); out.write(len(p).to_bytes(4, 'big') + p + len(raw).to_bytes(8, 'big') + raw) | |
| return out.getvalue() | |
| def source_meta(files: dict, params: dict, project: str = '') -> dict: | |
| """What an SVG stores so it can give its program back: the files, their digest, the public key and the signature.""" | |
| enc = [] | |
| for path, raw in sorted(files.items()): | |
| try: enc.append({'path': path, 'text': raw.decode('utf-8')}) | |
| except UnicodeDecodeError: enc.append({'path': path, 'base64': base64.b64encode(raw).decode()}) | |
| return {'format': FORMAT, 'project': project or (Path(next(iter(files))).stem if len(files) == 1 else 'project'), 'files': enc, | |
| 'algorithm': params['algorithm'], 'key': params['key'], 'context': params['context'], 'sha3_256': params['sha3_256'], | |
| 'public_key': params['public_key'], 'signature': params['signature'], 'hues': params['hues'], | |
| 'signed': 'the bundle: "%s" newline, then for each file in path order: 4-byte path length, path, 8-byte length, bytes' % FORMAT} | |
| def program_to_svg(files: dict, o: MapOptions = None, grounds=('white', 'black'), project: str = '', progress=None) -> dict: | |
| """{path: bytes} -> {ground: svg}. The same signature is used for every ground.""" | |
| o = o or MapOptions(); say = progress or (lambda m: None) | |
| if not files: raise ValueError('No .ling files found.') | |
| say('parsing'); model = analyse(files) | |
| if not model['modules']: raise ValueError('No Ling programs found (only lexicon tables).') | |
| say('signing'); msg = bundle(files); params = lh.sign(msg, lh.Options(key_phrase=o.key_phrase)) | |
| meta = source_meta(files, params, project) | |
| say('laying out'); lay = layout(model, o, meta['project']) | |
| out = {} | |
| for g in grounds: | |
| say('drawing on ' + g); out[g] = render(files, model, lay, o, g, params, meta) | |
| return out | |
| # =========================================================================== SVG -> Ling | |
| def embedded_program(svg: str): | |
| """The program stored in a map, with its signature checked. -> ({path: bytes}, info) or None if the SVG holds none.""" | |
| try: root = ET.fromstring(svg.encode('utf-8')) | |
| except ET.ParseError: return None | |
| md = next((e for e in root.iter() if e.tag.endswith('metadata') and e.get('id') == 'ling-source'), None) | |
| if md is None or not (md.text or '').strip(): return None | |
| meta = json.loads(md.text) | |
| if meta.get('format') != FORMAT: return None | |
| files = {f['path']: (f['text'].encode('utf-8') if 'text' in f else base64.b64decode(f['base64'])) for f in meta['files']} | |
| msg = bundle(files); ok = False; why = '' | |
| try: | |
| pk, sig, ctx = bytes.fromhex(meta['public_key']), bytes.fromhex(meta['signature']), meta['context'].encode() | |
| try: | |
| from cryptography.hazmat.primitives.asymmetric import mldsa | |
| mldsa.MLDSA87PublicKey.from_public_bytes(pk).verify(sig, msg, ctx); ok = True | |
| except ImportError: | |
| from dilithium_py.ml_dsa import ML_DSA_87 | |
| ok = bool(ML_DSA_87.verify(pk, msg, sig, ctx=ctx)) | |
| except Exception as ex: | |
| why = type(ex).__name__ | |
| return files, {'project': meta.get('project', 'project'), 'signature_valid': ok, 'why': why, | |
| 'sha3_256_matches': hashlib.sha3_256(msg).hexdigest() == meta.get('sha3_256')} | |
| def sheet_info(svg: str) -> dict: | |
| """What a map says about its own sheet: size, lettering height against the print floor, counts.""" | |
| md = next(e for e in ET.fromstring(svg.encode('utf-8')).iter() if e.tag.endswith('metadata') and e.get('id') == 'ling-source') | |
| return json.loads(md.text)['sheet'] | |
| _NUM = re.compile(r'[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?') | |
| _CMD = re.compile(r'([MmLlHhVvCcSsQqTtAaZz])|([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)') | |
| def _mat(tr: str): | |
| """An SVG transform attribute -> 3x3 matrix.""" | |
| M = np.eye(3) | |
| for name, args in re.findall(r'(\w+)\s*\(([^)]*)\)', tr or ''): | |
| v = [float(x) for x in _NUM.findall(args)]; T = np.eye(3) | |
| if name == 'translate': T[0, 2] = v[0]; T[1, 2] = v[1] if len(v) > 1 else 0 | |
| elif name == 'scale': T[0, 0] = v[0]; T[1, 1] = v[1] if len(v) > 1 else v[0] | |
| elif name == 'rotate': | |
| a = math.radians(v[0]); c, s = math.cos(a), math.sin(a); T[:2, :2] = [[c, -s], [s, c]] | |
| if len(v) == 3: | |
| P = np.eye(3); P[0, 2], P[1, 2] = v[1], v[2]; Q = np.eye(3); Q[0, 2], Q[1, 2] = -v[1], -v[2]; T = P @ T @ Q | |
| elif name == 'matrix' and len(v) == 6: T[0] = [v[0], v[2], v[4]]; T[1] = [v[1], v[3], v[5]] | |
| elif name == 'skewX': T[0, 1] = math.tan(math.radians(v[0])) | |
| elif name == 'skewY': T[1, 0] = math.tan(math.radians(v[0])) | |
| M = M @ T | |
| return M | |
| def _arc_pts(p0, rx, ry, phi, large, sweep, p1, n=None): | |
| """Endpoint arc -> points (SVG implementation notes, F.6.5).""" | |
| if rx == 0 or ry == 0: return [p1] | |
| cp, sp = math.cos(phi), math.sin(phi); dx, dy = (p0[0] - p1[0]) / 2, (p0[1] - p1[1]) / 2 | |
| x1, y1 = cp * dx + sp * dy, -sp * dx + cp * dy; rx, ry = abs(rx), abs(ry) | |
| lam = x1 * x1 / (rx * rx) + y1 * y1 / (ry * ry) | |
| if lam > 1: rx *= math.sqrt(lam); ry *= math.sqrt(lam) | |
| num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1; den = rx * rx * y1 * y1 + ry * ry * x1 * x1 | |
| co = (-1 if large == sweep else 1) * math.sqrt(max(0, num / den)) if den else 0 | |
| cxp, cyp = co * rx * y1 / ry, -co * ry * x1 / rx | |
| cx, cy = cp * cxp - sp * cyp + (p0[0] + p1[0]) / 2, sp * cxp + cp * cyp + (p0[1] + p1[1]) / 2 | |
| a0 = math.atan2((y1 - cyp) / ry, (x1 - cxp) / rx); a1 = math.atan2((-y1 - cyp) / ry, (-x1 - cxp) / rx); da = a1 - a0 | |
| if sweep and da < 0: da += 2 * math.pi | |
| if not sweep and da > 0: da -= 2 * math.pi | |
| n = n or max(6, int(abs(da) / math.radians(3))) | |
| return [(cx + rx * math.cos(a0 + da * i / n) * cp - ry * math.sin(a0 + da * i / n) * sp, | |
| cy + rx * math.cos(a0 + da * i / n) * sp + ry * math.sin(a0 + da * i / n) * cp) for i in range(1, n + 1)] | |
| def path_polylines(d: str) -> list: | |
| """SVG path data -> [(points, closed)]. Curves are flattened.""" | |
| toks = [(m.group(1), m.group(2)) for m in _CMD.finditer(d)]; i = 0; out = []; pts = []; cur = (0.0, 0.0); start = cur; cmd = ''; lastc = None | |
| def num(): | |
| nonlocal i | |
| v = float(toks[i][1]); i += 1; return v | |
| def flush(closed=False): | |
| nonlocal pts | |
| if len(pts) > 1: out.append((pts, closed)) | |
| pts = [] | |
| while i < len(toks): | |
| if toks[i][0]: cmd = toks[i][0]; i += 1 | |
| elif not cmd: i += 1; continue | |
| C = cmd.upper(); rel = cmd.islower() and C != 'Z'; bx, by = (cur if rel else (0.0, 0.0)) | |
| if C == 'Z': | |
| if pts: pts.append(start); flush(True) | |
| cur = start; lastc = None; cmd = ''; continue | |
| try: | |
| if C == 'M': | |
| flush(); cur = (bx + num(), by + num()); start = cur; pts = [cur]; cmd = 'l' if rel else 'L'; lastc = None; continue | |
| if not pts: pts = [cur] | |
| if C == 'L': cur = (bx + num(), by + num()); pts.append(cur) | |
| elif C == 'H': cur = ((cur[0] if rel else 0) + num(), cur[1]); pts.append(cur) | |
| elif C == 'V': cur = (cur[0], (cur[1] if rel else 0) + num()); pts.append(cur) | |
| elif C in ('C', 'S'): | |
| if C == 'C': c1 = (bx + num(), by + num()) | |
| else: c1 = (2 * cur[0] - lastc[0], 2 * cur[1] - lastc[1]) if lastc and lastc[2] == 'C' else cur | |
| c2 = (bx + num(), by + num()); p3 = (bx + num(), by + num()); p0 = cur | |
| for q in np.linspace(0, 1, 25)[1:]: | |
| pts.append(tuple((1 - q) ** 3 * p0[k] + 3 * (1 - q) ** 2 * q * c1[k] + 3 * (1 - q) * q * q * c2[k] + q ** 3 * p3[k] for k in (0, 1))) | |
| cur = p3; lastc = (c2[0], c2[1], 'C'); continue | |
| elif C in ('Q', 'T'): | |
| if C == 'Q': c1 = (bx + num(), by + num()) | |
| else: c1 = (2 * cur[0] - lastc[0], 2 * cur[1] - lastc[1]) if lastc and lastc[2] == 'Q' else cur | |
| p2 = (bx + num(), by + num()); p0 = cur | |
| for q in np.linspace(0, 1, 17)[1:]: | |
| pts.append(tuple((1 - q) ** 2 * p0[k] + 2 * (1 - q) * q * c1[k] + q * q * p2[k] for k in (0, 1))) | |
| cur = p2; lastc = (c1[0], c1[1], 'Q'); continue | |
| elif C == 'A': | |
| rx, ry, rot, large, sweep = num(), num(), num(), num(), num(); p1 = (bx + num(), by + num()) | |
| pts += _arc_pts(cur, rx, ry, math.radians(rot), bool(large), bool(sweep), p1); cur = p1 | |
| lastc = None | |
| except (IndexError, TypeError, ValueError): | |
| break | |
| flush() | |
| return out | |
| def flatten_svg(svg: str) -> dict: | |
| """Any SVG -> its line work: {'w', 'h', 'ground', 'shapes': [(kind, points, colour, width)], 'skipped': {...}}. | |
| kind is line (a stroked polyline), fill (a filled outline), rect or circle (filled, axis-aligned).""" | |
| root = ET.fromstring(svg.encode('utf-8')); tag = lambda e: e.tag.rsplit('}', 1)[-1] | |
| vb = [float(x) for x in _NUM.findall(root.get('viewBox') or '')] | |
| def length(v, default): | |
| m = _NUM.match(v or '') | |
| return float(m.group(0)) * {'mm': 96 / 25.4, 'cm': 960 / 25.4, 'in': 96, 'pt': 96 / 72}.get((v or '')[m.end():].strip(), 1) if m else default | |
| if len(vb) != 4: vb = [0, 0, length(root.get('width'), 800), length(root.get('height'), 600)] | |
| shapes = []; skipped = {}; ground = None | |
| def style(e, inh): | |
| st = dict(inh) | |
| for k in ('stroke', 'fill', 'stroke-width'): | |
| if e.get(k) is not None: st[k] = e.get(k) | |
| for part in (e.get('style') or '').split(';'): | |
| if ':' in part: | |
| k, v = part.split(':', 1) | |
| if k.strip() in ('stroke', 'fill', 'stroke-width', 'display'): st[k.strip()] = v.strip() | |
| return st | |
| def emit(subpaths, st, M, exact=None): | |
| """subpaths: [points]. A fill covers all of them together (so holes stay holes); each is stroked on its own.""" | |
| sc = math.sqrt(abs(np.linalg.det(M[:2, :2]))); stroke, fill = st.get('stroke', 'none'), st.get('fill', '#000000') | |
| Ps = [(np.c_[np.array(p, float), np.ones(len(p))] @ M.T)[:, :2] for p in subpaths if len(p) > 1] | |
| if not Ps: return | |
| if fill not in ('none', 'transparent', '') and not fill.startswith('url') and any(len(P) > 2 for P in Ps): | |
| upright = abs(M[0, 1]) < 1e-9 and abs(M[1, 0]) < 1e-9 | |
| if exact and exact[0] == 'rect' and upright: # Ling can fill these two exactly | |
| shapes.append(('rect', np.array([Ps[0].min(0), Ps[0].max(0) - Ps[0].min(0)]), _hex(fill), 0.0)) | |
| elif exact and exact[0] == 'circle' and upright and abs(abs(M[0, 0]) - abs(M[1, 1])) < 1e-9: | |
| shapes.append(('circle', np.array([(np.array([exact[1], exact[2], 1.0]) @ M.T)[:2], [exact[3] * sc, 0]]), _hex(fill), 0.0)) | |
| else: shapes.append(('fill', [P for P in Ps if len(P) > 2], _hex(fill), sc)) | |
| if stroke not in ('none', 'transparent', '') and not stroke.startswith('url'): | |
| w = float((_NUM.findall(str(st.get('stroke-width', '1'))) or ['1'])[0]) * sc | |
| for P in Ps: shapes.append(('line', P, _hex(stroke), w)) | |
| def walk(e, M, st): | |
| nonlocal ground | |
| t = tag(e) | |
| if t in ('defs', 'metadata', 'title', 'desc', 'style', 'clipPath', 'mask', 'symbol', 'pattern', 'linearGradient', 'radialGradient', 'filter'): return | |
| M = M @ _mat(e.get('transform')); st = style(e, st) | |
| if st.get('display') == 'none': return | |
| f = lambda k, dflt=0.0: float((_NUM.findall(e.get(k) or '') or [dflt])[0]) | |
| if t == 'path': | |
| emit([pts for pts, closed in path_polylines(e.get('d') or '')], st, M) | |
| elif t == 'rect': | |
| x, y, w, h = f('x'), f('y'), f('width'), f('height') | |
| if ground is None and not shapes and w >= vb[2] * 0.98 and h >= vb[3] * 0.98 and st.get('fill', 'none') not in ('none', ''): | |
| ground = _hex(st['fill']); return | |
| emit([[(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]], st, M, ('rect',)) | |
| elif t in ('circle', 'ellipse'): | |
| cx, cy = f('cx'), f('cy'); rx = f('r') if t == 'circle' else f('rx'); ry = f('r') if t == 'circle' else f('ry') | |
| n = max(24, int(8 * math.sqrt(max(rx, ry)) + 16)) | |
| emit([[(cx + rx * math.cos(2 * math.pi * i / n), cy + ry * math.sin(2 * math.pi * i / n)) for i in range(n + 1)]], st, M, | |
| ('circle', cx, cy, rx) if rx == ry else None) | |
| elif t == 'line': emit([[(f('x1'), f('y1')), (f('x2'), f('y2'))]], dict(st, fill='none'), M) | |
| elif t in ('polyline', 'polygon'): | |
| v = [float(x) for x in _NUM.findall(e.get('points') or '')]; pts = list(zip(v[0::2], v[1::2])) | |
| if len(pts) > 1: emit([pts + (pts[:1] if t == 'polygon' else [])], st, M) | |
| elif t in ('text', 'image', 'use', 'foreignObject'): skipped[t] = skipped.get(t, 0) + 1; return | |
| for ch in e: walk(ch, M, st) | |
| base = np.eye(3); base[0, 2], base[1, 2] = -vb[0], -vb[1] | |
| walk(root, base, {}) | |
| return {'w': vb[2], 'h': vb[3], 'ground': ground, 'shapes': shapes, 'skipped': skipped} | |
| _NAMED = {'black': '#000000', 'white': '#ffffff', 'red': '#ff0000', 'green': '#008000', 'blue': '#0000ff', 'yellow': '#ffff00', 'gray': '#808080', | |
| 'grey': '#808080', 'orange': '#ffa500', 'purple': '#800080', 'currentColor': '#000000'} | |
| def _hex(c: str) -> str: | |
| c = c.strip() | |
| if c in _NAMED: return _NAMED[c] | |
| if re.fullmatch(r'#[0-9a-fA-F]{3}', c): return '#' + ''.join(ch * 2 for ch in c[1:]).lower() | |
| if re.fullmatch(r'#[0-9a-fA-F]{6}', c): return c.lower() | |
| m = re.match(r'rgba?\(([^)]+)\)', c) | |
| if m: | |
| v = [float(x) for x in _NUM.findall(m.group(1))][:3] | |
| return '#%02x%02x%02x' % tuple(int(round(min(255, max(0, x)))) for x in v) | |
| return '#000000' | |
| def svg_to_drawing_program(svg: str, name: str = 'drawing', size: float = 2000.0, per_fn: int = 400, fills: str = 'hatch') -> tuple: | |
| """Any SVG -> (Ling source, notes): a program that redraws the SVG with svg_begin, svg_line, svg_polyline, | |
| svg_rect, svg_circle and svg_end. One function per colour and weight, in pieces small enough to read. | |
| Ling can fill only rectangles and circles, so other filled shapes are drawn the Ling way: outlined, and | |
| crosshatched in their colour (fills='hatch') or left open (fills='outline').""" | |
| fl = flatten_svg(svg); k = size / max(fl['w'], fl['h']); W, H = fl['w'] * k, fl['h'] * k | |
| layers = {}; nfill = 0; solids = [] | |
| for kind, P, col, w in fl['shapes']: | |
| if kind in ('rect', 'circle'): solids.append((kind, P * k, col)); continue | |
| if kind == 'fill': | |
| nfill += 1; cs = [c * k for c in P]; hair = round(max(0.6, size / 1600), 1) | |
| layers.setdefault((col, hair), []).extend(cs) | |
| if fills == 'hatch': | |
| for ang in (45, -45): | |
| layers[(col, hair)] += [np.array(seg) for seg in lt._hatch_polys(cs, ang, size / 220)] | |
| continue | |
| layers.setdefault((col, round(max(0.1, w * k), 1)), []).append(P * k) | |
| num = lambda v: ('%.2f' % v).rstrip('0').rstrip('.') | |
| out = ['// %s.ling: redraws %s.svg with Ling\'s SVG builtins, as %s_redrawn.svg.' % (name, name, name), | |
| '// Made by ling_map.py. %d strokes in %d layers, on a %s x %s canvas.' % (sum(len(v) for v in layers.values()), len(layers), num(W), num(H)), ''] | |
| calls = [] | |
| for part in range(0, len(solids), per_fn): # filled rectangles and circles first, in document order | |
| fname = 'solids_%d' % (part // per_fn + 1); calls.append(fname); out.append('fn %s() {' % fname) | |
| for kind, P, col in solids[part:part + per_fn]: | |
| if kind == 'rect': out.append(' svg_rect(%s, %s, %s, %s, "%s")' % (num(P[0, 0]), num(P[0, 1]), num(P[1, 0]), num(P[1, 1]), col)) | |
| else: out.append(' svg_circle(%s, %s, %s, "%s")' % (num(P[0, 0]), num(P[0, 1]), num(P[1, 0]), col)) | |
| out += ['}', ''] | |
| for li, ((col, w), polys) in enumerate(sorted(layers.items(), key=lambda kv: -len(kv[1]))): | |
| for part in range(0, len(polys), per_fn): | |
| fname = 'layer_%d_%d' % (li + 1, part // per_fn + 1); calls.append(fname) | |
| out.append('// %s, width %s' % (col, num(w))); out.append('fn %s() {' % fname) | |
| for P in polys[part:part + per_fn]: | |
| if len(P) > 3 and np.allclose(P[0], P[-1]): P = np.vstack([P, P[1]]) # closed: run on one step so the corner joins | |
| if len(P) == 2: out.append(' svg_line(%s, %s, %s, %s, "%s", %s)' % (num(P[0, 0]), num(P[0, 1]), num(P[1, 0]), num(P[1, 1]), col, num(w))) | |
| else: out.append(' svg_polyline("%s", "%s", %s)' % (' '.join('%s,%s' % (num(x), num(y)) for x, y in P), col, num(w))) | |
| out.append('}'); out.append('') | |
| out.append('bind start = do {') | |
| out.append(' svg_begin("%s_redrawn.svg", %s, %s)' % (name, num(W), num(H))) | |
| out.append(' svg_rect(0, 0, %s, %s, "%s")' % (num(W), num(H), fl['ground'] or '#ffffff')) | |
| out += [' %s()' % c for c in calls] | |
| out += [' svg_end()', '}', ''] | |
| notes = {'strokes': sum(len(v) for v in layers.values()), 'layers': len(layers), 'functions': len(calls), 'canvas': [round(W, 1), round(H, 1)], 'filled_rects_and_circles': len(solids), | |
| 'filled_shapes_hatched' if fills == 'hatch' else 'filled_shapes_outlined': nfill, 'skipped_elements': fl['skipped']} | |
| return '\n'.join(out), notes | |
| def svg_to_ling(svg: str, name: str = 'drawing', mode: str = 'auto') -> tuple: | |
| """-> ({path: bytes}, info). mode auto: the stored program if the SVG is a map made here, else a drawing program. | |
| mode draw: always a drawing program. mode source: only the stored program.""" | |
| if mode in ('auto', 'source'): | |
| emb = embedded_program(svg) | |
| if emb: return emb[0], dict(emb[1], kind='stored program') | |
| if mode == 'source': raise ValueError('This SVG does not carry a Ling program.') | |
| src, notes = svg_to_drawing_program(svg, name) | |
| return {name + '.ling': src.encode('utf-8')}, dict(notes, kind='drawing program') | |
| # =========================================================================== command line | |
| def write_outputs(files: dict, out_dir, stem: str) -> list: | |
| out_dir = Path(out_dir); out_dir.mkdir(parents=True, exist_ok=True); made = [] | |
| for path, raw in files.items(): | |
| f = out_dir / stem / path if len(files) > 1 else out_dir / path | |
| f.parent.mkdir(parents=True, exist_ok=True); f.write_bytes(raw); made.append(str(f)) | |
| if len(files) > 1: | |
| z = out_dir / (stem + '.zip') | |
| with zipfile.ZipFile(z, 'w', zipfile.ZIP_DEFLATED) as zf: | |
| for path, raw in files.items(): zf.writestr(path, raw) | |
| made.append(str(z)) | |
| return made | |
| def main(): | |
| ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0], formatter_class=argparse.RawDescriptionHelpFormatter) | |
| ap.add_argument('inputs', nargs='+', help='.ling files, a .zip or a folder (Ling -> SVG), or .svg files (SVG -> Ling)') | |
| ap.add_argument('--out', default='ling_map_out'); ap.add_argument('--project', default='') | |
| ap.add_argument('--grounds', nargs='+', default=['white', 'black'], choices=['white', 'black']) | |
| ap.add_argument('--mode', default='auto', choices=['auto', 'draw', 'source'], help='SVG -> Ling: stored program, or a drawing program') | |
| ap.add_argument('--png-scale', type=float, default=0.0) | |
| for f in fields(MapOptions): | |
| kind = {'int': int, 'float': float, 'bool': lambda v: v.lower() in ('1', 'true', 'yes')}.get(f.type, str) | |
| ap.add_argument('--' + f.name.replace('_', '-'), type=kind, default=f.default) | |
| a = ap.parse_args(); out = Path(a.out); out.mkdir(parents=True, exist_ok=True) | |
| svgs = [p for p in a.inputs if p.lower().endswith('.svg')]; rest = [p for p in a.inputs if not p.lower().endswith('.svg')] | |
| for p in svgs: | |
| files, info = svg_to_ling(Path(p).read_text(encoding='utf-8'), Path(p).stem, a.mode) | |
| made = write_outputs(files, out, Path(p).stem) | |
| print('%s -> %s (%s)' % (p, ', '.join(made[-2:]), {k: v for k, v in info.items() if k != 'why'})) | |
| if rest: | |
| o = MapOptions(**{f.name: getattr(a, f.name) for f in fields(MapOptions)}); files = load_sources(rest) | |
| project = a.project or (Path(rest[0]).stem if len(rest) == 1 else 'project') | |
| for g, svg in program_to_svg(files, o, a.grounds, project, lambda m: print(m, flush=True)).items(): | |
| f = out / ('%s_map_%s.svg' % (project, g)); f.write_text(svg, encoding='utf-8'); print(f) | |
| if a.png_scale > 0: lt.write_png(svg, f.with_suffix('.png'), g, a.png_scale) | |
| if __name__ == '__main__': | |
| main() | |