Module: AsciiChem::Cml::Extensions

Defined in:
lib/asciichem/cml/extensions.rb

Overview

Carries AsciiChem-specific fields through CML round-trip via an aci: (AsciiChem extension) namespace. CML's standard wire format covers element, isotope, charge, count, hydrogen count, and spin multiplicity — but not oxidation state, lone pairs, radical electrons, or anything else AsciiChem might add. Without this side channel, those fields are silently dropped on AsciiChem → CML → AsciiChem round-trip.

The mechanism: post-process the CML XML emitted by chemicalml to add aci: attributes for atoms that carry extension data. On parse, read the aci: attributes back. CML tools that don't recognise the namespace simply ignore the attributes — schema validity is preserved.

Adding a new extension field is one entry in FIELDS and one writer/reader pair. No other code changes. This is the OCP extension point for "fields CML doesn't natively carry".

Defined Under Namespace

Classes: TopLevelHandler

Constant Summary collapse

NAMESPACE =
'https://asciichem.org/cml-ext'
PREFIX =
'aci'
CML_NS =
'http://www.xml-cml.org/schema'
FIELDS =

Map of AsciiChem::Model::Atom attribute name (Symbol) to the wire attribute name (without prefix). Each entry produces a corresponding aci:<wire_name> attribute in the CML output.

{
  oxidation_state: 'oxidationState',
  lone_pairs: 'lonePairs',
  radical_electrons: 'radicalElectrons',
  ring_closures: 'ringClosures',
  atom_parity: 'atomParity'
}.freeze
TOP_LEVEL_HANDLERS =
[
  TopLevelHandler.new(
    node_class: AsciiChem::Model::ElectronConfiguration,
    element_name: 'electronConfiguration',
    serialize: ->(node) { text_render(node) },
    deserialize: ->(content) { AsciiChem.parse(content).nodes.first }
  ),
  TopLevelHandler.new(
    node_class: AsciiChem::Model::EmbeddedMath,
    element_name: 'embeddedMath',
    serialize: ->(node) { node.source.to_s },
    deserialize: ->(content) { AsciiChem.parse("`#{content}`").nodes.first }
  ),
  TopLevelHandler.new(
    node_class: AsciiChem::Model::Text,
    element_name: 'text',
    serialize: ->(node) { text_render(node) },
    deserialize: ->(content) { AsciiChem.parse(content).nodes.first }
  ),
  # -- beyond-formulas constructs (Phase 1-5) -------------------
  # Each carries its text representation inside an aci: element.
  # On parse, the text is re-parsed to rebuild the construct.
  # DRY: all five share the same serialize/deserialize pattern.
  TopLevelHandler.new(
    node_class: AsciiChem::Model::Crystal,
    element_name: 'crystal',
    serialize: ->(node) { text_render(node) },
    deserialize: ->(content) { AsciiChem.parse(content).nodes.first }
  ),
  TopLevelHandler.new(
    node_class: AsciiChem::Model::Spectrum,
    element_name: 'spectrum',
    serialize: ->(node) { text_render(node) },
    deserialize: ->(content) { AsciiChem.parse(content).nodes.first }
  ),
  TopLevelHandler.new(
    node_class: AsciiChem::Model::Calculation,
    element_name: 'calculation',
    serialize: ->(node) { text_render(node) },
    deserialize: ->(content) { AsciiChem.parse(content).nodes.first }
  ),
  TopLevelHandler.new(
    node_class: AsciiChem::Model::ZMatrix,
    element_name: 'zmatrix',
    serialize: ->(node) { text_render(node) },
    deserialize: ->(content) { AsciiChem.parse(content).nodes.first }
  ),
  TopLevelHandler.new(
    node_class: AsciiChem::Model::Mechanism,
    element_name: 'mechanism',
    serialize: ->(node) { text_render(node) },
    deserialize: ->(content) { AsciiChem.parse(content).nodes.first }
  )
].freeze

Class Method Summary collapse

Class Method Details

.collect(atom_mapping) ⇒ Object

Build the extensions map: { atom_id => { field: value } }. Values are Ruby-native (Integer for counts, String for oxidation state). Atoms without extension data are omitted from the map (so the CML output stays clean for plain atoms).



46
47
48
49
50
51
# File 'lib/asciichem/cml/extensions.rb', line 46

def self.collect(atom_mapping)
  atom_mapping.each_with_object({}) do |(atom_id, source_atom), memo|
    data = build_entry(source_atom)
    memo[atom_id] = data unless data.empty?
  end
end

.collect_top_level(formula) ⇒ Object

Build the top-level extensions list from a formula. Returns an array of { position:, element_name:, content: } hashes. The position is the index in the original formula's node list.



290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/asciichem/cml/extensions.rb', line 290

def self.collect_top_level(formula)
  handlers_by_class = top_level_handlers_by_class
  formula.nodes.each_with_index.with_object([]) do |(node, idx), memo|
    handler = handlers_by_class[node.class]
    next unless handler

    memo << {
      position: idx,
      element_name: handler.element_name,
      content: handler.serialize.call(node)
    }
  end
end

.extract(xml) ⇒ Object

Extract aci: attributes from a CML XML string. Returns a map { atom_id => { field: value } } with Ruby-native types (Integer for counts, String for oxidation state). Empty if no aci: attributes are present.



94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/asciichem/cml/extensions.rb', line 94

