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/__init__.py +7 -0
- glyph_cli/__main__.py +5 -0
- glyph_cli/cli.py +269 -0
- glyph_cli/data/vocab.json +545 -0
- glyph_cli/drawing.py +202 -0
- glyph_cli/export.py +28 -0
- glyph_cli/graph.py +147 -0
- glyph_cli/page.py +113 -0
- glyph_cli/script.py +272 -0
- glyph_cli-0.2.0.dist-info/METADATA +94 -0
- glyph_cli-0.2.0.dist-info/RECORD +14 -0
- glyph_cli-0.2.0.dist-info/WHEEL +4 -0
- glyph_cli-0.2.0.dist-info/entry_points.txt +2 -0
- glyph_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
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 "")
|