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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +21 -0
  3. data/.gitignore +14 -0
  4. data/.rubocop.yml +50 -0
  5. data/ARCHITECTURE.adoc +239 -0
  6. data/CHANGELOG.md +17 -0
  7. data/CLAUDE.md +318 -0
  8. data/Gemfile +14 -0
  9. data/LICENSE +24 -0
  10. data/README.adoc +55 -0
  11. data/RELEASING.md +102 -0
  12. data/Rakefile +8 -0
  13. data/asciichem.gemspec +39 -0
  14. data/benchmarks/RESULTS.md +49 -0
  15. data/benchmarks/benchmark.rb +106 -0
  16. data/exe/asciichem +6 -0
  17. data/lib/asciichem/cli.rb +96 -0
  18. data/lib/asciichem/cml/extensions.rb +360 -0
  19. data/lib/asciichem/cml/group_extensions.rb +290 -0
  20. data/lib/asciichem/cml/translator.rb +69 -0
  21. data/lib/asciichem/cml.rb +40 -0
  22. data/lib/asciichem/errors.rb +9 -0
  23. data/lib/asciichem/formatter/base.rb +21 -0
  24. data/lib/asciichem/formatter/html.rb +111 -0
  25. data/lib/asciichem/formatter/latex.rb +173 -0
  26. data/lib/asciichem/formatter/mathml.rb +309 -0
  27. data/lib/asciichem/formatter/structural_svg.rb +286 -0
  28. data/lib/asciichem/formatter/svg.rb +141 -0
  29. data/lib/asciichem/formatter/text.rb +143 -0
  30. data/lib/asciichem/formatter.rb +43 -0
  31. data/lib/asciichem/grammar.rb +344 -0
  32. data/lib/asciichem/greek.rb +80 -0
  33. data/lib/asciichem/layout.rb +313 -0
  34. data/lib/asciichem/linter/balance_check.rb +103 -0
  35. data/lib/asciichem/linter/base.rb +52 -0
  36. data/lib/asciichem/linter/bracket_balance_check.rb +44 -0
  37. data/lib/asciichem/linter/diagnostic.rb +16 -0
  38. data/lib/asciichem/linter/element_validation_check.rb +39 -0
  39. data/lib/asciichem/linter/isotope_sanity_check.rb +47 -0
  40. data/lib/asciichem/linter/registry.rb +31 -0
  41. data/lib/asciichem/linter/unclosed_ring_check.rb +27 -0
  42. data/lib/asciichem/linter/valence_check.rb +91 -0
  43. data/lib/asciichem/linter.rb +40 -0
  44. data/lib/asciichem/model/atom.rb +73 -0
  45. data/lib/asciichem/model/bond.rb +41 -0
  46. data/lib/asciichem/model/electron_configuration.rb +36 -0
  47. data/lib/asciichem/model/embedded_math.rb +26 -0
  48. data/lib/asciichem/model/formula.rb +31 -0
  49. data/lib/asciichem/model/group.rb +50 -0
  50. data/lib/asciichem/model/identifier.rb +14 -0
  51. data/lib/asciichem/model/molecule.rb +70 -0
  52. data/lib/asciichem/model/name.rb +15 -0
  53. data/lib/asciichem/model/node.rb +100 -0
  54. data/lib/asciichem/model/reaction.rb +49 -0
  55. data/lib/asciichem/model/reaction_cascade.rb +33 -0
  56. data/lib/asciichem/model/text.rb +23 -0
  57. data/lib/asciichem/model.rb +23 -0
  58. data/lib/asciichem/model_adapter/from_canonical.rb +272 -0
  59. data/lib/asciichem/model_adapter/to_canonical.rb +429 -0
  60. data/lib/asciichem/model_adapter.rb +56 -0
  61. data/lib/asciichem/parser.rb +36 -0
  62. data/lib/asciichem/periodic_table.rb +112 -0
  63. data/lib/asciichem/ring_bonds.rb +68 -0
  64. data/lib/asciichem/transform.rb +513 -0
  65. data/lib/asciichem/version.rb +5 -0
  66. data/lib/asciichem/xml_builder.rb +9 -0
  67. data/lib/asciichem.rb +34 -0
  68. 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