asciichem 0.5.0 → 0.19.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +52 -0
  3. data/ARCHITECTURE.adoc +153 -0
  4. data/CHANGELOG.md +363 -5
  5. data/asciichem.gemspec +7 -4
  6. data/benchmarks/benchmark.rb +6 -1
  7. data/lib/asciichem/cli.rb +23 -1
  8. data/lib/asciichem/cml/conditions_extensions.rb +132 -0
  9. data/lib/asciichem/cml/extensions/atom_attributes.rb +192 -0
  10. data/lib/asciichem/cml/extensions/top_level.rb +186 -0
  11. data/lib/asciichem/cml/extensions.rb +32 -313
  12. data/lib/asciichem/cml/group_extensions.rb +10 -20
  13. data/lib/asciichem/cml/metadata_extensions.rb +145 -0
  14. data/lib/asciichem/cml/opaque_extensions.rb +144 -0
  15. data/lib/asciichem/cml/translator.rb +46 -144
  16. data/lib/asciichem/cml.rb +14 -0
  17. data/lib/asciichem/formatter/html.rb +117 -0
  18. data/lib/asciichem/formatter/latex.rb +117 -0
  19. data/lib/asciichem/formatter/mathml.rb +134 -0
  20. data/lib/asciichem/formatter/structural_svg.rb +38 -0
  21. data/lib/asciichem/formatter/svg.rb +38 -6
  22. data/lib/asciichem/formatter/text.rb +14 -10
  23. data/lib/asciichem/grammar.rb +27 -1
  24. data/lib/asciichem/identifiers/base.rb +25 -0
  25. data/lib/asciichem/identifiers/cas.rb +56 -0
  26. data/lib/asciichem/identifiers/inchi.rb +104 -0
  27. data/lib/asciichem/identifiers/inchikey.rb +33 -0
  28. data/lib/asciichem/identifiers/smiles.rb +147 -0
  29. data/lib/asciichem/identifiers.rb +66 -0
  30. data/lib/asciichem/linter/charge_balance_check.rb +117 -0
  31. data/lib/asciichem/linter/crystal_sanity_check.rb +90 -0
  32. data/lib/asciichem/linter/identifier_consistency_check.rb +100 -0
  33. data/lib/asciichem/linter/identifier_format_check.rb +48 -0
  34. data/lib/asciichem/linter/spectrum_peak_check.rb +72 -0
  35. data/lib/asciichem/linter/zmatrix_reference_check.rb +89 -0
  36. data/lib/asciichem/linter.rb +13 -2
  37. data/lib/asciichem/model/atom.rb +31 -0
  38. data/lib/asciichem/model/bond.rb +22 -0
  39. data/lib/asciichem/model/calculation.rb +4 -0
  40. data/lib/asciichem/model/crystal.rb +35 -6
  41. data/lib/asciichem/model/group.rb +30 -12
  42. data/lib/asciichem/model/mechanism.rb +3 -0
  43. data/lib/asciichem/model/molecule.rb +134 -0
  44. data/lib/asciichem/model/node.rb +8 -0
  45. data/lib/asciichem/model/opaque_cml.rb +34 -0
  46. data/lib/asciichem/model/reaction.rb +19 -4
  47. data/lib/asciichem/model/spectrum.rb +5 -0
  48. data/lib/asciichem/model.rb +1 -0
  49. data/lib/asciichem/model_adapter/from_canonical.rb +139 -24
  50. data/lib/asciichem/model_adapter/to_canonical.rb +211 -28
  51. data/lib/asciichem/parser.rb +57 -5
  52. data/lib/asciichem/periodic_table.rb +30 -1
  53. data/lib/asciichem/transform.rb +76 -47
  54. data/lib/asciichem/version.rb +1 -1
  55. data/lib/asciichem.rb +1 -0
  56. metadata +28 -12
  57. data/TODO.beyond-formulas/01-crystallography.md +0 -69
  58. data/TODO.beyond-formulas/02-spectroscopy.md +0 -39
  59. data/TODO.beyond-formulas/03-compchem.md +0 -31
  60. data/TODO.beyond-formulas/04-structural.md +0 -48
  61. data/TODO.beyond-formulas/05-mechanisms.md +0 -40
  62. data/TODO.beyond-formulas/README.md +0 -69
data/lib/asciichem/cli.rb CHANGED
@@ -57,10 +57,12 @@ module AsciiChem
57
57
  desc "lint -i INPUT", "Run chemistry checks; exit 1 on error, 0 if clean"
