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,360 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module AsciiChem
|
|
6
|
+
module Cml
|
|
7
|
+
# Carries AsciiChem-specific fields through CML round-trip via an
|
|
8
|
+
# `aci:` (AsciiChem extension) namespace. CML's standard wire
|
|
9
|
+
# format covers element, isotope, charge, count, hydrogen count,
|
|
10
|
+
# and spin multiplicity — but not oxidation state, lone pairs,
|
|
11
|
+
# radical electrons, or anything else AsciiChem might add. Without
|
|
12
|
+
# this side channel, those fields are silently dropped on
|
|
13
|
+
# AsciiChem → CML → AsciiChem round-trip.
|
|
14
|
+
#
|
|
15
|
+
# The mechanism: post-process the CML XML emitted by chemicalml
|
|
16
|
+
# to add `aci:` attributes for atoms that carry extension data.
|
|
17
|
+
# On parse, read the `aci:` attributes back. CML tools that don't
|
|
18
|
+
# recognise the namespace simply ignore the attributes — schema
|
|
19
|
+
# validity is preserved.
|
|
20
|
+
#
|
|
21
|
+
# Adding a new extension field is one entry in `FIELDS` and one
|
|
22
|
+
# writer/reader pair. No other code changes. This is the OCP
|
|
23
|
+
# extension point for "fields CML doesn't natively carry".
|
|
24
|
+
module Extensions
|
|
25
|
+
NAMESPACE = 'https://asciichem.org/cml-ext'
|
|
26
|
+
PREFIX = 'aci'
|
|
27
|
+
CML_NS = 'http://www.xml-cml.org/schema'
|
|
28
|
+
|
|
29
|
+
# Map of AsciiChem::Model::Atom attribute name (Symbol) to the
|
|
30
|
+
# wire attribute name (without prefix). Each entry produces a
|
|
31
|
+
# corresponding `aci:<wire_name>` attribute in the CML output.
|
|
32
|
+
FIELDS = {
|
|
33
|
+
oxidation_state: 'oxidationState',
|
|
34
|
+
lone_pairs: 'lonePairs',
|
|
35
|
+
radical_electrons: 'radicalElectrons',
|
|
36
|
+
ring_closures: 'ringClosures',
|
|
37
|
+
atom_parity: 'atomParity'
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# -- AsciiChem -> CML ------------------------------------------
|
|
41
|
+
|
|
42
|
+
# Build the extensions map: `{ atom_id => { field: value } }`.
|
|
43
|
+
# Values are Ruby-native (Integer for counts, String for
|
|
44
|
+
# oxidation state). Atoms without extension data are omitted
|
|
45
|
+
# from the map (so the CML output stays clean for plain atoms).
|
|
46
|
+
def self.collect(atom_mapping)
|
|
47
|
+
atom_mapping.each_with_object({}) do |(atom_id, source_atom), memo|
|
|
48
|
+
data = build_entry(source_atom)
|
|
49
|
+
memo[atom_id] = data unless data.empty?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.build_entry(atom)
|
|
54
|
+
FIELDS.each_with_object({}) do |(attr_name, _wire_name), entry|
|
|
55
|
+
value = atom.public_send(attr_name)
|
|
56
|
+
entry[attr_name] = value if value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
private_class_method :build_entry
|
|
60
|
+
|
|
61
|
+
# Inject aci: attributes into a CML XML string. Returns the
|
|
62
|
+
# modified XML. No-op if the extensions map is empty.
|
|
63
|
+
def self.inject(xml, extensions)
|
|
64
|
+
return xml if extensions.empty?
|
|
65
|
+
|
|
66
|
+
doc = Nokogiri::XML(xml)
|
|
67
|
+
root = doc.root
|
|
68
|
+
root.add_namespace(PREFIX, NAMESPACE) unless namespace_declared?(root)
|
|
69
|
+
|
|
70
|
+
extensions.each do |atom_id, entry|
|
|
71
|
+
atom_el = root.at_xpath("//cml:atom[@id='#{atom_id}']", cml: CML_NS)
|
|
72
|
+
next unless atom_el
|
|
73
|
+
|
|
74
|
+
entry.each do |attr_name, value|
|
|
75
|
+
wire_name = FIELDS.fetch(attr_name)
|
|
76
|
+
atom_el["#{PREFIX}:#{wire_name}"] = value.to_s
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
doc.to_xml
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.namespace_declared?(root)
|
|
84
|
+
root.namespaces.value?(NAMESPACE)
|
|
85
|
+
end
|
|
86
|
+
private_class_method :namespace_declared?
|
|
87
|
+
|
|
88
|
+
# -- CML -> AsciiChem ------------------------------------------
|
|
89
|
+
|
|
90
|
+
# Extract aci: attributes from a CML XML string. Returns a map
|
|
91
|
+
# `{ atom_id => { field: value } }` with Ruby-native types
|
|
92
|
+
# (Integer for counts, String for oxidation state). Empty if no
|
|
93
|
+
# aci: attributes are present.
|
|
94
|
+
def self.extract(xml)
|
|
95
|
+
doc = Nokogiri::XML(xml)
|
|
96
|
+
result = {}
|
|
97
|
+
doc.xpath('//cml:atom', cml: CML_NS).each do |atom_el|
|
|
98
|
+
atom_id = atom_el['id']
|
|
99
|
+
next unless atom_id
|
|
100
|
+
|
|
101
|
+
entry = read_entry(atom_el)
|
|
102
|
+
result[atom_id] = entry unless entry.empty?
|
|
103
|
+
end
|
|
104
|
+
result
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.read_entry(atom_el)
|
|
108
|
+
FIELDS.each_with_object({}) do |(attr_name, wire_name), entry|
|
|
109
|
+
value = atom_el["#{PREFIX}:#{wire_name}"]
|
|
110
|
+
entry[attr_name] = cast_from_xml(attr_name, value) if value
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
private_class_method :read_entry
|
|
114
|
+
|
|
115
|
+
# Cast an XML string back to its Ruby form. Integer counts
|
|
116
|
+
# become Integers; everything else stays a string.
|
|
117
|
+
def self.cast_from_xml(attr_name, value)
|
|
118
|
+
case attr_name
|
|
119
|
+
when :lone_pairs, :radical_electrons then value.to_i
|
|
120
|
+
else value
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
private_class_method :cast_from_xml
|
|
124
|
+
|
|
125
|
+
# -- Round-trip restore ----------------------------------------
|
|
126
|
+
|
|
127
|
+
# Apply extracted extension data to a freshly-parsed
|
|
128
|
+
# AsciiChem::Model::Formula. The `canonical_doc` is the
|
|
129
|
+
# canonical document that produced the formula; it provides
|
|
130
|
+
# the atom-id ordering. Atoms are walked in parallel.
|
|
131
|
+
def self.restore(formula, canonical_doc, extensions)
|
|
132
|
+
return formula if extensions.empty?
|
|
133
|
+
|
|
134
|
+
canonical_atoms = flatten_canonical_atoms(canonical_doc)
|
|
135
|
+
formula_atoms = flatten_formula_atoms(formula)
|
|
136
|
+
|
|
137
|
+
canonical_atoms.each_with_index do |canon_atom, idx|
|
|
138
|
+
entry = extensions[canon_atom.id]
|
|
139
|
+
next unless entry
|
|
140
|
+
|
|
141
|
+
target = formula_atoms[idx]
|
|
142
|
+
next unless target
|
|
143
|
+
|
|
144
|
+
apply_entry(target, entry)
|
|
145
|
+
end
|
|
146
|
+
formula
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def self.apply_entry(atom, entry)
|
|
150
|
+
entry.each do |attr_name, value|
|
|
151
|
+
atom.public_send(:"#{attr_name}=", value)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
private_class_method :apply_entry
|
|
155
|
+
|
|
156
|
+
# Flatten the canonical document's atoms into a single list,
|
|
157
|
+
# in the same order ToCanonical walked them: top-level molecules,
|
|
158
|
+
# then reaction reactants+products, then cascade reactions.
|
|
159
|
+
def self.flatten_canonical_atoms(canonical_doc)
|
|
160
|
+
atoms = []
|
|
161
|
+
canonical_doc.molecules.each { |m| atoms.concat(m.atoms) }
|
|
162
|
+
canonical_doc.reactions.each do |reaction|
|
|
163
|
+
atoms.concat(flatten_reaction_atoms(reaction))
|
|
164
|
+
end
|
|
165
|
+
canonical_doc.reaction_lists.each do |list|
|
|
166
|
+
list.reactions.each { |r| atoms.concat(flatten_reaction_atoms(r)) }
|
|
167
|
+
end
|
|
168
|
+
atoms
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.flatten_reaction_atoms(reaction)
|
|
172
|
+
atoms = []
|
|
173
|
+
reactants = reaction.reactant_list
|
|
174
|
+
products = reaction.product_list
|
|
175
|
+
reactants&.reactants&.each { |r| atoms.concat(r.substance.molecule.atoms) }
|
|
176
|
+
products&.products&.each { |p| atoms.concat(p.substance.molecule.atoms) }
|
|
177
|
+
atoms
|
|
178
|
+
end
|
|
179
|
+
private_class_method :flatten_reaction_atoms
|
|
180
|
+
|
|
181
|
+
# Flatten the AsciiChem::Model::Formula's atoms in the same
|
|
182
|
+
# order ToCanonical walks them. Matches `flatten_canonical_atoms`
|
|
183
|
+
# one-for-one so parallel iteration by index works.
|
|
184
|
+
def self.flatten_formula_atoms(formula)
|
|
185
|
+
formula.nodes.each_with_object([]) do |node, memo|
|
|
186
|
+
case node
|
|
187
|
+
when AsciiChem::Model::Molecule
|
|
188
|
+
memo.concat(flatten_molecule_atoms(node))
|
|
189
|
+
when AsciiChem::Model::Reaction
|
|
190
|
+
memo.concat(reaction_atoms(node))
|
|
191
|
+
when AsciiChem::Model::ReactionCascade
|
|
192
|
+
node.steps.each { |step| memo.concat(reaction_atoms(step)) }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def self.reaction_atoms(reaction)
|
|
198
|
+
atoms = []
|
|
199
|
+
reaction.reactants.each { |m| atoms.concat(flatten_molecule_atoms(m)) }
|
|
200
|
+
reaction.products.each { |m| atoms.concat(flatten_molecule_atoms(m)) }
|
|
201
|
+
atoms
|
|
202
|
+
end
|
|
203
|
+
private_class_method :reaction_atoms
|
|
204
|
+
|
|
205
|
+
def self.flatten_molecule_atoms(molecule)
|
|
206
|
+
molecule.nodes.each_with_object([]) do |node, memo|
|
|
207
|
+
case node
|
|
208
|
+
when AsciiChem::Model::Atom
|
|
209
|
+
memo << node
|
|
210
|
+
when AsciiChem::Model::Group, AsciiChem::Model::Molecule
|
|
211
|
+
memo.concat(flatten_molecule_atoms(node))
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
private_class_method :flatten_molecule_atoms
|
|
216
|
+
|
|
217
|
+
# -- Top-level extensions --------------------------------------
|
|
218
|
+
#
|
|
219
|
+
# AsciiChem::Model node classes that have no canonical equivalent
|
|
220
|
+
# (ElectronConfiguration, EmbeddedMath) ride as `aci:` elements
|
|
221
|
+
# inside `<cml>`. Position in the original formula's node list is
|
|
222
|
+
# preserved via a `position` attribute so restore can re-insert
|
|
223
|
+
# at the right index. The wire format for each handler is the
|
|
224
|
+
# AsciiChem text rendering — round-trip-safe by construction.
|
|
225
|
+
|
|
226
|
+
# Registry of top-level handlers. Each handler is a Struct with
|
|
227
|
+
# `node_class`, `element_name`, `serialize`, `deserialize`.
|
|
228
|
+
# Adding a new top-level extension is one handler entry.
|
|
229
|
+
TopLevelHandler = Struct.new(:node_class, :element_name,
|
|
230
|
+
:serialize, :deserialize, keyword_init: true)
|
|
231
|
+
|
|
232
|
+
TOP_LEVEL_HANDLERS = [
|
|
233
|
+
TopLevelHandler.new(
|
|
234
|
+
node_class: AsciiChem::Model::ElectronConfiguration,
|
|
235
|
+
element_name: 'electronConfiguration',
|
|
236
|
+
serialize: ->(node) { text_render(node) },
|
|
237
|
+
deserialize: ->(content) { AsciiChem.parse(content).nodes.first }
|
|
238
|
+
),
|
|
239
|
+
TopLevelHandler.new(
|
|
240
|
+
node_class: AsciiChem::Model::EmbeddedMath,
|
|
241
|
+
element_name: 'embeddedMath',
|
|
242
|
+
serialize: ->(node) { node.source.to_s },
|
|
243
|
+
deserialize: ->(content) { AsciiChem.parse("`#{content}`").nodes.first }
|
|
244
|
+
),
|
|
245
|
+
TopLevelHandler.new(
|
|
246
|
+
node_class: AsciiChem::Model::Text,
|
|
247
|
+
element_name: 'text',
|
|
248
|
+
# Text formatter emits `"content"` (with quotes). Re-parsing
|
|
249
|
+
# that yields the original Text node — round-trip-safe by
|
|
250
|
+
# construction.
|
|
251
|
+
serialize: ->(node) { text_render(node) },
|
|
252
|
+
deserialize: ->(content) { AsciiChem.parse(content).nodes.first }
|
|
253
|
+
)
|
|
254
|
+
].freeze
|
|
255
|
+
|
|
256
|
+
# Build the top-level extensions list from a formula. Returns an
|
|
257
|
+
# array of `{ position:, element_name:, content: }` hashes. The
|
|
258
|
+
# position is the index in the original formula's node list.
|
|
259
|
+
def self.collect_top_level(formula)
|
|
260
|
+
handlers_by_class = top_level_handlers_by_class
|
|
261
|
+
formula.nodes.each_with_index.with_object([]) do |(node, idx), memo|
|
|
262
|
+
handler = handlers_by_class[node.class]
|
|
263
|
+
next unless handler
|
|
264
|
+
|
|
265
|
+
memo << {
|
|
266
|
+
position: idx,
|
|
267
|
+
element_name: handler.element_name,
|
|
268
|
+
content: handler.serialize.call(node)
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Inject top-level extensions into CML XML as `aci:` elements
|
|
274
|
+
# inside `<cml>`. No-op if the list is empty.
|
|
275
|
+
def self.inject_top_level(xml, top_level)
|
|
276
|
+
return xml if top_level.empty?
|
|
277
|
+
|
|
278
|
+
doc = Nokogiri::XML(xml)
|
|
279
|
+
root = doc.root
|
|
280
|
+
ensure_namespace(root)
|
|
281
|
+
top_level.each { |entry| insert_top_level_element(doc, root, entry) }
|
|
282
|
+
doc.to_xml
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def self.ensure_namespace(root)
|
|
286
|
+
root.add_namespace(PREFIX, NAMESPACE) unless namespace_declared?(root)
|
|
287
|
+
end
|
|
288
|
+
private_class_method :ensure_namespace
|
|
289
|
+
|
|
290
|
+
def self.insert_top_level_element(doc, root, entry)
|
|
291
|
+
element = doc.create_element("#{PREFIX}:#{entry[:element_name]}")
|
|
292
|
+
element['position'] = entry[:position].to_s
|
|
293
|
+
element.content = entry[:content]
|
|
294
|
+
# Insert before existing children so extensions appear at the
|
|
295
|
+
# top of <cml>, which reads more naturally than appended.
|
|
296
|
+
root.children.first&.add_previous_sibling(element) || root.add_child(element)
|
|
297
|
+
end
|
|
298
|
+
private_class_method :insert_top_level_element
|
|
299
|
+
|
|
300
|
+
# Extract `aci:` top-level elements from CML XML. Returns an
|
|
301
|
+
# array of `{ position:, element_name:, content: }` hashes in
|
|
302
|
+
# ascending position order.
|
|
303
|
+
def self.extract_top_level(xml)
|
|
304
|
+
doc = Nokogiri::XML(xml)
|
|
305
|
+
result = []
|
|
306
|
+
element_names = TOP_LEVEL_HANDLERS.map(&:element_name)
|
|
307
|
+
element_names.each do |name|
|
|
308
|
+
doc.xpath("//#{PREFIX}:#{name}", PREFIX => NAMESPACE).each do |el|
|
|
309
|
+
result << {
|
|
310
|
+
position: (el['position'] || 0).to_i,
|
|
311
|
+
element_name: name,
|
|
312
|
+
content: el.content
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
result.sort_by { |entry| entry[:position] }
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Restore top-level extension nodes into a freshly-parsed
|
|
320
|
+
# formula. Inserts each node at its original position; nodes
|
|
321
|
+
# are inserted in ascending position order so earlier inserts
|
|
322
|
+
# don't shift later positions.
|
|
323
|
+
def self.restore_top_level(formula, top_level)
|
|
324
|
+
handlers_by_element = top_level_handlers_by_element
|
|
325
|
+
top_level.sort_by { |entry| entry[:position] }.each do |entry|
|
|
326
|
+
handler = handlers_by_element[entry[:element_name]]
|
|
327
|
+
next unless handler
|
|
328
|
+
|
|
329
|
+
node = handler.deserialize.call(entry[:content])
|
|
330
|
+
next unless node
|
|
331
|
+
|
|
332
|
+
pos = [entry[:position], formula.nodes.length].min
|
|
333
|
+
formula.nodes.insert(pos, node)
|
|
334
|
+
end
|
|
335
|
+
formula
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def self.top_level_handlers_by_class
|
|
339
|
+
TOP_LEVEL_HANDLERS.to_h do |h|
|
|
340
|
+
[h.node_class, h]
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
private_class_method :top_level_handlers_by_class
|
|
344
|
+
|
|
345
|
+
def self.top_level_handlers_by_element
|
|
346
|
+
TOP_LEVEL_HANDLERS.to_h do |h|
|
|
347
|
+
[h.element_name, h]
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
private_class_method :top_level_handlers_by_element
|
|
351
|
+
|
|
352
|
+
# Render a node using the AsciiChem Text formatter. The output
|
|
353
|
+
# is round-trip-safe by construction (Text is the canonicaliser).
|
|
354
|
+
def self.text_render(node)
|
|
355
|
+
AsciiChem::Formatter.render(:text, node)
|
|
356
|
+
end
|
|
357
|
+
private_class_method :text_render
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module AsciiChem
|
|
6
|
+
module Cml
|
|
7
|
+
# Preserves AsciiChem::Model::Group structure through CML round-trip.
|
|
8
|
+
#
|
|
9
|
+
# CML has no native group concept — the canonical model flattens
|
|
10
|
+
# `(OH)_2` to `<atom elementType="O" count="2"/><atom elementType="H" count="2"/>`.
|
|
11
|
+
# The aci: namespace records the original grouping so AsciiChem
|
|
12
|
+
# can rebuild it on parse:
|
|
13
|
+
#
|
|
14
|
+
# <molecule id="m1">
|
|
15
|
+
# <atomArray>
|
|
16
|
+
# <atom id="a1" elementType="O" count="2"/>
|
|
17
|
+
# <atom id="a2" elementType="H" count="2"/>
|
|
18
|
+
# </atomArray>
|
|
19
|
+
# <aci:group multiplicity="2" bracket="paren" atomRefs="a1 a2"/>
|
|
20
|
+
# </molecule>
|
|
21
|
+
#
|
|
22
|
+
# The mechanism parallels `Extensions` (atom attributes) and the
|
|
23
|
+
# top-level handlers, but operates at molecule scope: each
|
|
24
|
+
# `<aci:group>` lives inside its parent `<molecule>` and references
|
|
25
|
+
# atoms by ID. Nested groups in AsciiChem produce multiple
|
|
26
|
+
# `<aci:group>` elements with overlapping `atomRefs` — the parent
|
|
27
|
+
# group's refs include the inner group's atoms.
|
|
28
|
+
#
|
|
29
|
+
# On restore, the canonical adapter has already applied the
|
|
30
|
+
# multiplicity to atom counts. GroupExtensions reverses this:
|
|
31
|
+
# divides each referenced atom's count by the group's multiplicity
|
|
32
|
+
# (rounding to nil when the result is 1) and wraps the atoms in an
|
|
33
|
+
# AsciiChem::Model::Group.
|
|
34
|
+
module GroupExtensions
|
|
35
|
+
NAMESPACE = 'https://asciichem.org/cml-ext'
|
|
36
|
+
PREFIX = 'aci'
|
|
37
|
+
CML_NS = 'http://www.xml-cml.org/schema'
|
|
38
|
+
|
|
39
|
+
# AsciiChem bracket symbols → wire strings.
|
|
40
|
+
BRACKET_TO_WIRE = { paren: 'paren', square: 'square', brace: 'brace' }.freeze
|
|
41
|
+
WIRE_TO_BRACKET = BRACKET_TO_WIRE.invert.freeze
|
|
42
|
+
|
|
43
|
+
# -- AsciiChem -> CML ------------------------------------------
|
|
44
|
+
|
|
45
|
+
# Build the group extensions map: `{ molecule_id => [group_record, ...] }`.
|
|
46
|
+
# Molecules without groups are omitted (keeps the CML clean for
|
|
47
|
+
# ungrouped molecules).
|
|
48
|
+
def self.collect(groups_by_molecule)
|
|
49
|
+
groups_by_molecule.reject { |_, groups| groups.empty? }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Inject `<aci:group>` elements into each referenced molecule.
|
|
53
|
+
# No-op when the map is empty.
|
|
54
|
+
def self.inject(xml, groups_by_molecule)
|
|
55
|
+
return xml if groups_by_molecule.empty?
|
|
56
|
+
|
|
57
|
+
doc = Nokogiri::XML(xml)
|
|
58
|
+
root = doc.root
|
|
59
|
+
root.add_namespace(PREFIX, NAMESPACE) unless namespace_declared?(root)
|
|
60
|
+
|
|
61
|
+
groups_by_molecule.each do |molecule_id, groups|
|
|
62
|
+
molecule_el = root.at_xpath("//cml:molecule[@id='#{molecule_id}']", cml: CML_NS)
|
|
63
|
+
next unless molecule_el
|
|
64
|
+
|
|
65
|
+
groups.each { |record| molecule_el.add_child(build_group_element(doc, record)) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
doc.to_xml
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.build_group_element(doc, record)
|
|
72
|
+
el = doc.create_element("#{PREFIX}:group")
|
|
73
|
+
el['multiplicity'] = record.multiplicity.to_s if record.multiplicity
|
|
74
|
+
el['bracket'] = BRACKET_TO_WIRE.fetch(record.bracket, 'paren')
|
|
75
|
+
el['atomRefs'] = record.atom_ids.join(' ')
|
|
76
|
+
el
|
|
77
|
+
end
|
|
78
|
+
private_class_method :build_group_element
|
|
79
|
+
|
|
80
|
+
def self.namespace_declared?(root)
|
|
81
|
+
root.namespaces.value?(NAMESPACE)
|
|
82
|
+
end
|
|
83
|
+
private_class_method :namespace_declared?
|
|
84
|
+
|
|
85
|
+
# -- CML -> AsciiChem ------------------------------------------
|
|
86
|
+
|
|
87
|
+
# Extract `<aci:group>` elements, keyed by their parent
|
|
88
|
+
# molecule's ID. Each value is an array of record hashes:
|
|
89
|
+
# `{ multiplicity:, bracket:, atom_ids: }`.
|
|
90
|
+
def self.extract(xml)
|
|
91
|
+
doc = Nokogiri::XML(xml)
|
|
92
|
+
result = Hash.new { |h, k| h[k] = [] }
|
|
93
|
+
doc.xpath('//cml:molecule', cml: CML_NS).each do |mol_el|
|
|
94
|
+
group_els = mol_el.xpath("./#{PREFIX}:group", PREFIX => NAMESPACE)
|
|
95
|
+
next if group_els.empty?
|
|
96
|
+
|
|
97
|
+
mol_el['id']&.then do |molecule_id|
|
|
98
|
+
group_els.each { |g| result[molecule_id] << read_group(g) }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
result
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.read_group(el)
|
|
105
|
+
atom_ids = (el['atomRefs'] || '').split
|
|
106
|
+
{
|
|
107
|
+
multiplicity: el['multiplicity'],
|
|
108
|
+
bracket: WIRE_TO_BRACKET.fetch(el['bracket'], :paren),
|
|
109
|
+
atom_ids: atom_ids
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
private_class_method :read_group
|
|
113
|
+
|
|
114
|
+
# -- Round-trip restore ----------------------------------------
|
|
115
|
+
#
|
|
116
|
+
# Walks the rebuilt AsciiChem::Model::Formula's molecules. For
|
|
117
|
+
# each molecule that has group records, rebuilds the Group nodes
|
|
118
|
+
# from the flattened atom list. Atom positions in the rebuilt
|
|
119
|
+
# molecule match the canonical order (a1, a2, ...), so the
|
|
120
|
+
# atom_ids in each record map directly to indices.
|
|
121
|
+
|
|
122
|
+
def self.restore(formula, canonical_doc, groups_by_molecule)
|
|
123
|
+
return formula if groups_by_molecule.empty?
|
|
124
|
+
|
|
125
|
+
canonical_molecules = flatten_canonical_molecules(canonical_doc)
|
|
126
|
+
formula_molecules = flatten_formula_molecules(formula)
|
|
127
|
+
|
|
128
|
+
canonical_molecules.each_with_index do |canon_mol, idx|
|
|
129
|
+
groups = groups_by_molecule[canon_mol.id]
|
|
130
|
+
next unless groups
|
|
131
|
+
|
|
132
|
+
target = formula_molecules[idx]
|
|
133
|
+
next unless target
|
|
134
|
+
|
|
135
|
+
rebuild_groups_in_molecule(target, groups)
|
|
136
|
+
end
|
|
137
|
+
formula
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Rebuilds groups inside a single molecule. Processes records in
|
|
141
|
+
# reverse-entry order so inner groups rebuild before their outer
|
|
142
|
+
# parents — the walker adds outer groups to its @groups array
|
|
143
|
+
# before entering inner groups, so reverse gives innermost-first.
|
|
144
|
+
# For sibling groups (no nesting), reverse order is equivalent.
|
|
145
|
+
def self.rebuild_groups_in_molecule(molecule, groups)
|
|
146
|
+
groups.reverse_each { |record| rebuild_one_group(molecule, record) }
|
|
147
|
+
end
|
|
148
|
+
private_class_method :rebuild_groups_in_molecule
|
|
149
|
+
|
|
150
|
+
def self.rebuild_one_group(molecule, record)
|
|
151
|
+
target_nodes = find_target_nodes(molecule.nodes, record[:atom_ids])
|
|
152
|
+
return if target_nodes.empty?
|
|
153
|
+
|
|
154
|
+
apply_multiplicity_to_atoms(target_nodes, record[:multiplicity])
|
|
155
|
+
splice_group_into(molecule.nodes, target_nodes, record)
|
|
156
|
+
end
|
|
157
|
+
private_class_method :rebuild_one_group
|
|
158
|
+
|
|
159
|
+
# Walks `nodes` in canonical-walk order, tracking which canonical
|
|
160
|
+
# atom ID each position covers. Returns ALL nodes (Atoms, Bonds,
|
|
161
|
+
# AND Groups) between the first and last target atom inclusive.
|
|
162
|
+
# Including Bonds preserves the chain inside the Group.
|
|
163
|
+
def self.find_target_nodes(nodes, atom_ids)
|
|
164
|
+
target_array = atom_ids.to_a
|
|
165
|
+
return [] if target_array.empty?
|
|
166
|
+
|
|
167
|
+
first_pos = nil
|
|
168
|
+
last_pos = nil
|
|
169
|
+
canonical_idx = 0
|
|
170
|
+
nodes.each_with_index do |node, idx|
|
|
171
|
+
atom_count = count_atoms_recursively(node)
|
|
172
|
+
covered_ids = (1..atom_count).map { |i| "a#{canonical_idx + i}" }
|
|
173
|
+
if covered_ids.intersect?(target_array)
|
|
174
|
+
first_pos ||= idx
|
|
175
|
+
last_pos = idx
|
|
176
|
+
end
|
|
177
|
+
canonical_idx += atom_count
|
|
178
|
+
end
|
|
179
|
+
return [] unless first_pos && last_pos
|
|
180
|
+
|
|
181
|
+
nodes[first_pos..last_pos]
|
|
182
|
+
end
|
|
183
|
+
private_class_method :find_target_nodes
|
|
184
|
+
|
|
185
|
+
# Counts the atoms transitively contained in a node. Atoms count
|
|
186
|
+
# as 1; Groups/Molecules recurse and sum.
|
|
187
|
+
def self.count_atoms_recursively(node)
|
|
188
|
+
case node
|
|
189
|
+
when AsciiChem::Model::Atom
|
|
190
|
+
1
|
|
191
|
+
when AsciiChem::Model::Group, AsciiChem::Model::Molecule
|
|
192
|
+
node.nodes.sum { |n| count_atoms_recursively(n) }
|
|
193
|
+
else
|
|
194
|
+
0
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
private_class_method :count_atoms_recursively
|
|
198
|
+
|
|
199
|
+
# Divide each Atom's subscript by the multiplicity. Atoms inside
|
|
200
|
+
# an inner Group have already been divided when the inner Group
|
|
201
|
+
# was processed — leave them alone. Subscripts of 1 (after
|
|
202
|
+
# division) become nil (the default).
|
|
203
|
+
def self.apply_multiplicity_to_atoms(target_nodes, multiplicity)
|
|
204
|
+
return unless multiplicity
|
|
205
|
+
|
|
206
|
+
divisor = multiplicity.to_i
|
|
207
|
+
return unless divisor.positive?
|
|
208
|
+
|
|
209
|
+
target_nodes.each do |node|
|
|
210
|
+
next unless node.is_a?(AsciiChem::Model::Atom)
|
|
211
|
+
next unless node.subscript
|
|
212
|
+
|
|
213
|
+
new_sub = node.subscript.to_i / divisor
|
|
214
|
+
node.subscript = (new_sub == 1 ? nil : new_sub.to_s)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
private_class_method :apply_multiplicity_to_atoms
|
|
218
|
+
|
|
219
|
+
# Replaces the collected target nodes (which must be contiguous
|
|
220
|
+
# in `nodes`) with a single new Group containing them. Uses
|
|
221
|
+
# identity (`equal?`) for position lookup so duplicate Bonds
|
|
222
|
+
# (same kind → `==` equal) aren't confused.
|
|
223
|
+
def self.splice_group_into(nodes, target_nodes, record)
|
|
224
|
+
return if target_nodes.empty?
|
|
225
|
+
|
|
226
|
+
positions = target_nodes.map do |target|
|
|
227
|
+
found = nodes.each_with_index.find { |node, _| node.equal?(target) }
|
|
228
|
+
found&.last
|
|
229
|
+
end.compact
|
|
230
|
+
return if positions.empty?
|
|
231
|
+
|
|
232
|
+
group = AsciiChem::Model::Group.new(
|
|
233
|
+
nodes: target_nodes,
|
|
234
|
+
multiplicity: record[:multiplicity],
|
|
235
|
+
bracket: record[:bracket]
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
first_pos = positions.min
|
|
239
|
+
nodes[first_pos] = group
|
|
240
|
+
# Remove remaining positions in descending order so earlier
|
|
241
|
+
# positions don't shift before deletion.
|
|
242
|
+
positions.reject { |p| p == first_pos }.sort.reverse.each do |pos|
|
|
243
|
+
nodes.delete_at(pos)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
private_class_method :splice_group_into
|
|
247
|
+
|
|
248
|
+
# Flatten the canonical document's molecules (top-level +
|
|
249
|
+
# reaction reactants/products + cascade reactions) into a list,
|
|
250
|
+
# in the same order ToCanonical walked them.
|
|
251
|
+
def self.flatten_canonical_molecules(canonical_doc)
|
|
252
|
+
mols = canonical_doc.molecules.dup
|
|
253
|
+
canonical_doc.reactions.each { |r| mols.concat(flatten_reaction_molecules(r)) }
|
|
254
|
+
canonical_doc.reaction_lists.each do |list|
|
|
255
|
+
list.reactions.each { |r| mols.concat(flatten_reaction_molecules(r)) }
|
|
256
|
+
end
|
|
257
|
+
mols
|
|
258
|
+
end
|
|
259
|
+
private_class_method :flatten_canonical_molecules
|
|
260
|
+
|
|
261
|
+
def self.flatten_reaction_molecules(reaction)
|
|
262
|
+
mols = []
|
|
263
|
+
reaction.reactant_list&.reactants&.each { |r| mols << r.substance.molecule }
|
|
264
|
+
reaction.product_list&.products&.each { |p| mols << p.substance.molecule }
|
|
265
|
+
mols
|
|
266
|
+
end
|
|
267
|
+
private_class_method :flatten_reaction_molecules
|
|
268
|
+
|
|
269
|
+
def self.flatten_formula_molecules(formula)
|
|
270
|
+
mols = []
|
|
271
|
+
formula.nodes.each do |node|
|
|
272
|
+
case node
|
|
273
|
+
when AsciiChem::Model::Molecule
|
|
274
|
+
mols << node
|
|
275
|
+
when AsciiChem::Model::Reaction
|
|
276
|
+
mols.concat(node.reactants)
|
|
277
|
+
mols.concat(node.products)
|
|
278
|
+
when AsciiChem::Model::ReactionCascade
|
|
279
|
+
node.steps.each do |step|
|
|
280
|
+
mols.concat(step.reactants)
|
|
281
|
+
mols.concat(step.products)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
mols
|
|
286
|
+
end
|
|
287
|
+
private_class_method :flatten_formula_molecules
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|