def self.extract(xml)
  doc = Nokogiri::XML(xml)
  result = {}
  doc.xpath('//cml:atom', cml: CML_NS).each do |atom_el|
    atom_id = atom_el['id']
    next unless atom_id

    entry = read_entry(atom_el)
    result[atom_id] = entry unless entry.empty?
  end
  result
end

.extract_top_level(xml) ⇒ Object

Extract aci: top-level elements from CML XML. Returns an array of { position:, element_name:, content: } hashes in ascending position order.



334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'lib/asciichem/cml/extensions.rb', line 334

def self.extract_top_level(xml)
  doc = Nokogiri::XML(xml)
  result = []
  element_names = TOP_LEVEL_HANDLERS.map(&:element_name)
  element_names.each do |name|
    doc.xpath("//#{PREFIX}:#{name}", PREFIX => NAMESPACE).each do |el|
      result << {
        position: (el['position'] || 0).to_i,
        element_name: name,
        content: el.content
      }
    end
  end
  result.sort_by { |entry| entry[:position] }
end

.flatten_canonical_atoms(canonical_doc) ⇒ Object

Flatten the canonical document's atoms into a single list, in the same order ToCanonical walked them: top-level molecules, then reaction reactants+products, then cascade reactions.



159
160
161
162
163
164
165
166
167
168
169
# File 'lib/asciichem/cml/extensions.rb', line 159

def self.flatten_canonical_atoms(canonical_doc)
  atoms = []
  canonical_doc.molecules.each { |m| atoms.concat((m.atom_array&.atoms || [])) }
  canonical_doc.reactions.each do |reaction|
    atoms.concat(flatten_reaction_atoms(reaction))
  end
  canonical_doc.reaction_lists.each do |list|
    list.reactions.each { |r| atoms.concat(flatten_reaction_atoms(r)) }
  end
  atoms
end

.flatten_formula_atoms(formula) ⇒ Object

Flatten the AsciiChem::Model::Formula's atoms in the same order ToCanonical walks them. Matches flatten_canonical_atoms one-for-one so parallel iteration by index works.



184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/asciichem/cml/extensions.rb', line 184

def self.flatten_formula_atoms(formula)
  formula.nodes.each_with_object([]) do |node, memo|
    case node
    when AsciiChem::Model::Molecule
      memo.concat(flatten_molecule_atoms(node))
    when AsciiChem::Model::Reaction
      memo.concat(reaction_atoms(node))
    when AsciiChem::Model::ReactionCascade
      node.steps.each { |step| memo.concat(reaction_atoms(step)) }
    end
  end
end

.inject(xml, extensions) ⇒ Object

Inject aci: attributes into a CML XML string. Returns the modified XML. No-op if the extensions map is empty.



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
# File 'lib/asciichem/cml/extensions.rb', line 63

def self.inject(xml, extensions)
  return xml if extensions.empty?

  doc = Nokogiri::XML(xml)
  root = doc.root
  root.add_namespace(PREFIX, NAMESPACE) unless namespace_declared?(root)

  extensions.each do |atom_id, entry|
    atom_el = root.at_xpath("//cml:atom[@id='#{atom_id}']", cml: CML_NS)
    next unless atom_el

    entry.each do |attr_name, value|
      wire_name = FIELDS.fetch(attr_name)
      atom_el["#{PREFIX}:#{wire_name}"] = value.to_s
    end
  end

  doc.to_xml
end

.inject_top_level(xml, top_level) ⇒ Object

Inject top-level extensions into CML XML as aci: elements inside <cml>. No-op if the list is empty.



306
307
308
309
310
311
312
313
314
# File 'lib/asciichem/cml/extensions.rb', line 306

def self.inject_top_level(xml, top_level)
  return xml if top_level.empty?

  doc = Nokogiri::XML(xml)
  root = doc.root
  ensure_namespace(root)
  top_level.each { |entry| insert_top_level_element(doc, root, entry) }
  doc.to_xml
end

.restore(formula, canonical_doc, extensions) ⇒ Object

Apply extracted extension data to a freshly-parsed AsciiChem::Model::Formula. The canonical_doc is the canonical document that produced the formula; it provides the atom-id ordering. Atoms are walked in parallel.



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/asciichem/cml/extensions.rb', line 131

def self.restore(formula, canonical_doc, extensions)
  return formula if extensions.empty?

  canonical_atoms = flatten_canonical_atoms(canonical_doc)
  formula_atoms = flatten_formula_atoms(formula)

  canonical_atoms.each_with_index do |canon_atom, idx|
    entry = extensions[canon_atom.id]
    next unless entry

    target = formula_atoms[idx]
    next unless target

    apply_entry(target, entry)
  end
  formula
end

.restore_top_level(formula, top_level) ⇒ Object

Restore top-level extension nodes into a freshly-parsed formula. Inserts each node at its original position; nodes are inserted in ascending position order so earlier inserts don't shift later positions.



354
355
356
357
358
359
360
361
362
363
364
365
366
367
# File 'lib/asciichem/cml/extensions.rb', line 354

def self.restore_top_level(formula, top_level)
  handlers_by_element = top_level_handlers_by_element
  top_level.sort_by { |entry| entry[:position] }.each do |entry|
    handler = handlers_by_element[entry[:element_name]]
    next unless handler

    node = handler.deserialize.call(entry[:content])
    next unless node

    pos = [entry[:position], formula.nodes.length].min
    formula.nodes.insert(pos, node)
  end
  formula
end