glyph-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
glyph_cli/drawing.py ADDED
@@ -0,0 +1,202 @@
1
+ """Drawing pages on the lattice, and reading drawings back.
2
+
3
+ Rule 1 of the grammar: every mark is a whole 3x3 part in a lattice position,
4
+ positions are one empty cell apart, so a line is 6 positions (23 cells) wide.
5
+ Bands of one line are one empty row apart; lines are three empty rows apart.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from html import escape
11
+
12
+ from .page import Band, Line, Page, check_symbol
13
+ from .script import NUMBER, GlyphError, Half, Vocabulary, Word
14
+
15
+ POSITIONS = 6
16
+ WIDTH = POSITIONS * 4 - 1 # 23 cells
17
+ BAND_GAP = 1
18
+ LINE_GAP = 3
19
+ EMPTY_CELL = "."
20
+ MARK = "#"
21
+
22
+ #: Colours of the phase 2 ("137") design: signal blue, white-cyan for them, Sol yellow for us.
23
+ BACKGROUND = "#0d1457"
24
+ GRID = "#141d6e"
25
+ INKS = {"+": "#9ff8ff", "×": "#ffd84a", "x": "#ffd84a"}
26
+ OTHER_INKS = ["#c9b3ff", "#7dffa8", "#ffa45c", "#ffffff"]
27
+
28
+
29
+ # ---------- shapes ----------
30
+
31
+
32
+ def number_shape(n: int) -> tuple[str, str, str]:
33
+ """Nine bits, 256 ... 1, left to right, top to bottom."""
34
+ bits = format(n, "09b")
35
+ return tuple("".join(MARK if bits[r * 3 + c] == "1" else EMPTY_CELL for c in range(3)) for r in range(3)) # type: ignore[return-value]
36
+
37
+
38
+ def shape(vocab: Vocabulary, half: Half) -> tuple[str, str, str]:
39
+ if half is None:
40
+ return (EMPTY_CELL * 3,) * 3 # type: ignore[return-value]
41
+ if isinstance(half, int):
42
+ return number_shape(half)
43
+ return vocab.parts[half].shape
44
+
45
+
46
+ def band_rows(vocab: Vocabulary, halves: list[Half], symbol: str) -> list[str]:
47
+ """Three rows of text for a run of positions drawn in one symbol."""
48
+ rows = ["", "", ""]
49
+ for i, half in enumerate(halves):
50
+ s = shape(vocab, half)
51
+ for r in range(3):
52
+ rows[r] += (EMPTY_CELL if i else "") + s[r].replace(MARK, symbol)
53
+ return rows
54
+
55
+
56
+ def draw_words(vocab: Vocabulary, words: list[Word], symbol: str = "+", spacing: int = 3) -> list[str]:
57
+ """Words side by side, as a quick look-up drawing (not a page)."""
58
+ blocks = [band_rows(vocab, list(w.halves), symbol) for w in words]
59
+ return [(" " * spacing).join(b[r] for b in blocks) for r in range(3)]
60
+
61
+
62
+ # ---------- pages ----------
63
+
64
+
65
+ def render(vocab: Vocabulary, page: Page) -> list[str]:
66
+ """Draw a page as rows of text. Every line gets as many bands as the page has voices."""
67
+ blank = EMPTY_CELL * WIDTH
68
+ n = page.voices
69
+ out: list[str] = []
70
+ for li, line in enumerate(page.lines):
71
+ if li:
72
+ out += [blank] * LINE_GAP
73
+ for k in range(n):
74
+ if k:
75
+ out += [blank] * BAND_GAP
76
+ if k < len(line.bands):
77
+ band = line.bands[k]
78
+ out += band_rows(vocab, band.halves, band.symbol)
79
+ else:
80
+ out += [blank] * 3
81
+ return out
82
+
83
+
84
+ def ink(symbol: str, symbols: list[str]) -> str:
85
+ if symbol in INKS:
86
+ return INKS[symbol]
87
+ others = [s for s in symbols if s not in INKS]
88
+ return OTHER_INKS[others.index(symbol) % len(OTHER_INKS)]
89
+
90
+
91
+ def render_svg(vocab: Vocabulary, page: Page, cell: int = 12, pad: int = 1, grid: bool = True) -> str:
92
+ """Draw a page as SVG: square pixels on signal blue, no curves."""
93
+ rows = render(vocab, page)
94
+ symbols = page.symbols()
95
+ w, h = WIDTH + 2 * pad, len(rows) + 2 * pad
96
+ out = [
97
+ f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w * cell} {h * cell}" '
98
+ f'width="{w * cell}" height="{h * cell}" shape-rendering="crispEdges">',
99
+ f'<rect width="100%" height="100%" fill="{BACKGROUND}"/>',
100
+ ]
101
+ inset = max(1, cell // 12)
102
+ for r, row in enumerate(rows):
103
+ for c, ch in enumerate(row):
104
+ x, y = (c + pad) * cell + inset, (r + pad) * cell + inset
105
+ size = cell - 2 * inset
106
+ box = f'x="{x}" y="{y}" width="{size}" height="{size}"'
107
+ if ch != EMPTY_CELL:
108
+ out.append(f'<rect {box} fill="{ink(ch, symbols)}"><title>{escape(ch)}</title></rect>')
109
+ elif grid:
110
+ out.append(f'<rect {box} fill="{GRID}"/>')
111
+ out.append("</svg>")
112
+ return "\n".join(out) + "\n"
113
+
114
+
115
+ # ---------- reading a drawing back ----------
116
+
117
+
118
+ def _structure(rows: list[str]) -> int:
119
+ """Find how many bands each line has, from where the empty rows fall."""
120
+ empty = [not row.strip(EMPTY_CELL) for row in rows]
121
+ total = len(rows)
122
+ for n in range(1, total + 1):
123
+ line_h = 4 * n - 1
124
+ period = line_h + LINE_GAP
125
+ if (total + LINE_GAP) % period:
126
+ continue
127
+ ok = True
128
+ for i in range(total):
129
+ at = i % period
130
+ gap = at >= line_h or (at % 4 == 3)
131
+ if gap and not empty[i]:
132
+ ok = False
133
+ break
134
+ if ok:
135
+ return n
136
+ raise GlyphError("the rows don't fall into lines and bands; is this a picture rather than text?")
137
+
138
+
139
+ def _which(vocab: Vocabulary, pattern: tuple[str, ...], kind: Half, above: Word | None) -> Half:
140
+ """Read a which: bits under COUNT (or under nothing, answering a COUNT word above), else a part."""
141
+ if kind == NUMBER or (kind is None and above is not None and NUMBER in (above.kind, above.which)):
142
+ return int("".join(pattern).replace(EMPTY_CELL, "0").replace(MARK, "1"), 2)
143
+ return {p.shape: p.name for p in vocab.parts.values()}.get(pattern)
144
+
145
+
146
+ def decode(vocab: Vocabulary, text: str) -> Page:
147
+ """Read a drawing (as made by :func:`render`) back into a page.
148
+
149
+ A which is read as nine bits only after COUNT, or with no kind under a line
150
+ whose word there is a COUNT word (a reply like ``_.12``); everywhere else it
151
+ must be a part.
152
+ """
153
+ rows = [r.strip() for r in text.splitlines() if r.strip()]
154
+ if not rows:
155
+ return Page()
156
+ for i, row in enumerate(rows, 1):
157
+ if len(row) != WIDTH:
158
+ raise GlyphError(f"row {i} is {len(row)} cells wide; text is always {WIDTH} (off the lattice: a picture?)")
159
+ for c in range(3, WIDTH, 4):
160
+ if row[c] != EMPTY_CELL:
161
+ raise GlyphError(f"row {i}, column {c + 1}: a mark between lattice positions (a picture?)")
162
+ n = _structure(rows)
163
+ period = 4 * n - 1 + LINE_GAP
164
+ by_shape = {p.shape: p.name for p in vocab.parts.values()}
165
+ page = Page()
166
+ for top in range(0, len(rows), period):
167
+ line = Line()
168
+ for k in range(n):
169
+ r0 = top + 4 * k
170
+ band = rows[r0 : r0 + 3]
171
+ marks = {ch for row in band for ch in row if ch != EMPTY_CELL}
172
+ if not marks:
173
+ continue
174
+ if len(marks) > 1:
175
+ raise GlyphError(f"rows {r0 + 1}-{r0 + 3}: one band mixes symbols {' '.join(sorted(marks))}")
176
+ symbol = check_symbol(marks.pop())
177
+ halves: list[Half] = []
178
+ for pos in range(POSITIONS):
179
+ pattern = tuple(
180
+ "".join(MARK if ch != EMPTY_CELL else EMPTY_CELL for ch in row[pos * 4 : pos * 4 + 3])
181
+ for row in band
182
+ )
183
+ if not any(MARK in p for p in pattern):
184
+ halves.append(None)
185
+ continue
186
+ slot = pos // 2
187
+ if pos % 2 == 0:
188
+ name = by_shape.get(pattern)
189
+ if name is None:
190
+ raise GlyphError(f"rows {r0 + 1}-{r0 + 3}, position {pos + 1}: not a part")
191
+ halves.append(name)
192
+ else:
193
+ above = line.bands[0].words[slot] if line.bands else None
194
+ which = _which(vocab, pattern, halves[-1], above)
195
+ if which is None:
196
+ raise GlyphError(f"rows {r0 + 1}-{r0 + 3}, position {pos + 1}: not a part or a number here")
197
+ halves.append(which)
198
+ words = tuple(Word(halves[i], halves[i + 1]) for i in (0, 2, 4))
199
+ line.bands.append(Band(symbol, words)) # type: ignore[arg-type]
200
+ if line.bands:
201
+ page.lines.append(line)
202
+ return page
glyph_cli/export.py ADDED
@@ -0,0 +1,28 @@
1
+ """The vocabulary as a Markdown document."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .drawing import band_rows
6
+ from .script import Vocabulary
7
+
8
+ HEADER = "<!-- generated by `glyph export-md`; edit the vocabulary with the tool, not this file -->"
9
+
10
+ INTRO = """\
11
+ How words are made: a **kind** part narrowed by a **which** part (`KIND.WHICH`). A single part is a word
12
+ on its own. Any part can also be a **relation**, and numbers (`COUNT.0` to `COUNT.511`) are built in, so they
13
+ are not listed. Status: **preliminary**, {count} words."""
14
+
15
+
16
+ def vocabulary_markdown(vocab: Vocabulary, title: str | None = None) -> str:
17
+ out = []
18
+ if title:
19
+ out += [f"# {title}", "", INTRO.format(count=len(vocab.entries)), ""]
20
+ out.append(HEADER)
21
+ for domain in vocab.domains():
22
+ out += ["", f"### {domain}", "", "| Word | Parts | Meaning | Note |", "|---|---|---|---|"]
23
+ for e in vocab.entries:
24
+ if e.domain != domain:
25
+ continue
26
+ pic = "<br>".join(f"`{r}`" for r in band_rows(vocab, list(e.word.halves), "+"))
27
+ out.append(f"| {pic} | {e.word} | {e.gloss} | {e.note} |")
28
+ return "\n".join(out) + "\n"
glyph_cli/graph.py ADDED
@@ -0,0 +1,147 @@
1
+ """Reading a page as a knowledge graph (grammar rules 3 and 5).
2
+
3
+ Rule 3: the first band of every line is one edge, node | relation | node. The
4
+ same word on two lines is the same node, and a lone ONE in a node slot means
5
+ "that", the statement on the line above.
6
+
7
+ Rule 5: every part in a lower band answers the nearest drawn part above it in
8
+ the same position: the same part is *yes*, NOT is *no*, a different part is
9
+ *instead*, a part under an empty position is *and also*.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import asdict, dataclass, field
15
+
16
+ from .page import SLOTS, Page
17
+ from .script import Half, Vocabulary, Word
18
+
19
+ HALVES = ("kind", "which")
20
+ THAT = Word("ONE", None)
21
+
22
+
23
+ @dataclass
24
+ class Node:
25
+ word: str | None = None
26
+ gloss: str = ""
27
+ statement: int | None = None # set when the node is "that": edge number it points at
28
+
29
+ def label(self) -> str:
30
+ if self.statement is not None:
31
+ return f"[statement {self.statement}]" if self.statement else "[that: nothing above]"
32
+ return self.gloss
33
+
34
+
35
+ @dataclass
36
+ class Response:
37
+ slot: str
38
+ half: str
39
+ meaning: str # yes | no | instead | and also
40
+ part: str
41
+ to: str | None # symbol of the voice answered, None when answering an empty position
42
+
43
+ def text(self, vocab: Vocabulary) -> str:
44
+ tail = "" if self.meaning in ("yes", "no") else f' "{self.part}"'
45
+ to = f" (to {self.to})" if self.to else ""
46
+ return f"{self.slot}.{self.half}: {self.meaning}{tail}{to}"
47
+
48
+
49
+ @dataclass
50
+ class Reply:
51
+ symbol: str
52
+ responses: list[Response] = field(default_factory=list)
53
+
54
+
55
+ @dataclass
56
+ class Edge:
57
+ number: int
58
+ symbol: str
59
+ subject: Node
60
+ relation: str
61
+ relation_word: str
62
+ object: Node
63
+ replies: list[Reply] = field(default_factory=list)
64
+
65
+
66
+ @dataclass
67
+ class Graph:
68
+ edges: list[Edge] = field(default_factory=list)
69
+ nodes: dict[str, str] = field(default_factory=dict) # word -> gloss
70
+
71
+ def to_json(self) -> dict:
72
+ return {"edges": [asdict(e) for e in self.edges], "nodes": self.nodes}
73
+
74
+
75
+ def respond(above: Half, below: Half) -> str | None:
76
+ if below is None:
77
+ return None
78
+ if above is None:
79
+ return "and also"
80
+ if below == above:
81
+ return "yes"
82
+ if below == "NOT":
83
+ return "no"
84
+ return "instead"
85
+
86
+
87
+ def read_graph(vocab: Vocabulary, page: Page) -> Graph:
88
+ g = Graph()
89
+ for i, line in enumerate(page.lines, 1):
90
+ st = line.statement
91
+ subject_w, relation_w, object_w = st.words
92
+
93
+ def node(word: Word, i: int = i) -> Node:
94
+ if word == THAT:
95
+ return Node(statement=i - 1)
96
+ gloss = vocab.gloss(word)
97
+ if not word.is_empty:
98
+ g.nodes.setdefault(str(word), gloss)
99
+ return Node(str(word) if not word.is_empty else None, gloss)
100
+
101
+ relation = vocab.gloss(relation_w, role="relation")
102
+ edge = Edge(i, st.symbol, node(subject_w), relation, str(relation_w), node(object_w))
103
+ for b, band in enumerate(line.bands[1:], 1):
104
+ reply = Reply(band.symbol)
105
+ for pos, below in enumerate(band.halves):
106
+ above, by = None, None
107
+ for prev in reversed(line.bands[:b]):
108
+ if prev.halves[pos] is not None:
109
+ above, by = prev.halves[pos], prev.symbol
110
+ break
111
+ meaning = respond(above, below)
112
+ if meaning:
113
+ reply.responses.append(Response(SLOTS[pos // 2], HALVES[pos % 2], meaning, vocab.thing(below), by))
114
+ edge.replies.append(reply)
115
+ g.edges.append(edge)
116
+ return g
117
+
118
+
119
+ def format_graph(vocab: Vocabulary, g: Graph) -> list[str]:
120
+ out = ["Edges:"]
121
+ for e in g.edges:
122
+ out.append(f"{e.number}. ({e.symbol}) {e.subject.label()} --{e.relation}--> {e.object.label()}")
123
+ for r in e.replies:
124
+ text = "; ".join(x.text(vocab) for x in r.responses) or "present, no comment"
125
+ out.append(f" ({r.symbol}) {text}")
126
+ out += ["", "Nodes:"]
127
+ out += [f" {word:<14} {gloss}" for word, gloss in g.nodes.items()]
128
+ return out
129
+
130
+
131
+ def to_dot(g: Graph) -> str:
132
+ """Graphviz DOT, for anyone who wants to draw the graph."""
133
+
134
+ def nid(n: Node) -> str:
135
+ return f'"statement {n.statement}"' if n.statement is not None else f'"{n.word or "_"}"'
136
+
137
+ def q(s: str) -> str:
138
+ return s.replace("\\", "\\\\").replace('"', '\\"')
139
+
140
+ lines = ["digraph page {", " rankdir=LR;"]
141
+ for word, gloss in g.nodes.items():
142
+ lines.append(f' "{word}" [label="{q(gloss)}"];')
143
+ for e in g.edges:
144
+ label = q(f"{e.number}. {e.relation} ({e.symbol})")
145
+ lines.append(f' {nid(e.subject)} -> {nid(e.object)} [label="{label}"];')
146
+ lines.append("}")
147
+ return "\n".join(lines) + "\n"
glyph_cli/page.py ADDED
@@ -0,0 +1,113 @@
1
+ """Page source: the plain-text form of a page.
2
+
3
+ One band per line, ``SYMBOL: node | relation | node``. The first band of a
4
+ block is the statement (one edge of the graph); the bands under it are other
5
+ voices replying, part by part. A blank line starts the next statement. ``#``
6
+ starts a comment.
7
+
8
+ +: BODY.OTHER | ONE | BODY.AIR # their statement: your world is an air-world
9
+ ×: _ | _ | _.WATER # our reply: water instead of air
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ from .script import GlyphError, Vocabulary, Word
17
+
18
+ #: Characters that cannot be a party's symbol: they mean something in the formats.
19
+ RESERVED_SYMBOLS = set(".#|:_ \t")
20
+
21
+ SLOTS = ("subject", "relation", "object")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Band:
26
+ """One voice's row of marks within a line."""
27
+
28
+ symbol: str
29
+ words: tuple[Word, Word, Word]
30
+
31
+ @property
32
+ def halves(self) -> list:
33
+ """The six lattice positions, left to right."""
34
+ return [h for w in self.words for h in w.halves]
35
+
36
+
37
+ @dataclass
38
+ class Line:
39
+ """One line of a page: a statement band and the voices that answer it."""
40
+
41
+ bands: list[Band] = field(default_factory=list)
42
+
43
+ @property
44
+ def statement(self) -> Band:
45
+ return self.bands[0]
46
+
47
+ @property
48
+ def replies(self) -> list[Band]:
49
+ return self.bands[1:]
50
+
51
+
52
+ @dataclass
53
+ class Page:
54
+ lines: list[Line] = field(default_factory=list)
55
+
56
+ @property
57
+ def voices(self) -> int:
58
+ """The most bands any line has: every line is drawn this tall."""
59
+ return max((len(line.bands) for line in self.lines), default=0)
60
+
61
+ def symbols(self) -> list[str]:
62
+ out: list[str] = []
63
+ for line in self.lines:
64
+ for band in line.bands:
65
+ if band.symbol not in out:
66
+ out.append(band.symbol)
67
+ return out
68
+
69
+
70
+ def check_symbol(symbol: str) -> str:
71
+ if len(symbol) != 1 or symbol in RESERVED_SYMBOLS:
72
+ reserved = " ".join(sorted(RESERVED_SYMBOLS - {" ", "\t"}))
73
+ raise GlyphError(f"a symbol is one character, not a space or any of {reserved}: {symbol!r}")
74
+ return symbol
75
+
76
+
77
+ def parse_page(text: str, vocab: Vocabulary) -> Page:
78
+ """Read page source into a :class:`Page`."""
79
+ page, current = Page(), Line()
80
+ for number, raw in enumerate(text.splitlines(), 1):
81
+ line = raw.split("#", 1)[0].rstrip()
82
+ if not line.strip():
83
+ if current.bands:
84
+ page.lines.append(current)
85
+ current = Line()
86
+ continue
87
+ symbol, sep, rest = line.partition(":")
88
+ where = f"line {number}"
89
+ if not sep:
90
+ raise GlyphError(f'{where}: expected "SYMBOL: node | relation | node", got {raw.strip()!r}')
91
+ try:
92
+ check_symbol(symbol.strip())
93
+ except GlyphError as exc:
94
+ raise GlyphError(f"{where}: {exc}") from None
95
+ cells = rest.split("|")
96
+ if len(cells) != 3:
97
+ raise GlyphError(f"{where}: need exactly three slots separated by |, got {len(cells)}")
98
+ try:
99
+ words = tuple(vocab.parse(c) for c in cells)
100
+ except GlyphError as exc:
101
+ raise GlyphError(f"{where}: {exc}") from None
102
+ current.bands.append(Band(symbol.strip(), words)) # type: ignore[arg-type]
103
+ if current.bands:
104
+ page.lines.append(current)
105
+ return page
106
+
107
+
108
+ def format_page(page: Page) -> str:
109
+ """Write a :class:`Page` back as page source."""
110
+ blocks = []
111
+ for line in page.lines:
112
+ blocks.append("\n".join(f"{b.symbol}: " + " | ".join(str(w) for w in b.words) for b in line.bands))
113
+ return "\n\n".join(blocks) + ("\n" if blocks else "")