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',
    # Text formatter emits `"content"` (with quotes). Re-parsing
    # that yields the original Text node — round-trip-safe by
    # construction.
    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.



259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/asciichem/cml/extensions.rb', line 259

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.



303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/asciichem/cml/extensions.rb', line 303

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.



275
276
277
278
279
280
281
282
283
# File 'lib/asciichem/cml/extensions.rb', line 275

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.



323
324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/asciichem/cml/extensions.rb', line 323

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