mathspec 0.1.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.
- mathspec/__init__.py +59 -0
- mathspec/__main__.py +87 -0
- mathspec/_expression_parser.py +436 -0
- mathspec/_expression_resolver.py +739 -0
- mathspec/_sealed.py +62 -0
- mathspec/_where_parser.py +288 -0
- mathspec/_where_resolver.py +745 -0
- mathspec/_yaml.py +141 -0
- mathspec/advice.py +79 -0
- mathspec/boundedness.py +168 -0
- mathspec/degree.py +209 -0
- mathspec/dimensions.py +377 -0
- mathspec/errors.py +105 -0
- mathspec/exclusivity.py +587 -0
- mathspec/expansion.py +124 -0
- mathspec/lowering.py +302 -0
- mathspec/model.py +905 -0
- mathspec/operators.py +235 -0
- mathspec/piecewise.py +505 -0
- mathspec/program.py +1514 -0
- mathspec/py.typed +0 -0
- mathspec/resolution.py +475 -0
- mathspec/separability.py +185 -0
- mathspec/sos.py +159 -0
- mathspec/typesetting/README.md +110 -0
- mathspec/typesetting/__init__.py +223 -0
- mathspec/typesetting/format.py +273 -0
- mathspec/typesetting/latex.py +139 -0
- mathspec/typesetting/legend.py +286 -0
- mathspec/typesetting/markdown.py +105 -0
- mathspec/typesetting/symbols.py +310 -0
- mathspec/typesetting/typst.py +147 -0
- mathspec/typesetting/walk.py +941 -0
- mathspec/validation.py +338 -0
- mathspec-0.1.0.dist-info/METADATA +247 -0
- mathspec-0.1.0.dist-info/RECORD +38 -0
- mathspec-0.1.0.dist-info/WHEEL +4 -0
- mathspec-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Which symbol each declared name prints as, and the sidecar that overrides it.
|
|
6
|
+
|
|
7
|
+
This module decides *which* symbol a name gets; a :class:`~mathspec.typesetting.format.Format` decides how it is written.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import string
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, cast
|
|
17
|
+
|
|
18
|
+
from mathspec._yaml import read_yaml
|
|
19
|
+
from mathspec.errors import SchemaError, did_you_mean
|
|
20
|
+
from mathspec.piecewise import Emitted, leaves_ungated
|
|
21
|
+
from mathspec.program import Dual, Variable, walk
|
|
22
|
+
from mathspec.sos import Emitted as EmittedSet
|
|
23
|
+
from mathspec.typesetting.format import NOTATIONS
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from mathspec.program import Program
|
|
27
|
+
from mathspec.typesetting.format import Format, Notation
|
|
28
|
+
|
|
29
|
+
__all__ = ['SymbolTable', 'Symbols', 'symbols_for']
|
|
30
|
+
|
|
31
|
+
#: Dimensions whose conventional index letter is not their own initial, which
|
|
32
|
+
#: is what anything unlisted falls back to.
|
|
33
|
+
_INDEX_ALIASES = {'snapshot': 't', 'snapshots': 't', 'time': 't', 'timestep': 't', 'timesteps': 't'}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: Names that are a Greek letter written out. Lower case only — every one has
|
|
37
|
+
#: a letter in LaTeX and in Typst, which the capitals do not — and ``omicron``
|
|
38
|
+
#: is left out because LaTeX spells it ``o``.
|
|
39
|
+
_GREEK = frozenset(
|
|
40
|
+
{
|
|
41
|
+
'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta',
|
|
42
|
+
'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'rho', 'sigma', 'tau',
|
|
43
|
+
'upsilon', 'phi', 'chi', 'psi', 'omega',
|
|
44
|
+
}
|
|
45
|
+
) # fmt: skip
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _word(name: str, fmt: Format, *, given: bool) -> str:
|
|
49
|
+
r"""One name as one symbol: upright where *given*, italic where chosen.
|
|
50
|
+
|
|
51
|
+
A Greek name is set as the letter only where chosen. Upright lower-case
|
|
52
|
+
Greek needs ``upgreek``, which the two-package preamble and GitHub's
|
|
53
|
+
MathJax both lack, so a given ``eta`` prints as ``\mathrm{eta}``; a table
|
|
54
|
+
entry is how an author who loads ``upgreek`` writes ``\upeta``.
|
|
55
|
+
"""
|
|
56
|
+
if given:
|
|
57
|
+
return fmt.upright(name)
|
|
58
|
+
if name in _GREEK:
|
|
59
|
+
return fmt.greek(name)
|
|
60
|
+
return name if len(name) == 1 else fmt.italic(name)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, given: bool = False) -> str:
|
|
64
|
+
r"""``p`` → ``p``; ``load`` → ``\mathit{load}``; ``p_max`` → ``p^{\mathrm{max}}``.
|
|
65
|
+
|
|
66
|
+
An underscore is a qualifier, landing in the superscript, only where its
|
|
67
|
+
head is a symbol in its own right — a single letter, a Greek letter, or a
|
|
68
|
+
declared parameter or variable. A dimension is not a head: ``zone_cap`` is
|
|
69
|
+
a capacity *indexed by* zone. Anywhere else the name prints as written.
|
|
70
|
+
"""
|
|
71
|
+
head, _, tail = name.partition('_')
|
|
72
|
+
if tail and (len(head) == 1 or head in _GREEK or head in declared):
|
|
73
|
+
return fmt.superscript(_word(head, fmt, given=given), fmt.upright(tail.replace('_', ',')))
|
|
74
|
+
return _word(name, fmt, given=given)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def chosen_expressions(program: Program) -> frozenset[str]:
|
|
78
|
+
"""The named expressions the solver decides, rather than is handed.
|
|
79
|
+
|
|
80
|
+
A ``when`` does not move one: a variable there asks whether the variable
|
|
81
|
+
*exists*, which the model settles when it is built. Only a value reaching a
|
|
82
|
+
variable does — through another named expression too, since a use of one
|
|
83
|
+
stands where the name was written.
|
|
84
|
+
A ``dual`` moves one for the same reason a variable does: the solve settles
|
|
85
|
+
it, and no data hands it over.
|
|
86
|
+
"""
|
|
87
|
+
return frozenset(
|
|
88
|
+
name
|
|
89
|
+
for name, entry in program.expressions.items()
|
|
90
|
+
if any(isinstance(node, Variable | Dual) for node in walk(entry.expression))
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class Symbols:
|
|
96
|
+
r"""How every declared name prints: overrides first, derivation for the rest.
|
|
97
|
+
|
|
98
|
+
Built by :func:`symbols_for`. Name symbols settle *before* dimension
|
|
99
|
+
indices, so an index is kept off a single letter a variable owns — a
|
|
100
|
+
dimension ``plant`` beside a variable ``p`` would otherwise render
|
|
101
|
+
``p_{t,p}``. A parameter is upright, so ``\mathrm{p}`` beside an index
|
|
102
|
+
``p`` is not a collision.
|
|
103
|
+
|
|
104
|
+
Attributes:
|
|
105
|
+
overridden: Names the table spelled; the convention note quotes only
|
|
106
|
+
derived symbols.
|
|
107
|
+
name: Each parameter's, variable's and expression's symbol.
|
|
108
|
+
constraint: Each constraint's symbol, the subscript ``dual(c)`` prints
|
|
109
|
+
λ against. Off the flat namespace, like the constraints themselves
|
|
110
|
+
— a model may name a constraint after a variable, so this is its
|
|
111
|
+
own map rather than an entry in :attr:`name`. Given structure, so
|
|
112
|
+
upright unless a table overrides it.
|
|
113
|
+
index: Each dimension's index letter.
|
|
114
|
+
set: Each dimension's set symbol.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
overridden: frozenset[str]
|
|
118
|
+
name: Mapping[str, str]
|
|
119
|
+
constraint: Mapping[str, str]
|
|
120
|
+
index: Mapping[str, str]
|
|
121
|
+
set: Mapping[str, str]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def symbols_for(program: Program, fmt: Format, table: SymbolTable) -> Symbols:
|
|
125
|
+
"""The :class:`Symbols` *program* prints with in *fmt*, *table* overriding the derivation.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
SchemaError: If *table* is written in a notation *fmt* does not read.
|
|
129
|
+
"""
|
|
130
|
+
if table.notation != fmt.notation:
|
|
131
|
+
msg = (
|
|
132
|
+
f'symbol table: written in {table.notation}, but this is a {fmt.notation} render '
|
|
133
|
+
f'and nothing translates between notations — write a {fmt.notation} table.'
|
|
134
|
+
)
|
|
135
|
+
raise SchemaError(msg)
|
|
136
|
+
chosen = frozenset(program.variables) | chosen_expressions(program)
|
|
137
|
+
names = (*program.parameters, *program.variables, *program.expressions)
|
|
138
|
+
declared = frozenset(names)
|
|
139
|
+
|
|
140
|
+
name = {
|
|
141
|
+
n: table.names[n] if n in table.names else _derive_name_symbol(n, declared, fmt, given=n not in chosen)
|
|
142
|
+
for n in names
|
|
143
|
+
}
|
|
144
|
+
spoken_for = {s for s in name.values() if len(s) == 1}
|
|
145
|
+
constraint = {
|
|
146
|
+
n: table.names[n] if n in table.names else _derive_name_symbol(n, declared, fmt, given=True)
|
|
147
|
+
for n in program.constraints
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
index: dict[str, str] = {}
|
|
151
|
+
sets: dict[str, str] = {}
|
|
152
|
+
taken_index, taken_set = set(spoken_for), set()
|
|
153
|
+
for dim in program.dimensions:
|
|
154
|
+
overridden = dim in table.indices
|
|
155
|
+
letter = table.indices[dim] if overridden else _first_free(_index_candidates(dim), taken_index)
|
|
156
|
+
taken_index.add(letter)
|
|
157
|
+
index[dim] = letter if len(letter) <= 1 or overridden else fmt.upright(letter)
|
|
158
|
+
upper = _first_free(_set_candidates(dim, letter), taken_set)
|
|
159
|
+
taken_set.add(upper)
|
|
160
|
+
sets[dim] = table.sets[dim] if dim in table.sets else fmt.script(upper)
|
|
161
|
+
return Symbols(frozenset(table.names) & declared, name, constraint, index, sets)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _index_candidates(dim: str) -> list[str]:
|
|
165
|
+
alias = _INDEX_ALIASES.get(dim)
|
|
166
|
+
letters = [c for c in dim.lower() if c.isalpha()]
|
|
167
|
+
return [*([alias] if alias else []), *letters, *string.ascii_lowercase, dim]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _set_candidates(dim: str, index_letter: str) -> list[str]:
|
|
171
|
+
first = next((c for c in index_letter if c.isalpha()), '')
|
|
172
|
+
letters = [c.upper() for c in dim if c.isalpha()]
|
|
173
|
+
return [*([first.upper()] if first else []), *letters, *string.ascii_uppercase]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _first_free(candidates: list[str], taken: set[str]) -> str:
|
|
177
|
+
return next((c for c in candidates if c not in taken), candidates[-1])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# the symbol table (a sidecar file, not the model)
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class SymbolTable:
|
|
187
|
+
r"""How a *reader* wants the model to print — notation only, kept out of the model.
|
|
188
|
+
|
|
189
|
+
Every entry is a spelling, printed verbatim. ``notation:`` says which
|
|
190
|
+
language they are written in, and a render in the other one refuses::
|
|
191
|
+
|
|
192
|
+
notation: latex
|
|
193
|
+
dimensions:
|
|
194
|
+
snapshot: {index: t, set: "\\mathcal{T}"}
|
|
195
|
+
plant: {index: n}
|
|
196
|
+
names:
|
|
197
|
+
marginal_cost: "c^{\\mathrm{marg}}"
|
|
198
|
+
|
|
199
|
+
An entry naming nothing in the model is an error naming the near miss.
|
|
200
|
+
|
|
201
|
+
Attributes:
|
|
202
|
+
notation: The language the entries are written in; :meth:`load`
|
|
203
|
+
lower-cases it.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
notation: Notation
|
|
207
|
+
indices: dict[str, str] = field(default_factory=dict)
|
|
208
|
+
sets: dict[str, str] = field(default_factory=dict)
|
|
209
|
+
names: dict[str, str] = field(default_factory=dict)
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def load(cls, source: str | Path | Mapping[str, object]) -> SymbolTable:
|
|
213
|
+
"""A table from a YAML path or the mapping it parses to.
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
SchemaError: An unknown section, a section or a dimension that is
|
|
217
|
+
not a mapping, or a ``notation:`` that is missing or not
|
|
218
|
+
``latex``/``typst``.
|
|
219
|
+
"""
|
|
220
|
+
raw = dict(source) if isinstance(source, Mapping) else read_yaml(Path(source))
|
|
221
|
+
unknown = set(raw) - {'notation', 'dimensions', 'names'}
|
|
222
|
+
if unknown:
|
|
223
|
+
msg = f'symbol table: unknown section(s) {sorted(unknown)}. Valid sections: notation, dimensions, names.'
|
|
224
|
+
raise SchemaError(msg)
|
|
225
|
+
if 'notation' not in raw:
|
|
226
|
+
msg = "symbol table: 'notation:' is required — latex or typst, the language the entries are written in."
|
|
227
|
+
raise SchemaError(msg)
|
|
228
|
+
notation = str(raw['notation']).lower()
|
|
229
|
+
if notation not in NOTATIONS:
|
|
230
|
+
msg = f'symbol table: unknown notation {raw["notation"]!r}. Valid notations: latex, typst.'
|
|
231
|
+
raise SchemaError(msg)
|
|
232
|
+
|
|
233
|
+
indices: dict[str, str] = {}
|
|
234
|
+
sets: dict[str, str] = {}
|
|
235
|
+
for dim, spec in _section(raw, 'dimensions').items():
|
|
236
|
+
if not isinstance(spec, Mapping):
|
|
237
|
+
msg = f"symbol table: dimension '{dim}' must be a mapping like {{index: t, set: '\\\\mathcal{{T}}'}}"
|
|
238
|
+
raise SchemaError(msg)
|
|
239
|
+
extra = set(spec) - {'index', 'set'}
|
|
240
|
+
if extra:
|
|
241
|
+
msg = f"symbol table: dimension '{dim}' has unknown key(s) {sorted(extra)}. Valid keys: index, set."
|
|
242
|
+
raise SchemaError(msg)
|
|
243
|
+
if 'index' in spec:
|
|
244
|
+
indices[dim] = str(spec['index'])
|
|
245
|
+
if 'set' in spec:
|
|
246
|
+
sets[dim] = str(spec['set'])
|
|
247
|
+
|
|
248
|
+
return cls(
|
|
249
|
+
notation=cast('Notation', notation),
|
|
250
|
+
indices=indices,
|
|
251
|
+
sets=sets,
|
|
252
|
+
names={k: str(v) for k, v in _section(raw, 'names').items()},
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
def checked_against(self, program: Program) -> SymbolTable:
|
|
256
|
+
"""Reject entries naming nothing in *program* or in what its formulations state, with the near miss.
|
|
257
|
+
|
|
258
|
+
A name a ``piecewise:`` or ``sos:`` block emits counts as declared, so
|
|
259
|
+
one table spells both readings of a model: the blocks as the file states
|
|
260
|
+
them, and the rows :meth:`~mathspec.model.Spec.expand` writes out.
|
|
261
|
+
"""
|
|
262
|
+
dims = set(program.dimensions)
|
|
263
|
+
everything = dims | _declared(program) | _emitted(program)
|
|
264
|
+
errors = [
|
|
265
|
+
*(_unknown_entry(d, 'dimensions', dims) for d in {*self.indices, *self.sets} - dims),
|
|
266
|
+
*(_unknown_entry(n, 'names', everything - dims) for n in set(self.names) - everything),
|
|
267
|
+
]
|
|
268
|
+
if errors:
|
|
269
|
+
raise SchemaError('\n'.join(sorted(errors)))
|
|
270
|
+
return self
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _declared(program: Program) -> set[str]:
|
|
274
|
+
"""Every name *program* declares that a table entry may spell."""
|
|
275
|
+
return set(program.parameters) | set(program.variables) | set(program.expressions) | set(program.constraints)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _emitted(program: Program) -> set[str]:
|
|
279
|
+
"""Every variable and constraint writing *program*'s curves and sets out would declare."""
|
|
280
|
+
curves = (
|
|
281
|
+
Emitted.of(name, curve).written(
|
|
282
|
+
curve.method,
|
|
283
|
+
ungated=leaves_ungated(program.variables[curve.activity] if curve.activity is not None else None),
|
|
284
|
+
)
|
|
285
|
+
for name, curve in program.piecewise.items()
|
|
286
|
+
)
|
|
287
|
+
sets = (EmittedSet.of(name, block.sos_type).by_kind for name, block in program.sos.items())
|
|
288
|
+
return {
|
|
289
|
+
*(name for names in curves for name in names),
|
|
290
|
+
*(name for by_kind in sets for _, names in by_kind for name in names),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _section(raw: Mapping[str, object], name: str) -> Mapping[str, object]:
|
|
295
|
+
"""The *name* section of a symbol table as the mapping it has to be, empty where it is absent or null.
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
SchemaError: The section is something else, such as a list.
|
|
299
|
+
"""
|
|
300
|
+
section = raw.get(name)
|
|
301
|
+
if section is None:
|
|
302
|
+
return {}
|
|
303
|
+
if not isinstance(section, Mapping):
|
|
304
|
+
msg = f'symbol table: {name}: must be a mapping of names to entries, got {type(section).__name__}.'
|
|
305
|
+
raise SchemaError(msg)
|
|
306
|
+
return section
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _unknown_entry(name: str, section: str, known: set[str]) -> str:
|
|
310
|
+
return f"symbol table: '{name}' under {section}: is not declared by the model. {did_you_mean(name, known)}"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""Typst. Multi-letter identifiers in math are upright by default, which is why names go through ``italic("…")``."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import TYPE_CHECKING, ClassVar
|
|
11
|
+
|
|
12
|
+
from mathspec.typesetting.format import OPERATOR_SPELLINGS, aligned_rows, escaped, paragraphs
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
|
|
17
|
+
from mathspec.typesetting.format import Entry, Line, Notation, OperatorName
|
|
18
|
+
|
|
19
|
+
_PREAMBLE = """#set page(margin: 2.5cm)
|
|
20
|
+
#set text(size: 11pt)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
#: What Typst reads as markup in text mode, each escaped by a leading
|
|
25
|
+
#: backslash. They are not LaTeX's: ``%`` and ``&`` are ordinary characters
|
|
26
|
+
#: here, while ``*``, ``@``, ``<``, ``/`` and the square brackets are not.
|
|
27
|
+
_SPECIALS = frozenset('\\#$*_@`<>~/[]')
|
|
28
|
+
|
|
29
|
+
#: A list or heading marker is markup only at the start of a line.
|
|
30
|
+
_LEADING_MARKER = re.compile(r'(^|\n)([-+=])(?= )')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _quote(text: str) -> str:
|
|
34
|
+
"""A Typst string literal — only the quote and the backslash can bite."""
|
|
35
|
+
return '"' + text.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _escape(text: str) -> str:
|
|
39
|
+
escaped = ''.join(f'\\{c}' if c in _SPECIALS else c for c in text)
|
|
40
|
+
return _LEADING_MARKER.sub(r'\1\\\2', escaped)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _raw(text: str) -> str:
|
|
44
|
+
"""Inline raw text. Backticks are the only character that can end it."""
|
|
45
|
+
return '`' + text.replace('`', "'") + '`'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TypstFormat:
|
|
49
|
+
"""See :class:`mathspec.typesetting.format.Format`.
|
|
50
|
+
|
|
51
|
+
The cyclic operators spell with ``.o``, Typst's circled modifier;
|
|
52
|
+
``minus.circle`` does not compile.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
notation: ClassVar[Notation] = 'typst'
|
|
56
|
+
#: Typst applies the same substitution TeX does.
|
|
57
|
+
dash: ClassVar[str] = '---'
|
|
58
|
+
|
|
59
|
+
operators: ClassVar[Mapping[OperatorName, str]] = {name: typst for name, (_, typst) in OPERATOR_SPELLINGS.items()}
|
|
60
|
+
|
|
61
|
+
# -- atoms -------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def italic(self, name: str) -> str:
|
|
64
|
+
return f'italic({_quote(name)})'
|
|
65
|
+
|
|
66
|
+
def upright(self, name: str) -> str:
|
|
67
|
+
return f'upright({_quote(name)})'
|
|
68
|
+
|
|
69
|
+
def script(self, letter: str) -> str:
|
|
70
|
+
return f'cal({letter})'
|
|
71
|
+
|
|
72
|
+
def greek(self, name: str) -> str:
|
|
73
|
+
return name
|
|
74
|
+
|
|
75
|
+
def prose(self, text: str) -> str:
|
|
76
|
+
return f'upright({_quote(text)})'
|
|
77
|
+
|
|
78
|
+
def quoted(self, label: str) -> str:
|
|
79
|
+
in_quotes = f"'{label}'"
|
|
80
|
+
return f'upright({_quote(in_quotes)})'
|
|
81
|
+
|
|
82
|
+
def mono(self, text: str) -> str:
|
|
83
|
+
return _raw(text)
|
|
84
|
+
|
|
85
|
+
def escape(self, prose: str) -> str:
|
|
86
|
+
return escaped(prose, _escape, self.mono)
|
|
87
|
+
|
|
88
|
+
def math(self, expression: str) -> str:
|
|
89
|
+
return f'${expression}$'
|
|
90
|
+
|
|
91
|
+
# -- structure ---------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def subscript(self, base: str, indices: list[str]) -> str:
|
|
94
|
+
return f'{base}_({",".join(indices)})' if indices else base
|
|
95
|
+
|
|
96
|
+
def superscript(self, base: str, tail: str) -> str:
|
|
97
|
+
return f'{base}^({tail})'
|
|
98
|
+
|
|
99
|
+
def parenthesise(self, inner: str) -> str:
|
|
100
|
+
return f'({inner})'
|
|
101
|
+
|
|
102
|
+
def cardinality(self, inner: str) -> str:
|
|
103
|
+
return f'abs({inner})'
|
|
104
|
+
|
|
105
|
+
def fraction(self, numerator: str, denominator: str) -> str:
|
|
106
|
+
return f'frac({numerator}, {denominator})'
|
|
107
|
+
|
|
108
|
+
def set_of(self, members: str, condition: str) -> str:
|
|
109
|
+
return f'{{{members} {self.operators["such_that"]} {condition}}}'
|
|
110
|
+
|
|
111
|
+
def cases(self, arms: list[tuple[str, str]]) -> str:
|
|
112
|
+
return 'cases({})'.format(', '.join(f'{value} & {condition}' for value, condition in arms))
|
|
113
|
+
|
|
114
|
+
def summation(self, domain: str, body: str) -> str:
|
|
115
|
+
return f'sum_({domain}) {body}'
|
|
116
|
+
|
|
117
|
+
def apply(self, function: str, argument: str) -> str:
|
|
118
|
+
return f'{function}({argument})'
|
|
119
|
+
|
|
120
|
+
def joined(self, parts: list[str], operator: str) -> str:
|
|
121
|
+
return f' {operator} '.join(parts) if operator else ', '.join(parts)
|
|
122
|
+
|
|
123
|
+
# -- document ----------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def equation(self, line: Line) -> str:
|
|
126
|
+
body = f'{line.left} {line.right}'.strip()
|
|
127
|
+
return f'{body} quad {line.condition}' if line.condition else body
|
|
128
|
+
|
|
129
|
+
def equations(self, lines: list[Line], *, numbered: bool) -> str:
|
|
130
|
+
"""A block equation, aligned on ``&`` as amsmath does."""
|
|
131
|
+
body = ' \\\n '.join(aligned_rows(lines, self, gap=' & '))
|
|
132
|
+
numbering = '#set math.equation(numbering: "(1)")\n' if numbered else ''
|
|
133
|
+
return f'{numbering}$ {body} $'
|
|
134
|
+
|
|
135
|
+
def glossary(self, entries: list[Entry]) -> str:
|
|
136
|
+
return '\n'.join(f'/ {self.math(e.symbol)}: {e.meaning}' for e in entries)
|
|
137
|
+
|
|
138
|
+
def section(self, title: str, body: str) -> str:
|
|
139
|
+
return f'== {title}\n{body}'
|
|
140
|
+
|
|
141
|
+
def note(self, text: str) -> str:
|
|
142
|
+
return text
|
|
143
|
+
|
|
144
|
+
def document(self, blocks: list[str], *, standalone: bool) -> str:
|
|
145
|
+
"""No preamble/body split: ``standalone`` only adds the page setup."""
|
|
146
|
+
body = paragraphs(blocks)
|
|
147
|
+
return f'{_PREAMBLE}\n{body}' if standalone else body
|