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.
- asciichem-0.1.0/LICENSE +24 -0
- asciichem-0.1.0/PKG-INFO +77 -0
- asciichem-0.1.0/README.md +58 -0
- asciichem-0.1.0/pyproject.toml +24 -0
- asciichem-0.1.0/setup.cfg +4 -0
- asciichem-0.1.0/src/asciichem/__init__.py +24 -0
- asciichem-0.1.0/src/asciichem/inchi.py +78 -0
- asciichem-0.1.0/src/asciichem/mathml.py +362 -0
- asciichem-0.1.0/src/asciichem/model.py +225 -0
- asciichem-0.1.0/src/asciichem/molfile.py +131 -0
- asciichem-0.1.0/src/asciichem/parser.py +819 -0
- asciichem-0.1.0/src/asciichem/smiles.py +441 -0
- asciichem-0.1.0/src/asciichem/structure.py +100 -0
- asciichem-0.1.0/src/asciichem/text.py +245 -0
- asciichem-0.1.0/src/asciichem/wire.py +254 -0
- asciichem-0.1.0/src/asciichem.egg-info/PKG-INFO +77 -0
- asciichem-0.1.0/src/asciichem.egg-info/SOURCES.txt +21 -0
- asciichem-0.1.0/src/asciichem.egg-info/dependency_links.txt +1 -0
- asciichem-0.1.0/src/asciichem.egg-info/requires.txt +9 -0
- asciichem-0.1.0/src/asciichem.egg-info/top_level.txt +1 -0
- asciichem-0.1.0/tests/test_inchi.py +43 -0
- asciichem-0.1.0/tests/test_parse.py +66 -0
- asciichem-0.1.0/tests/test_smiles_molfile.py +71 -0
asciichem-0.1.0/LICENSE
ADDED
|
@@ -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.
|
asciichem-0.1.0/PKG-INFO
ADDED
|
@@ -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,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("<", "<")
|
|
45
|
+
.replace(">", ">"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _escape_attr(value: str) -> str:
|
|
49
|
+
return _escape_text(value).replace('"', """)
|
|
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)
|