asciichem 0.3.0
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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +21 -0
- data/.gitignore +14 -0
- data/.rubocop.yml +50 -0
- data/ARCHITECTURE.adoc +239 -0
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +318 -0
- data/Gemfile +14 -0
- data/LICENSE +24 -0
- data/README.adoc +55 -0
- data/RELEASING.md +102 -0
- data/Rakefile +8 -0
- data/asciichem.gemspec +39 -0
- data/benchmarks/RESULTS.md +49 -0
- data/benchmarks/benchmark.rb +106 -0
- data/exe/asciichem +6 -0
- data/lib/asciichem/cli.rb +96 -0
- data/lib/asciichem/cml/extensions.rb +360 -0
- data/lib/asciichem/cml/group_extensions.rb +290 -0
- data/lib/asciichem/cml/translator.rb +69 -0
- data/lib/asciichem/cml.rb +40 -0
- data/lib/asciichem/errors.rb +9 -0
- data/lib/asciichem/formatter/base.rb +21 -0
- data/lib/asciichem/formatter/html.rb +111 -0
- data/lib/asciichem/formatter/latex.rb +173 -0
- data/lib/asciichem/formatter/mathml.rb +309 -0
- data/lib/asciichem/formatter/structural_svg.rb +286 -0
- data/lib/asciichem/formatter/svg.rb +141 -0
- data/lib/asciichem/formatter/text.rb +143 -0
- data/lib/asciichem/formatter.rb +43 -0
- data/lib/asciichem/grammar.rb +344 -0
- data/lib/asciichem/greek.rb +80 -0
- data/lib/asciichem/layout.rb +313 -0
- data/lib/asciichem/linter/balance_check.rb +103 -0
- data/lib/asciichem/linter/base.rb +52 -0
- data/lib/asciichem/linter/bracket_balance_check.rb +44 -0
- data/lib/asciichem/linter/diagnostic.rb +16 -0
- data/lib/asciichem/linter/element_validation_check.rb +39 -0
- data/lib/asciichem/linter/isotope_sanity_check.rb +47 -0
- data/lib/asciichem/linter/registry.rb +31 -0
- data/lib/asciichem/linter/unclosed_ring_check.rb +27 -0
- data/lib/asciichem/linter/valence_check.rb +91 -0
- data/lib/asciichem/linter.rb +40 -0
- data/lib/asciichem/model/atom.rb +73 -0
- data/lib/asciichem/model/bond.rb +41 -0
- data/lib/asciichem/model/electron_configuration.rb +36 -0
- data/lib/asciichem/model/embedded_math.rb +26 -0
- data/lib/asciichem/model/formula.rb +31 -0
- data/lib/asciichem/model/group.rb +50 -0
- data/lib/asciichem/model/identifier.rb +14 -0
- data/lib/asciichem/model/molecule.rb +70 -0
- data/lib/asciichem/model/name.rb +15 -0
- data/lib/asciichem/model/node.rb +100 -0
- data/lib/asciichem/model/reaction.rb +49 -0
- data/lib/asciichem/model/reaction_cascade.rb +33 -0
- data/lib/asciichem/model/text.rb +23 -0
- data/lib/asciichem/model.rb +23 -0
- data/lib/asciichem/model_adapter/from_canonical.rb +272 -0
- data/lib/asciichem/model_adapter/to_canonical.rb +429 -0
- data/lib/asciichem/model_adapter.rb +56 -0
- data/lib/asciichem/parser.rb +36 -0
- data/lib/asciichem/periodic_table.rb +112 -0
- data/lib/asciichem/ring_bonds.rb +68 -0
- data/lib/asciichem/transform.rb +513 -0
- data/lib/asciichem/version.rb +5 -0
- data/lib/asciichem/xml_builder.rb +9 -0
- data/lib/asciichem.rb +34 -0
- metadata +197 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# A chemical reaction: reactants, an arrow, and products. Conditions
|
|
6
|
+
# render above and below the arrow per IUPAC.
|
|
7
|
+
class Reaction < Node
|
|
8
|
+
Conditions = Struct.new(:above, :below, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
ARROWS = {
|
|
11
|
+
forward: { ascii: "->", mathml_entity: "→" },
|
|
12
|
+
reverse: { ascii: "<-", mathml_entity: "←" },
|
|
13
|
+
equilibrium: { ascii: "<=>", mathml_entity: "⇌" },
|
|
14
|
+
resonance: { ascii: "<->", mathml_entity: "↔" }
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
attr_accessor :reactants, :products, :arrow, :conditions
|
|
18
|
+
|
|
19
|
+
def initialize(reactants:, products:, arrow: :forward, conditions: nil)
|
|
20
|
+
@reactants = reactants
|
|
21
|
+
@products = products
|
|
22
|
+
@arrow = arrow
|
|
23
|
+
@conditions = conditions || Conditions.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def value_attributes
|
|
27
|
+
{ reactants: reactants, products: products, arrow: arrow,
|
|
28
|
+
conditions: conditions }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def children
|
|
32
|
+
reactants + products
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def arrow_ascii
|
|
36
|
+
ARROWS.fetch(arrow).fetch(:ascii)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def arrow_entity
|
|
40
|
+
ARROWS.fetch(arrow).fetch(:mathml_entity)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_s
|
|
44
|
+
"Reaction(#{reactants.map(&:to_s).join(' + ')} " \
|
|
45
|
+
"#{arrow_ascii} #{products.map(&:to_s).join(' + ')})"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# A multi-step reaction cascade: an ordered list of Reaction
|
|
6
|
+
# objects where each step's products are the next step's
|
|
7
|
+
# reactants. Source spelling is a chain of arrows:
|
|
8
|
+
#
|
|
9
|
+
# A -> B -> C -> D
|
|
10
|
+
#
|
|
11
|
+
# Single-step reactions stay as `Reaction`; only multi-step
|
|
12
|
+
# sequences promote to `ReactionCascade`.
|
|
13
|
+
class ReactionCascade < Node
|
|
14
|
+
attr_accessor :steps
|
|
15
|
+
|
|
16
|
+
def initialize(steps:)
|
|
17
|
+
@steps = steps
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def value_attributes
|
|
21
|
+
{ steps: steps }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def children
|
|
25
|
+
steps
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_s
|
|
29
|
+
"ReactionCascade(#{steps.map(&:to_s).join(' >> ')})"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Model
|
|
5
|
+
# A run of plain text — operators, whitespace, anything the grammar
|
|
6
|
+
# doesn't promote to a typed node.
|
|
7
|
+
class Text < Node
|
|
8
|
+
attr_accessor :content
|
|
9
|
+
|
|
10
|
+
def initialize(content:)
|
|
11
|
+
@content = content
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def value_attributes
|
|
15
|
+
{ content: content }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
"Text(#{content.inspect})"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# Semantic model. Every parse produces a tree of Model instances, and
|
|
5
|
+
# every formatter consumes the same tree. Formatters visit via
|
|
6
|
+
# double-dispatch (`node.accept(formatter)` -> `formatter.visit_<class>`),
|
|
7
|
+
# keeping both sides open for extension.
|
|
8
|
+
module Model
|
|
9
|
+
autoload :Atom, "asciichem/model/atom"
|
|
10
|
+
autoload :Bond, "asciichem/model/bond"
|
|
11
|
+
autoload :ElectronConfiguration, "asciichem/model/electron_configuration"
|
|
12
|
+
autoload :EmbeddedMath, "asciichem/model/embedded_math"
|
|
13
|
+
autoload :Formula, "asciichem/model/formula"
|
|
14
|
+
autoload :Group, "asciichem/model/group"
|
|
15
|
+
autoload :Identifier, "asciichem/model/identifier"
|
|
16
|
+
autoload :Molecule, "asciichem/model/molecule"
|
|
17
|
+
autoload :Name, "asciichem/model/name"
|
|
18
|
+
autoload :Node, "asciichem/model/node"
|
|
19
|
+
autoload :Reaction, "asciichem/model/reaction"
|
|
20
|
+
autoload :ReactionCascade, "asciichem/model/reaction_cascade"
|
|
21
|
+
autoload :Text, "asciichem/model/text"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "chemicalml"
|
|
4
|
+
|
|
5
|
+
module AsciiChem
|
|
6
|
+
module ModelAdapter
|
|
7
|
+
# Chemicalml::Model -> AsciiChem::Model. Walks the canonical tree
|
|
8
|
+
# and rebuilds an AsciiChem::Model::Formula. Pure transformation;
|
|
9
|
+
# no I/O.
|
|
10
|
+
#
|
|
11
|
+
# Mapping rules:
|
|
12
|
+
#
|
|
13
|
+
# - `Chemicalml::Model::Document` -> `Formula`.
|
|
14
|
+
# - `Chemicalml::Model::Molecule` -> `Molecule`. Atoms become
|
|
15
|
+
# AsciiChem atoms with
|
|
16
|
+
# subscript=count. Bonds
|
|
17
|
+
# re-insert between their
|
|
18
|
+
# endpoint positions.
|
|
19
|
+
# - `Chemicalml::Model::Atom` -> `Atom` (element, isotope,
|
|
20
|
+
# charge, lone pairs,
|
|
21
|
+
# radical electrons).
|
|
22
|
+
# - `Chemicalml::Model::Bond` -> `Bond` (kind enum).
|
|
23
|
+
# - `Chemicalml::Model::Reaction` -> `Reaction`.
|
|
24
|
+
# - `Chemicalml::Model::ReactionList` -> `ReactionCascade`.
|
|
25
|
+
#
|
|
26
|
+
# Round-trip note: the canonical model is richer than AsciiChem's
|
|
27
|
+
# (3D coordinates, metadata, etc.). Those fields are dropped on the
|
|
28
|
+
# way back. AsciiChem-specific constructs (Lewis markers, embedded
|
|
29
|
+
# math) round-trip when they ride in the canonical Atom's
|
|
30
|
+
# lone_pairs / radical_electrons fields.
|
|
31
|
+
class FromCanonical
|
|
32
|
+
def initialize(document)
|
|
33
|
+
@document = document
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build
|
|
37
|
+
molecules = @document.molecules.map { |m| molecule_from_canonical(m) }
|
|
38
|
+
reactions = @document.reactions.map { |r| reaction_from_canonical(r) }
|
|
39
|
+
cascades = @document.reaction_lists.map { |l| reaction_list_from_canonical(l) }
|
|
40
|
+
AsciiChem::Model::Formula.new(nodes: molecules + reactions + cascades)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# -- Molecules --------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def molecule_from_canonical(molecule)
|
|
48
|
+
builder = MoleculeRebuilder.new(molecule)
|
|
49
|
+
AsciiChem::Model::Molecule.new(
|
|
50
|
+
nodes: builder.nodes,
|
|
51
|
+
coefficient: molecule.count,
|
|
52
|
+
names: extract_names(molecule.names),
|
|
53
|
+
identifiers: extract_identifiers(molecule.identifiers),
|
|
54
|
+
title: molecule.title,
|
|
55
|
+
formulas: extract_formulas(molecule.formulas),
|
|
56
|
+
properties: extract_properties(molecule.properties),
|
|
57
|
+
labels: extract_labels(molecule.labels)
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def extract_names(canonical_names)
|
|
62
|
+
return [] if canonical_names.nil? || canonical_names.empty?
|
|
63
|
+
|
|
64
|
+
canonical_names.map do |n|
|
|
65
|
+
AsciiChem::Model::Name.new(
|
|
66
|
+
content: n.content,
|
|
67
|
+
convention: n.convention,
|
|
68
|
+
dict_ref: n.dict_ref
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def extract_identifiers(canonical_identifiers)
|
|
74
|
+
return [] if canonical_identifiers.nil? || canonical_identifiers.empty?
|
|
75
|
+
|
|
76
|
+
canonical_identifiers.map do |i|
|
|
77
|
+
AsciiChem::Model::Identifier.new(
|
|
78
|
+
value: i.value,
|
|
79
|
+
convention: i.convention,
|
|
80
|
+
dict_ref: i.dict_ref
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def extract_formulas(canonical_formulas)
|
|
86
|
+
return [] if canonical_formulas.nil? || canonical_formulas.empty?
|
|
87
|
+
|
|
88
|
+
canonical_formulas.map do |f|
|
|
89
|
+
{
|
|
90
|
+
concise: f.concise,
|
|
91
|
+
inline: f.inline,
|
|
92
|
+
formal_charge: f.formal_charge,
|
|
93
|
+
count: f.count,
|
|
94
|
+
title: f.title,
|
|
95
|
+
convention: f.convention,
|
|
96
|
+
dict_ref: f.dict_ref
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def extract_properties(canonical_properties)
|
|
102
|
+
return [] if canonical_properties.nil? || canonical_properties.empty?
|
|
103
|
+
|
|
104
|
+
canonical_properties.map do |p|
|
|
105
|
+
{
|
|
106
|
+
title: p.title,
|
|
107
|
+
value: extract_scalar_value(p.value),
|
|
108
|
+
dict_ref: p.dict_ref,
|
|
109
|
+
convention: p.convention
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def extract_scalar_value(value)
|
|
115
|
+
return nil if value.nil?
|
|
116
|
+
return value.value if value.is_a?(Chemicalml::Model::Scalar)
|
|
117
|
+
|
|
118
|
+
value.to_s
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def extract_labels(canonical_labels)
|
|
122
|
+
return [] if canonical_labels.nil? || canonical_labels.empty?
|
|
123
|
+
|
|
124
|
+
canonical_labels.map do |l|
|
|
125
|
+
{
|
|
126
|
+
value: l.value,
|
|
127
|
+
dict_ref: l.dict_ref,
|
|
128
|
+
convention: l.convention
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# -- Reactions --------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def reaction_from_canonical(reaction)
|
|
136
|
+
AsciiChem::Model::Reaction.new(
|
|
137
|
+
reactants: reactants_from_canonical(reaction.reactant_list),
|
|
138
|
+
products: products_from_canonical(reaction.product_list),
|
|
139
|
+
arrow: reaction.arrow,
|
|
140
|
+
conditions: conditions_from_canonical(reaction)
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def reaction_list_from_canonical(list)
|
|
145
|
+
AsciiChem::Model::ReactionCascade.new(
|
|
146
|
+
steps: list.reactions.map { |r| reaction_from_canonical(r) }
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def reactants_from_canonical(list)
|
|
151
|
+
return [] unless list
|
|
152
|
+
|
|
153
|
+
list.reactants.map { |r| molecule_from_canonical(r.substance.molecule) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def products_from_canonical(list)
|
|
157
|
+
return [] unless list
|
|
158
|
+
|
|
159
|
+
list.products.map { |p| molecule_from_canonical(p.substance.molecule) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def conditions_from_canonical(reaction)
|
|
163
|
+
above = reaction.conditions_above
|
|
164
|
+
below = reaction.conditions_below
|
|
165
|
+
return nil unless above || below
|
|
166
|
+
|
|
167
|
+
AsciiChem::Model::Reaction::Conditions.new(above: above, below: below)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Rebuilds an AsciiChem::Model::Molecule's node list from a
|
|
171
|
+
# canonical molecule. Bonds in the canonical model reference
|
|
172
|
+
# atom IDs; AsciiChem bonds are positional, sitting between two
|
|
173
|
+
# adjacent atoms. The rebuilder builds an ID-to-position map and
|
|
174
|
+
# inserts each bond just before its later endpoint, in
|
|
175
|
+
# descending later-position order so earlier insertions don't
|
|
176
|
+
# shift pending positions.
|
|
177
|
+
class MoleculeRebuilder
|
|
178
|
+
attr_reader :nodes
|
|
179
|
+
|
|
180
|
+
def initialize(molecule)
|
|
181
|
+
@molecule = molecule
|
|
182
|
+
@position_by_atom_id = {}
|
|
183
|
+
molecule.atoms.each_with_index do |atom, idx|
|
|
184
|
+
@position_by_atom_id[atom.id] = idx
|
|
185
|
+
end
|
|
186
|
+
@nodes = build_nodes
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def build_nodes
|
|
192
|
+
atoms = @molecule.atoms.map { |a| atom_from_canonical(a) }
|
|
193
|
+
return atoms if @molecule.bonds.empty?
|
|
194
|
+
|
|
195
|
+
insert_bonds(atoms)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def atom_from_canonical(atom)
|
|
199
|
+
AsciiChem::Model::Atom.new(
|
|
200
|
+
element: atom.element,
|
|
201
|
+
isotope: atom.isotope,
|
|
202
|
+
subscript: subscript_from_count(atom.count),
|
|
203
|
+
charge: atom.formal_charge,
|
|
204
|
+
lone_pairs: atom.lone_pairs,
|
|
205
|
+
radical_electrons: atom.radical_electrons,
|
|
206
|
+
**extract_coordinates(atom)
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Read 2D or 3D coordinates from canonical atom. Prefer 3D
|
|
211
|
+
# (x3/y3/z3) if present; fall back to 2D (x2/y2).
|
|
212
|
+
def extract_coordinates(atom)
|
|
213
|
+
if atom.x3 && atom.y3
|
|
214
|
+
{ x2: atom.x3.to_f, y2: atom.y3.to_f,
|
|
215
|
+
z2: atom.z3&.to_f }
|
|
216
|
+
elsif atom.x2 && atom.y2
|
|
217
|
+
{ x2: atom.x2.to_f, y2: atom.y2.to_f }
|
|
218
|
+
else
|
|
219
|
+
{}
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def subscript_from_count(count)
|
|
224
|
+
return nil if count.nil? || count.to_s == "1"
|
|
225
|
+
|
|
226
|
+
count.to_s
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def insert_bonds(atoms)
|
|
230
|
+
result = atoms.dup
|
|
231
|
+
# Insert in descending position order so earlier insertions
|
|
232
|
+
# don't shift the indices of pending ones.
|
|
233
|
+
bonds_with_pos = @molecule.bonds.map { |b| [b, later_position(b)] }
|
|
234
|
+
bonds_with_pos.sort_by { |(_, pos)| -pos }.each do |bond, pos|
|
|
235
|
+
# Ring bonds connect non-adjacent atoms. They're represented
|
|
236
|
+
# by the ring_closures digit carried via aci: extension on
|
|
237
|
+
# the atoms, not as a positional bond in the node list.
|
|
238
|
+
next if ring_bond?(bond)
|
|
239
|
+
|
|
240
|
+
result.insert(pos, AsciiChem::Model::Bond.new(kind: bond.kind))
|
|
241
|
+
end
|
|
242
|
+
result
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Position of the later endpoint of the bond — i.e. where the
|
|
246
|
+
# bond marker should sit in the rebuilt linear sequence.
|
|
247
|
+
def later_position(bond)
|
|
248
|
+
positions = bond.atom_refs.map { |id| @position_by_atom_id[id] }.compact
|
|
249
|
+
return 0 if positions.empty?
|
|
250
|
+
|
|
251
|
+
positions.max
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# A ring bond connects atoms that are not positionally
|
|
255
|
+
# adjacent — its endpoints span a gap. Such bonds are
|
|
256
|
+
# represented by ring_closures digits, not positional markers.
|
|
257
|
+
def ring_bond?(bond)
|
|
258
|
+
positions = bond.atom_refs.map { |id| @position_by_atom_id[id] }.compact
|
|
259
|
+
return false if positions.length < 2
|
|
260
|
+
|
|
261
|
+
positions.max - positions.min > 1
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def first_position(bond)
|
|
265
|
+
positions = bond.atom_refs.map { |id| @position_by_atom_id[id] }.compact
|
|
266
|
+
positions.min || 0
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
private_constant :MoleculeRebuilder
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|