asciichem 0.1.0__tar.gz

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.
@@ -0,0 +1,24 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, Ribose Inc.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: asciichem
3
+ Version: 0.1.0
4
+ Summary: Python implementation of AsciiChem - parse ASCII chemistry into a semantic model; canonical text and wire JSON. Conformant with asciichem-model v1 and the asciichem-tests corpus.
5
+ License: BSD-2-Clause
6
+ Project-URL: Homepage, https://asciichem.org
7
+ Project-URL: Repository, https://github.com/asciichem/asciichem-py
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest>=8; extra == "dev"
13
+ Requires-Dist: jsonschema>=4; extra == "dev"
14
+ Requires-Dist: pyyaml>=6; extra == "dev"
15
+ Requires-Dist: referencing>=0.35; extra == "dev"
16
+ Provides-Extra: inchi
17
+ Requires-Dist: rdkit>=2023; extra == "inchi"
18
+ Dynamic: license-file
19
+
20
+ # asciichem (Python)
21
+
22
+ Python implementation of [AsciiChem](https://asciichem.org) — parse
23
+ ASCII chemistry into a **semantic model** (atoms, bonds, isotopes,
24
+ charges, reactions, electron configurations), then render canonical
25
+ text or the canonical wire JSON. The defining fix over AsciiMath:
26
+ `^14C` binds the isotope to the atom, never to a phantom `{}` carrier.
27
+
28
+ Strategy A of the ecosystem's [language-binding
29
+ ADR](https://github.com/asciichem/asciichem-ts/blob/main/docs/adr/0001-language-bindings.adoc):
30
+ a native implementation conforming to the shared contracts —
31
+ `asciichem-model` (v1 wire form) and `asciichem-tests` (the corpus
32
+ every implementation passes). The Ruby gem is the reference; this
33
+ package, like the TypeScript one, must agree with it on every corpus
34
+ case. Zero runtime dependencies.
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ from asciichem import parse_text
40
+ from asciichem.text import render
41
+ from asciichem.wire import to_model_json, from_model_json
42
+
43
+ from asciichem.smiles import parse_smiles, write_smiles
44
+
45
+ formula = parse_text("2H_2 + O_2 ->[heat] 2H_2O")
46
+ aspirin = parse_smiles("CC(=O)OC1=CC=CC=C1C(=O)O") # same model, same renderers
47
+ write_smiles(aspirin) # deterministic writer; round-trips exactly
48
+ render(formula) # canonical AsciiChem text (round-trip contract)
49
+ to_model_json(formula) # asciichem-model v1 wire JSON
50
+ back = from_model_json(to_model_json(formula))
51
+ ```
52
+
53
+ ## Conformance claim
54
+
55
+ Verified in CI against the [asciichem-tests](https://github.com/asciichem/asciichem-tests)
56
+ corpus and [asciichem-model](https://github.com/asciichem/asciichem-model)
57
+ schemas (both cloned as siblings):
58
+
59
+ | Level | Claim |
60
+ |---|---|
61
+ | parse/reject | 100% — every corpus case parses or raises `ParseError` exactly as marked |
62
+ | L0 | 100% — emission validates against the v1 JSON Schemas |
63
+ | L1 | 100% — `render(parse(s)) == s` for every `roundTrip` case |
64
+ | SMILES | 100% — ingestion + deterministic emission, every `structure/smiles/*` fixture (asciichem-tests v0.3.0) |
65
+ | molfile | 100% — V2000 ingestion + emission, every `structure/molfile/*` fixture |
66
+ | MathML / resolver | not yet claimed (tracked follow-ups; the Ruby and TS implementations have them) |
67
+
68
+ ## Development
69
+
70
+ ```sh
71
+ pip install -e ".[dev]"
72
+ python -m pytest # unit + corpus conformance (siblings cloned)
73
+ ```
74
+
75
+ ## License
76
+
77
+ BSD-2-Clause — same as the rest of the AsciiChem ecosystem.
@@ -0,0 +1,58 @@
1
+ # asciichem (Python)
2
+
3
+ Python implementation of [AsciiChem](https://asciichem.org) — parse
4
+ ASCII chemistry into a **semantic model** (atoms, bonds, isotopes,
5
+ charges, reactions, electron configurations), then render canonical
6
+ text or the canonical wire JSON. The defining fix over AsciiMath:
7
+ `^14C` binds the isotope to the atom, never to a phantom `{}` carrier.
8
+
9
+ Strategy A of the ecosystem's [language-binding
10
+ ADR](https://github.com/asciichem/asciichem-ts/blob/main/docs/adr/0001-language-bindings.adoc):
11
+ a native implementation conforming to the shared contracts —
12
+ `asciichem-model` (v1 wire form) and `asciichem-tests` (the corpus
13
+ every implementation passes). The Ruby gem is the reference; this
14
+ package, like the TypeScript one, must agree with it on every corpus
15
+ case. Zero runtime dependencies.
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from asciichem import parse_text
21
+ from asciichem.text import render
22
+ from asciichem.wire import to_model_json, from_model_json
23
+
24
+ from asciichem.smiles import parse_smiles, write_smiles
25
+
26
+ formula = parse_text("2H_2 + O_2 ->[heat] 2H_2O")
27
+ aspirin = parse_smiles("CC(=O)OC1=CC=CC=C1C(=O)O") # same model, same renderers
28
+ write_smiles(aspirin) # deterministic writer; round-trips exactly
29
+ render(formula) # canonical AsciiChem text (round-trip contract)
30
+ to_model_json(formula) # asciichem-model v1 wire JSON
31
+ back = from_model_json(to_model_json(formula))
32
+ ```
33
+
34
+ ## Conformance claim
35
+
36
+ Verified in CI against the [asciichem-tests](https://github.com/asciichem/asciichem-tests)
37
+ corpus and [asciichem-model](https://github.com/asciichem/asciichem-model)
38
+ schemas (both cloned as siblings):
39
+
40
+ | Level | Claim |
41
+ |---|---|
42
+ | parse/reject | 100% — every corpus case parses or raises `ParseError` exactly as marked |
43
+ | L0 | 100% — emission validates against the v1 JSON Schemas |
44
+ | L1 | 100% — `render(parse(s)) == s` for every `roundTrip` case |
45
+ | SMILES | 100% — ingestion + deterministic emission, every `structure/smiles/*` fixture (asciichem-tests v0.3.0) |
46
+ | molfile | 100% — V2000 ingestion + emission, every `structure/molfile/*` fixture |
47
+ | MathML / resolver | not yet claimed (tracked follow-ups; the Ruby and TS implementations have them) |
48
+
49
+ ## Development
50
+
51
+ ```sh
52
+ pip install -e ".[dev]"
53
+ python -m pytest # unit + corpus conformance (siblings cloned)
54
+ ```
55
+
56
+ ## License
57
+
58
+ BSD-2-Clause — same as the rest of the AsciiChem ecosystem.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "asciichem"
7
+ version = "0.1.0"
8
+ description = "Python implementation of AsciiChem - parse ASCII chemistry into a semantic model; canonical text and wire JSON. Conformant with asciichem-model v1 and the asciichem-tests corpus."
9
+ readme = "README.md"
10
+ license = { text = "BSD-2-Clause" }
11
+ requires-python = ">=3.10"
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ dev = ["pytest>=8", "jsonschema>=4", "pyyaml>=6", "referencing>=0.35"]
16
+ # InChI identity engine (TODO.v2 10): RDKit wraps the IUPAC library.
17
+ inchi = ["rdkit>=2023"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://asciichem.org"
21
+ Repository = "https://github.com/asciichem/asciichem-py"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,24 @@
1
+ """asciichem - Python implementation of the AsciiChem contract.
2
+
3
+ parse("H_2O").to_text() canonical AsciiChem text (round-trip contract)
4
+ parse("H_2O").to_model_json() asciichem-model v1 wire JSON
5
+ """
6
+ from .model import AsciiChemError, ParseError
7
+ from .parser import parse_text
8
+ from .text import render
9
+ from .mathml import render_mathml
10
+ from .wire import from_model_json, to_model_json, to_wire
11
+
12
+ __version__ = "0.1.0"
13
+ __all__ = ["parse_text", "from_model_json", "to_model_json", "to_wire",
14
+ "render_mathml", "AsciiChemError", "ParseError", "__version__"]
15
+
16
+
17
+ def _render(node):
18
+ return render(node)
19
+
20
+
21
+ # Convenience: attach to_text()/to_model_json() to nodes dynamically is
22
+ # un-Pythonic; expose a parse() that returns the Formula plus helpers.
23
+ def parse(source: str):
24
+ return parse_text(source)
@@ -0,0 +1,78 @@
1
+ """Local identity derivation (TODO.v2 10; TODO.impl 48).
2
+
3
+ The InChI algorithm is never reimplemented: engines wrap an InChI
4
+ implementation — RDKit's (which uses the IUPAC library underneath)
5
+ via the `asciichem[inchi]` extra here. Engines are opt-in: without
6
+ one, derivation raises EngineMissingError with guidance; no silent
7
+ fallback.
8
+
9
+ from asciichem.inchi import RdkitEngine, set_engine
10
+ set_engine(RdkitEngine())
11
+ identity_for(molecule) # Identity(inchi=..., inchikey=...)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Optional
16
+
17
+ from .model import AsciiChemError, Molecule
18
+
19
+ INSTALL_GUIDE = ("install the InChI engine extra (pip install "
20
+ "'asciichem[inchi]') and register it via "
21
+ "asciichem.inchi.set_engine(RdkitEngine())")
22
+
23
+
24
+ class EngineMissingError(AsciiChemError):
25
+ pass
26
+
27
+
28
+ class Identity:
29
+ def __init__(self, inchi: str, inchikey: Optional[str] = None):
30
+ self.inchi = inchi
31
+ self.inchikey = inchikey
32
+
33
+ def __eq__(self, other):
34
+ return (isinstance(other, Identity) and
35
+ self.inchi == other.inchi and self.inchikey == other.inchikey)
36
+
37
+ def __repr__(self):
38
+ return f"Identity(inchi={self.inchi!r}, inchikey={self.inchikey!r})"
39
+
40
+
41
+ _engine = None
42
+
43
+
44
+ def set_engine(engine) -> None:
45
+ global _engine
46
+ _engine = engine
47
+
48
+
49
+ def get_engine():
50
+ return _engine
51
+
52
+
53
+ def identity_for(molecule: Molecule, engine=None) -> Identity:
54
+ chosen = engine if engine is not None else _engine
55
+ if chosen is None:
56
+ raise EngineMissingError(f"no InChI engine configured - {INSTALL_GUIDE}")
57
+ return chosen.identity(molecule)
58
+
59
+
60
+ class RdkitEngine:
61
+ """Engine over RDKit's InChI support (asciichem[inchi] extra).
62
+ Pipe: molecule -> molfile -> rdkit -> standard InChI/InChIKey."""
63
+
64
+ def identity(self, molecule: Molecule) -> Identity:
65
+ try:
66
+ from rdkit import Chem
67
+ except ImportError as e:
68
+ raise EngineMissingError(f"rdkit is not installed - {INSTALL_GUIDE}") from e
69
+
70
+ from .molfile import write_molfile
71
+
72
+ molblock = write_molfile(molecule)
73
+ mol = Chem.MolFromMolBlock(molblock, sanitize=True, removeHs=False)
74
+ if mol is None:
75
+ raise AsciiChemError("rdkit could not parse the molfile for this molecule")
76
+ inchi = Chem.MolToInchi(mol)
77
+ inchikey = Chem.MolToInchiKey(mol)
78
+ return Identity(inchi=inchi, inchikey=inchikey or None)
@@ -0,0 +1,362 @@
1
+ """Presentation MathML formatter - byte-identical port of the
2
+ reference AsciiChem::Formatter::Mathml (single-contract rule: the
3
+ shared mathml corpus goldens are exact string comparisons).
4
+
5
+ Emission mirrors Nokogiri's default pretty-printing: elements with
6
+ element children are multi-line with 2-space indent; text-only
7
+ elements are single-line; empty elements self-close."""
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ from .model import (Atom, Bond, Calculation, Crystal, ElectronConfiguration,
13
+ EmbeddedMath, Formula, Group, Mechanism, Molecule, Node,
14
+ Reaction, ReactionCascade, Spectrum, Text, ZMatrix,
15
+ STEREO_LETTER, AsciiChemError)
16
+ from .parser import parse_text
17
+
18
+ MATHML_NS = "http://www.w3.org/1998/Math/MathML"
19
+
20
+ _FORMATTERS = {}
21
+
22
+
23
+ def _formats(cls):
24
+ def register(fn):
25
+ _FORMATTERS[cls] = fn
26
+ return fn
27
+ return register
28
+
29
+
30
+ class _El:
31
+ """Immutable-ish XML element: either text-bearing or child-bearing."""
32
+
33
+ __slots__ = ("name", "attrs", "text", "children")
34
+
35
+ def __init__(self, name, attrs=None, text=None, children=None):
36
+ self.name = name
37
+ self.attrs = attrs
38
+ self.text = text
39
+ self.children = children or []
40
+
41
+
42
+ def _escape_text(value: str) -> str:
43
+ return (value.replace("&", "&")
44
+ .replace("<", "&lt;")
45
+ .replace(">", "&gt;"))
46
+
47
+
48
+ def _escape_attr(value: str) -> str:
49
+ return _escape_text(value).replace('"', "&quot;")
50
+
51
+
52
+ def _serialize(el: _El, depth: int = 0) -> str:
53
+ pad = " " * (depth * 2)
54
+ attrs = ""
55
+ if el.attrs:
56
+ attrs = "".join(f' {k}="{_escape_attr(v)}"' for k, v in el.attrs.items())
57
+ if el.text is not None:
58
+ if el.text == "":
59
+ return f"{pad}<{el.name}{attrs}/>"
60
+ return f"{pad}<{el.name}{attrs}>{_escape_text(el.text)}</{el.name}>"
61
+ if not el.children:
62
+ return f"{pad}<{el.name}{attrs}/>"
63
+ inner = "\n".join(_serialize(c, depth + 1) for c in el.children)
64
+ return f"{pad}<{el.name}{attrs}>\n{inner}\n{pad}</{el.name}>"
65
+
66
+
67
+ def _el(name, attrs=None, children=None):
68
+ return _El(name, attrs=attrs, children=children)
69
+
70
+
71
+ def _mi(content) -> _El:
72
+ return _El("mi", attrs={"mathvariant": "normal"}, text=str(content))
73
+
74
+
75
+ def _mn(content) -> _El:
76
+ return _El("mn", text=str(content))
77
+
78
+
79
+ def _mo(content) -> _El:
80
+ return _El("mo", text=str(content))
81
+
82
+
83
+ def _mtext(content) -> _El:
84
+ return _El("mtext", text=str(content))
85
+
86
+
87
+ def render(node: Node) -> _El:
88
+ return _FORMATTERS[type(node)](node)
89
+
90
+
91
+ def render_mathml(formula) -> str:
92
+ xml = _serialize(render(formula))
93
+ return f'<?xml version="1.0" encoding="UTF-8"?>\n{xml}\n'
94
+
95
+
96
+ @_formats(Formula)
97
+ def _formula(f: Formula) -> _El:
98
+ return _el("math", {"xmlns": MATHML_NS}, [_el("mrow", None, [render(n) for n in f.nodes])])
99
+
100
+
101
+ @_formats(Molecule)
102
+ def _molecule(m: Molecule) -> _El:
103
+ children = []
104
+ if m.stereo:
105
+ children.append(_mtext(f"({STEREO_LETTER[m.stereo]})-"))
106
+ if m.coefficient is not None:
107
+ children.append(_mn(m.coefficient))
108
+ children.extend(render(n) for n in m.nodes)
109
+ return _el("mrow", None, children)
110
+
111
+
112
+ @_formats(Atom)
113
+ def _atom(atom: Atom) -> _El:
114
+ base = _mi(atom.element)
115
+ if atom.isotope is not None:
116
+ base = _el("mmultiscripts", None,
117
+ [base, _el("none"), _el("none"), _el("mprescripts"),
118
+ _el("none"), _mn(atom.isotope)])
119
+ if atom.lone_pairs is not None:
120
+ base = _el("mrow", None, [_mtext(":" * atom.lone_pairs), base])
121
+ base = _wrap_sub_and_super(base, atom)
122
+ if atom.radical_electrons is not None:
123
+ base = _el("mrow", None, [base, _mtext("." * atom.radical_electrons)])
124
+ if atom.ring_closures is not None:
125
+ base = _el("mrow", None, [base, _mn(atom.ring_closures)])
126
+ return base
127
+
128
+
129
+ def _wrap_sub_and_super(base: _El, atom: Atom) -> _El:
130
+ has_sub = atom.subscript not in (None, "")
131
+ super_node = _super_element(atom)
132
+ if not has_sub and super_node is None:
133
+ return base
134
+ if has_sub and super_node is None:
135
+ return _el("msub", None, [base, _mn(atom.subscript)])
136
+ if not has_sub and super_node is not None:
137
+ return _el("msup", None, [base, super_node])
138
+ return _el("msubsup", None, [base, _mn(atom.subscript), super_node])
139
+
140
+
141
+ def _super_element(atom: Atom):
142
+ # charge > oxidation state > raw superscript (reference priority).
143
+ if atom.charge is not None:
144
+ row = _charge_row(atom.charge)
145
+ if row is not None:
146
+ return row
147
+ if atom.oxidation_state is not None:
148
+ return _el("mrow", None, [_mi(atom.oxidation_state)])
149
+ if atom.superscript is not None:
150
+ return _mn(atom.superscript)
151
+ return None
152
+
153
+
154
+ def _charge_row(charge: str):
155
+ digits_sign = re.match(r"^(\d*)([+-])$", charge)
156
+ sign_digits = re.match(r"^([+-])(\d*)$", charge) if digits_sign is None else None
157
+ if digits_sign is None and sign_digits is None:
158
+ return None
159
+ digits = digits_sign.group(1) if digits_sign else sign_digits.group(2)
160
+ sign = digits_sign.group(2) if digits_sign else sign_digits.group(1)
161
+ children = []
162
+ if digits != "":
163
+ children.append(_mn(digits))
164
+ children.append(_mo(sign))
165
+ return _el("mrow", None, children)
166
+
167
+
168
+ @_formats(Bond)
169
+ def _bond(b: Bond) -> _El:
170
+ return _mo(b.entity)
171
+
172
+
173
+ @_formats(Group)
174
+ def _group(g: Group) -> _El:
175
+ children = [_mo(g.open_char)]
176
+ children.extend(render(n) for n in g.nodes)
177
+ children.append(_mo(g.close_char))
178
+ row = _el("mrow", None, children)
179
+ if g.multiplicity is None:
180
+ return row
181
+ return _el("msub", None, [row, _mn(g.multiplicity)])
182
+
183
+
184
+ def _add_terms(children: list, terms) -> None:
185
+ for index, term in enumerate(terms):
186
+ if index > 0:
187
+ children.append(_mo("+"))
188
+ children.append(render(term))
189
+
190
+
191
+ def _render_arrow(reaction: Reaction) -> _El:
192
+ op = _mo(reaction.arrow_entity)
193
+ if reaction.conditions is None:
194
+ return op
195
+ above = reaction.conditions.above
196
+ below = reaction.conditions.below
197
+ if above is None and below is None:
198
+ return op
199
+ if above is not None and below is not None:
200
+ name = "munderover"
201
+ elif above is not None:
202
+ name = "mover"
203
+ else:
204
+ name = "munder"
205
+ children = [op]
206
+ if above is not None:
207
+ children.append(_render_condition(above))
208
+ if below is not None:
209
+ children.append(_render_condition(below))
210
+ return _el(name, None, children)
211
+
212
+
213
+ def _render_condition(text):
214
+ if not text:
215
+ return _mtext("")
216
+ try:
217
+ formula = parse_text(text)
218
+ return _el("mrow", None, [render(n) for n in formula.nodes])
219
+ except AsciiChemError:
220
+ return _mtext(text)
221
+
222
+
223
+ @_formats(Reaction)
224
+ def _reaction(r: Reaction) -> _El:
225
+ children = []
226
+ _add_terms(children, r.reactants)
227
+ children.append(_render_arrow(r))
228
+ _add_terms(children, r.products)
229
+ return _el("mrow", None, children)
230
+
231
+
232
+ @_formats(ReactionCascade)
233
+ def _cascade(c: ReactionCascade) -> _El:
234
+ if not c.steps:
235
+ return _el("mrow")
236
+ children = []
237
+ _add_terms(children, c.steps[0].reactants)
238
+ for step in c.steps:
239
+ children.append(_render_arrow(step))
240
+ _add_terms(children, step.products)
241
+ return _el("mrow", None, children)
242
+
243
+
244
+ @_formats(ElectronConfiguration)
245
+ def _electron_configuration(ec: ElectronConfiguration) -> _El:
246
+ children = []
247
+ for index, (orbital, occupancy) in enumerate(ec.orbitals):
248
+ if index > 0:
249
+ children.append(_mo(" "))
250
+ children.append(_el("msup", None, [_mi(orbital), _mn(occupancy)]))
251
+ return _el("mrow", None, children)
252
+
253
+
254
+ @_formats(EmbeddedMath)
255
+ def _embedded_math(em: EmbeddedMath) -> _El:
256
+ # Spec'd loss (TODO.impl/61): the reference embeds Plurimath
257
+ # MathML; this port has no AsciiMath engine, so the source
258
+ # degrades to <mtext>.
259
+ return _mtext(em.source)
260
+
261
+
262
+ @_formats(Text)
263
+ def _text(t: Text) -> _El:
264
+ return _mtext(t.content)
265
+
266
+
267
+ def _named_bracket(content) -> _El:
268
+ return _el("mrow", None, [_mo("["), _mtext(content), _mo("]")])
269
+
270
+
271
+ def _simple_table(rows) -> _El:
272
+ return _el("mtable", None, [
273
+ _el("mtr", None, [_el("mtd", None, [cell]) for cell in row])
274
+ for row in rows
275
+ ])
276
+
277
+
278
+ _CELL_LABELS = {"a": "a", "b": "b", "c": "c", "alpha": "α", "beta": "β", "gamma": "γ"}
279
+
280
+
281
+ @_formats(Crystal)
282
+ def _crystal(crystal: Crystal) -> _El:
283
+ children = [_mi("crystal")]
284
+ if crystal.name is not None:
285
+ children.append(_named_bracket(crystal.name))
286
+ params = []
287
+ for key in ("a", "b", "c", "alpha", "beta", "gamma"):
288
+ value = getattr(crystal, key)
289
+ if value is not None:
290
+ params.append([_mi(_CELL_LABELS[key]), _mn(value)])
291
+ if params:
292
+ children.append(_simple_table(params))
293
+ if crystal.atoms:
294
+ rows = [[_mn(i + 1), render(atom)] for i, atom in enumerate(crystal.atoms)]
295
+ children.append(_simple_table(rows))
296
+ return _el("mrow", None, children)
297
+
298
+
299
+ @_formats(Spectrum)
300
+ def _spectrum(spectrum: Spectrum) -> _El:
301
+ children = [_mi("spectrum")]
302
+ if spectrum.technique is not None:
303
+ children.append(_named_bracket(spectrum.technique))
304
+ if spectrum.params:
305
+ rows = [[_mi(key), _mtext(value)] for key, value in spectrum.params.items()]
306
+ children.append(_simple_table(rows))
307
+ if spectrum.peaks:
308
+ rows = []
309
+ for peak in spectrum.peaks:
310
+ row = [_mn(peak.position or ""), _mn(peak.intensity or "")]
311
+ if peak.multiplicity is not None:
312
+ row.append(_mi(peak.multiplicity))
313
+ if peak.assignment is not None:
314
+ row.append(_mtext(peak.assignment))
315
+ rows.append(row)
316
+ children.append(_simple_table(rows))
317
+ return _el("mrow", None, children)
318
+
319
+
320
+ @_formats(Calculation)
321
+ def _calculation(calc: Calculation) -> _El:
322
+ children = [_mi("calc")]
323
+ if calc.method is not None or calc.basis is not None:
324
+ children.append(_named_bracket("/".join(p for p in (calc.method, calc.basis) if p)))
325
+ if calc.properties:
326
+ rows = []
327
+ for prop in calc.properties:
328
+ row = [_mi(prop.title), _mn(prop.value)]
329
+ if prop.units is not None:
330
+ row.append(_mi(prop.units))
331
+ rows.append(row)
332
+ children.append(_simple_table(rows))
333
+ return _el("mrow", None, children)
334
+
335
+
336
+ @_formats(ZMatrix)
337
+ def _zmatrix(zm: ZMatrix) -> _El:
338
+ children = [_mi("zmatrix")]
339
+ if zm.rows:
340
+ rows = []
341
+ for row in zm.rows:
342
+ cells = [_mi(row.atom)]
343
+ if row.ref1 is not None:
344
+ cells.extend([_mi(row.ref1), _mn(row.distance or "")])
345
+ if row.ref2 is not None:
346
+ cells.extend([_mi(row.ref2), _mn(row.angle or "")])
347
+ if row.ref3 is not None:
348
+ cells.extend([_mi(row.ref3), _mn(row.dihedral or "")])
349
+ rows.append(cells)
350
+ children.append(_simple_table(rows))
351
+ return _el("mrow", None, children)
352
+
353
+
354
+ @_formats(Mechanism)
355
+ def _mechanism(mech: Mechanism) -> _El:
356
+ children = [_mi("mechanism")]
357
+ if mech.steps or getattr(mech, "spectators", []):
358
+ rows = [[_mi(s.label), _mtext(s.reaction)] for s in mech.steps]
359
+ for spectator in getattr(mech, "spectators", []):
360
+ rows.append([_mi("spectator"), _mtext(spectator)])
361
+ children.append(_simple_table(rows))
362
+ return _el("mrow", None, children)