58
58
  method_option :input, aliases: "-i", type: :string, required: true,
59
59
  desc: "AsciiChem source text"
60
+ method_option :format, aliases: "-f", type: :string, default: "text",
61
+ desc: "Output format: text or json"
60
62
  def lint
61
63
  formula = AsciiChem.parse(options[:input])
62
64
  diagnostics = AsciiChem::Linter.run(formula)
63
- diagnostics.each { |d| puts d }
65
+ output_lint(diagnostics, options[:format])
64
66
  exit diagnostics.any? { |d| d.severity == :error } ? 1 : 0
65
67
  rescue AsciiChem::ParseError => e
66
68
  warn "Parse error: #{e.message}"
@@ -92,5 +94,25 @@ module AsciiChem
92
94
 
93
95
  AsciiChem::Formatter.render(format.to_sym, formula)
94
96
  end
97
+
98
+ def output_lint(diagnostics, format)
99
+ case format.to_s
100
+ when "json" then output_lint_json(diagnostics)
101
+ else
102
+ diagnostics.each { |d| puts d }
103
+ end
104
+ end
105
+
106
+ def output_lint_json(diagnostics)
107
+ require "json"
108
+ payload = diagnostics.map do |d|
109
+ {
110
+ severity: d.severity.to_s,
111
+ message: d.message,
112
+ node: d.node&.diagnostic_label
113
+ }
114
+ end
115
+ puts JSON.pretty_generate(payload)
116
+ end
95
117
  end
96
118
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module AsciiChem
6
+ module Cml
7
+ # Carries per-reaction conditions (above/below the arrow) through
8
+ # CML round-trip. v0.11.0+: chemicalml 0.3.0 Reaction wire now
9
+ # serialises `<conditionList>` natively, so conditions are emitted
10
+ # by the adapter directly. This module now handles ONLY:
11
+ #
12
+ # - Parse legacy `<conditionList>` from XML (for files written by
13
+ # other CML tools or older AsciiChem versions using aci: attrs)
14
+ # - Parse legacy `aci:conditionsAbove`/`aci:conditionsBelow`
15
+ # attributes for backwards compat
16
+ #
17
+ # The inject path is retained only to support test fixtures that
18
+ # build XML by hand; production emit goes through the adapter.
19
+ module ConditionsExtensions
20
+ # Inject aci:conditionsAbove/Below attributes for legacy consumers.
21
+ # v0.11.0+: native <conditionList> is emitted by the adapter, so
22
+ # this is now suppressed unless conditions are missing from the
23
+ # XML (defensive — shouldn't normally trigger).
24
+ def self.inject(xml, formula)
25
+ conditions_map = build_conditions_map(formula)
26
+ return xml if conditions_map.empty?
27
+
28
+ # If native <conditionList> already present, skip aci: emit.
29
+ doc_check = Nokogiri::XML(xml)
30
+ return xml if doc_check.xpath('//cml:reaction/cml:conditionList',
31
+ cml: Extensions::CML_NS).any?
32
+
33
+ doc = Nokogiri::XML(xml)
34
+ root = doc.root
35
+ Extensions.ensure_namespace(root)
36
+ apply_conditions(root, conditions_map)
37
+ doc.to_xml
38
+ end
39
+
40
+ # Extract aci:conditionsAbove/Below from each <reaction>.
41
+ # Returns `{ reaction_id => { above:, below: } }`.
42
+ def self.extract(xml)
43
+ doc = Nokogiri::XML(xml)
44
+ result = {}
45
+ doc.xpath('//cml:reaction', cml: Extensions::CML_NS).each do |el|
46
+ id = el['id']
47
+ next unless id
48
+
49
+ above = el["#{Extensions::PREFIX}:conditionsAbove"]
50
+ below = el["#{Extensions::PREFIX}:conditionsBelow"]
51
+ result[id] = { above: above, below: below } if above || below
52
+ end
53
+ result
54
+ end
55
+
56
+ # Restore extracted conditions onto matching reactions in a
57
+ # freshly-parsed formula. Walks in canonical order to match IDs.
58
+ def self.restore(formula, conditions_map)
59
+ return formula if conditions_map.empty?
60
+
61
+ prefix = AsciiChem::Cml::ID_PREFIXES.fetch(:reaction)
62
+ index = 0
63
+ each_reaction_in_canonical_order(formula) do |reaction|
64
+ index += 1
65
+ data = conditions_map["#{prefix}#{index}"]
66
+ next unless data
67
+
68
+ reaction.conditions = AsciiChem::Model::Reaction::Conditions.new(
69
+ above: data[:above],
70
+ below: data[:below]
71
+ )
72
+ end
73
+ formula
74
+ end
75
+
76
+ class << self
77
+ private
78
+
79
+ def build_conditions_map(formula)
80
+ prefix = AsciiChem::Cml::ID_PREFIXES.fetch(:reaction)
81
+ index = 0
82
+ map = {}
83
+ each_reaction_in_canonical_order(formula) do |reaction|
84
+ index += 1
85
+ next unless reaction.conditions
86
+
87
+ data = conditions_data(reaction)
88
+ map["#{prefix}#{index}"] = data if data[:above] || data[:below]
89
+ end
90
+ map
91
+ end
92
+
93
+ def conditions_data(reaction)
94
+ {
95
+ above: reaction.conditions&.above,
96
+ below: reaction.conditions&.below
97
+ }
98
+ end
99
+
100
+ def each_reaction_in_canonical_order(formula, &block)
101
+ formula.nodes.each do |node|
102
+ case node
103
+ when AsciiChem::Model::Reaction
104
+ yield node
105
+ when AsciiChem::Model::ReactionCascade
106
+ node.steps.each(&block)
107
+ end
108
+ end
109
+ end
110
+
111
+ def apply_conditions(root, conditions_map)
112
+ conditions_map.each do |reaction_id, data|
113
+ reaction_el = root.at_xpath(
114
+ "//cml:reaction[@id='#{reaction_id}']",
115
+ cml: Extensions::CML_NS
116
+ )
117
+ next unless reaction_el
118
+
119
+ apply_one_condition(reaction_el, data)
120
+ end
121
+ end
122
+
123
+ def apply_one_condition(reaction_el, data)
124
+ reaction_el["#{Extensions::PREFIX}:conditionsAbove"] = data[:above] if data[:above]
125
+ return unless data[:below]
126
+
127
+ reaction_el["#{Extensions::PREFIX}:conditionsBelow"] = data[:below]
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module AsciiChem
6
+ module Cml
7
+ module Extensions
8
+ # Atom-attribute extension channel: carries per-atom fields that
9
+ # CML's standard wire format doesn't cover (oxidation state, lone
10
+ # pairs, radical electrons, ring closures, atom parity) via
11
+ # `aci:<wire_name>` attributes on `<atom>` elements.
12
+ #
13
+ # CML tools that don't recognise the aci: namespace ignore these
14
+ # attributes — schema validity is preserved.
15
+ module AtomAttributes
16
+ # Map of AsciiChem::Model::Atom attribute name (Symbol) to the
17
+ # wire attribute name (without prefix). Each entry produces a
18
+ # corresponding `aci:<wire_name>` attribute in the CML output.
19
+ FIELDS = {
20
+ oxidation_state: 'oxidationState',
21
+ lone_pairs: 'lonePairs',
22
+ radical_electrons: 'radicalElectrons',
23
+ ring_closures: 'ringClosures',
24
+ atom_parity: 'atomParity'
25
+ }.freeze
26
+
27
+ # Build the extensions map: `{ atom_id => { field: value } }`.
28
+ # Values are Ruby-native (Integer for counts, String for
29
+ # oxidation state). Atoms without extension data are omitted.
30
+ def self.collect(atom_mapping)
31
+ atom_mapping.each_with_object({}) do |(atom_id, source_atom), memo|
32
+ data = build_entry(source_atom)
33
+ memo[atom_id] = data unless data.empty?
34
+ end
35
+ end
36
+
37
+ # Inject aci: attributes into a CML XML string. Returns the
38
+ # modified XML. No-op if the extensions map is empty.
39
+ def self.inject(xml, extensions)
40
+ return xml if extensions.empty?
41
+
42
+ doc = Nokogiri::XML(xml)
43
+ root = doc.root
44
+ unless root.namespaces.value?(Extensions::NAMESPACE)
45
+ root.add_namespace(Extensions::PREFIX, Extensions::NAMESPACE)
46
+ end
47
+
48
+ extensions.each do |atom_id, entry|
49
+ atom_el = root.at_xpath("//cml:atom[@id='#{atom_id}']",
50
+ cml: Extensions::CML_NS)
51
+ next unless atom_el
52
+
53
+ entry.each do |attr_name, value|
54
+ wire_name = FIELDS.fetch(attr_name)
55
+ atom_el["#{Extensions::PREFIX}:#{wire_name}"] = value.to_s
56
+ end
57
+ end
58
+
59
+ doc.to_xml
60
+ end
61
+
62
+ # Extract aci: attributes from a CML XML string. Returns a map
63
+ # `{ atom_id => { field: value } }` with Ruby-native types.
64
+ def self.extract(xml)
65
+ doc = Nokogiri::XML(xml)
66
+ result = {}
67
+ doc.xpath('//cml:atom', cml: Extensions::CML_NS).each do |atom_el|
68
+ atom_id = atom_el['id']
69
+ next unless atom_id
70
+
71
+ entry = read_entry(atom_el)
72
+ result[atom_id] = entry unless entry.empty?
73
+ end
74
+ result
75
+ end
76
+
77
+ # Apply extracted extension data to a freshly-parsed formula.
78
+ # Atoms are walked in parallel between the canonical document
79
+ # (which has IDs) and the formula (which has model objects).
80
+ def self.restore(formula, canonical_doc, extensions)
81
+ return formula if extensions.empty?
82
+
83
+ canonical_atoms = flatten_canonical_atoms(canonical_doc)
84
+ formula_atoms = flatten_formula_atoms(formula)
85
+
86
+ canonical_atoms.each_with_index do |canon_atom, idx|
87
+ entry = extensions[canon_atom.id]
88
+ next unless entry
89
+
90
+ target = formula_atoms[idx]
91
+ next unless target
92
+
93
+ apply_entry(target, entry)
94
+ end
95
+ formula
96
+ end
97
+
98
+ class << self
99
+ private
100
+
101
+ def build_entry(atom)
102
+ FIELDS.each_with_object({}) do |(attr_name, _wire_name), entry|
103
+ value = atom.public_send(attr_name)
104
+ entry[attr_name] = value if value
105
+ end
106
+ end
107
+
108
+ def read_entry(atom_el)
109
+ FIELDS.each_with_object({}) do |(attr_name, wire_name), entry|
110
+ value = atom_el["#{Extensions::PREFIX}:#{wire_name}"]
111
+ entry[attr_name] = cast_from_xml(attr_name, value) if value
112
+ end
113
+ end
114
+
115
+ # Cast an XML string back to its Ruby form. Integer counts
116
+ # become Integers; everything else stays a string.
117
+ def 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
+
124
+ def apply_entry(atom, entry)
125
+ entry.each do |attr_name, value|
126
+ atom.public_send(:"#{attr_name}=", value)
127
+ end
128
+ end
129
+
130
+ # Flatten the canonical document's atoms in the order
131
+ # ToCanonical walked them: top-level molecules, then reaction
132
+ # reactants+products, then cascade reactions.
133
+ def flatten_canonical_atoms(canonical_doc)
134
+ atoms = []
135
+ canonical_doc.molecules.each { |m| atoms.concat((m.atom_array&.atoms || [])) }
136
+ canonical_doc.reactions.each do |reaction|
137
+ atoms.concat(flatten_reaction_atoms(reaction))
138
+ end
139
+ canonical_doc.reaction_lists.each do |list|
140
+ list.reactions.each { |r| atoms.concat(flatten_reaction_atoms(r)) }
141
+ end
142
+ atoms
143
+ end
144
+
145
+ def flatten_reaction_atoms(reaction)
146
+ atoms = []
147
+ reactants = reaction.reactant_list
148
+ products = reaction.product_list
149
+ reactants&.reactants&.each { |r| atoms.concat(r.substance.molecule.atom_array&.atoms || []) }
150
+ products&.products&.each { |p| atoms.concat(p.substance.molecule.atom_array&.atoms || []) }
151
+ atoms
152
+ end
153
+
154
+ # Flatten the AsciiChem::Model::Formula's atoms in the same
155
+ # order ToCanonical walks them. Matches
156
+ # flatten_canonical_atoms one-for-one so parallel iteration
157
+ # by index works.
158
+ def flatten_formula_atoms(formula)
159
+ formula.nodes.each_with_object([]) do |node, memo|
160
+ case node
161
+ when AsciiChem::Model::Molecule
162
+ memo.concat(flatten_molecule_atoms(node))
163
+ when AsciiChem::Model::Reaction
164
+ memo.concat(reaction_atoms(node))
165
+ when AsciiChem::Model::ReactionCascade
166
+ node.steps.each { |step| memo.concat(reaction_atoms(step)) }
167
+ end
168
+ end
169
+ end
170
+
171
+ def reaction_atoms(reaction)
172
+ atoms = []
173
+ reaction.reactants.each { |m| atoms.concat(flatten_molecule_atoms(m)) }
174
+ reaction.products.each { |m| atoms.concat(flatten_molecule_atoms(m)) }
175
+ atoms
176
+ end
177
+
178
+ def flatten_molecule_atoms(molecule)
179
+ molecule.nodes.each_with_object([]) do |node, memo|
180
+ case node
181
+ when AsciiChem::Model::Atom
182
+ memo << node
183
+ when AsciiChem::Model::Group, AsciiChem::Model::Molecule
184
+ memo.concat(flatten_molecule_atoms(node))
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+ require 'set'
5
+
6
+ module AsciiChem
7
+ module Cml
8
+ module Extensions
9
+ # Top-level construct extension channel: carries AsciiChem model
10
+ # classes that have no CML wire equivalent (ElectronConfiguration,
11
+ # EmbeddedMath, Text, Crystal, Spectrum, Calculation, ZMatrix,
12
+ # Mechanism) as `aci:<element_name>` children of `<cml>`.
13
+ #
14
+ # Position in the original formula's node list is preserved via a
15
+ # `position` attribute so restore can re-insert at the right index.
16
+ # The wire format for each handler is the AsciiChem text rendering
17
+ # — round-trip-safe by construction (Text formatter is the
18
+ # canonicaliser).
19
+ module TopLevel
20
+ # Registry of handlers. Each is a Struct with `node_class`,
21
+ # `element_name`, `serialize`, `deserialize`. Adding a new
22
+ # top-level extension is one factory call.
23
+ #
24
+ # Factory methods on the struct eliminate the duplicated
25
+ # serialize/deserialize lambdas. A construct needing bespoke
26
+ # logic calls `.new(...)` directly with custom lambdas.
27
+ # Registry entry mapping a model class to its aci: wire element.
28
+ # Construct via the factory methods (.text_round_trip,
29
+ # .source_with_wrapper) instead of .new where possible.
30
+ Handler = Struct.new(:node_class, :element_name,
31
+ :serialize, :deserialize, keyword_init: true)
32
+
33
+ class Handler
34
+ # Default pattern: serialize via Text formatter, deserialize
35
+ # by re-parsing. Round-trip-safe by construction.
36
+ def self.text_round_trip(node_class:, element_name:)
37
+ new(
38
+ node_class: node_class,
39
+ element_name: element_name,
40
+ serialize: ->(node) { AsciiChem::Formatter.render(:text, node) },
41
+ deserialize: ->(content) { AsciiChem.parse(content).nodes.first }
42
+ )
43
+ end
44
+
45
+ # Pattern for constructs whose text format wraps content in
46
+ # delimiter chars (e.g. EmbeddedMath wraps in backticks) and
47
+ # whose model carries the unwrapped form in `source`.
48
+ def self.source_with_wrapper(node_class:, element_name:, wrapper:)
49
+ new(
50
+ node_class: node_class,
51
+ element_name: element_name,
52
+ serialize: ->(node) { node.source.to_s },
53
+ deserialize: ->(content) { AsciiChem.parse("#{wrapper}#{content}#{wrapper}").nodes.first }
54
+ )
55
+ end
56
+ end
57
+
58
+ HANDLERS = [
59
+ Handler.text_round_trip(
60
+ node_class: AsciiChem::Model::ElectronConfiguration,
61
+ element_name: 'electronConfiguration'
62
+ ),
63
+ Handler.source_with_wrapper(
64
+ node_class: AsciiChem::Model::EmbeddedMath,
65
+ element_name: 'embeddedMath',
66
+ wrapper: '`'
67
+ ),
68
+ Handler.text_round_trip(
69
+ node_class: AsciiChem::Model::Text,
70
+ element_name: 'text'
71
+ ),
72
+ # Beyond-formulas constructs — each carries its text rendering
73
+ # inside an aci: element. On parse, the text is re-parsed.
74
+ Handler.text_round_trip(
75
+ node_class: AsciiChem::Model::Crystal,
76
+ element_name: 'crystal'
77
+ ),
78
+ Handler.text_round_trip(
79
+ node_class: AsciiChem::Model::Spectrum,
80
+ element_name: 'spectrum'
81
+ ),
82
+ Handler.text_round_trip(
83
+ node_class: AsciiChem::Model::Calculation,
84
+ element_name: 'calculation'
85
+ ),
86
+ Handler.text_round_trip(
87
+ node_class: AsciiChem::Model::ZMatrix,
88
+ element_name: 'zmatrix'
89
+ ),
90
+ Handler.text_round_trip(
91
+ node_class: AsciiChem::Model::Mechanism,
92
+ element_name: 'mechanism'
93
+ )
94
+ ].freeze
95
+
96
+ # Build the top-level extensions list from a formula. Returns
97
+ # an array of `{ position:, element_name:, content: }` hashes.
98
+ # Pass `skip_classes:` to suppress the aci: text carrier for
99
+ # constructs that have native wire representation elsewhere
100
+ # in the pipeline (e.g. Crystal via chemicalml 0.3.0).
101
+ def self.collect(formula, skip_classes: [])
102
+ handlers_by_class = HANDLERS.to_h { |h| [h.node_class, h] }
103
+ skip_set = skip_classes.to_set
104
+ formula.nodes.each_with_index.with_object([]) do |(node, idx), memo|
105
+ next if skip_set.include?(node.class)
106
+
107
+ handler = handlers_by_class[node.class]
108
+ next unless handler
109
+
110
+ memo << {
111
+ position: idx,
112
+ element_name: handler.element_name,
113
+ content: handler.serialize.call(node)
114
+ }
115
+ end
116
+ end
117
+
118
+ # Inject top-level extensions into CML XML as `aci:` elements
119
+ # inside `<cml>`. No-op if the list is empty.
120
+ def self.inject(xml, top_level)
121
+ return xml if top_level.empty?
122
+
123
+ doc = Nokogiri::XML(xml)
124
+ root = doc.root
125
+ unless root.namespaces.value?(Extensions::NAMESPACE)
126
+ root.add_namespace(Extensions::PREFIX, Extensions::NAMESPACE)
127
+ end
128
+ top_level.each { |entry| insert_element(doc, root, entry) }
129
+ doc.to_xml
130
+ end
131
+
132
+ # Extract `aci:` top-level elements from CML XML. Returns an
133
+ # array of `{ position:, element_name:, content: }` hashes in
134
+ # ascending position order.
135
+ def self.extract(xml)
136
+ doc = Nokogiri::XML(xml)
137
+ result = []
138
+ element_names = HANDLERS.map(&:element_name)
139
+ element_names.each do |name|
140
+ doc.xpath("//#{Extensions::PREFIX}:#{name}",
141
+ Extensions::PREFIX => Extensions::NAMESPACE).each do |el|
142
+ result << {
143
+ position: (el['position'] || 0).to_i,
144
+ element_name: name,
145
+ content: el.content
146
+ }
147
+ end
148
+ end
149
+ result.sort_by { |entry| entry[:position] }
150
+ end
151
+
152
+ # Restore top-level extension nodes into a freshly-parsed
153
+ # formula. Inserts each node at its original position; nodes
154
+ # are inserted in ascending position order so earlier inserts
155
+ # don't shift later positions.
156
+ def self.restore(formula, top_level)
157
+ handlers_by_element = HANDLERS.to_h { |h| [h.element_name, h] }
158
+ top_level.sort_by { |entry| entry[:position] }.each do |entry|
159
+ handler = handlers_by_element[entry[:element_name]]
160
+ next unless handler
161
+
162
+ node = handler.deserialize.call(entry[:content])
163
+ next unless node
164
+
165
+ pos = [entry[:position], formula.nodes.length].min
166
+ formula.nodes.insert(pos, node)
167
+ end
168
+ formula
169
+ end
170
+
171
+ class << self
172
+ private
173
+
174
+ def insert_element(doc, root, entry)
175
+ element = doc.create_element("#{Extensions::PREFIX}:#{entry[:element_name]}")
176
+ element['position'] = entry[:position].to_s
177
+ element.content = entry[:content]
178
+ # Insert before existing children so extensions appear at
179
+ # the top of <cml>, which reads more naturally than appended.
180
+ root.children.first&.add_previous_sibling(element) || root.add_child(element)
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end