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
-
.collect(atom_mapping) ⇒ Object
Build the extensions map:
{ atom_id => { field: value } }. -
.collect_top_level(formula) ⇒ Object
Build the top-level extensions list from a formula.
-
.extract(xml) ⇒ Object
Extract aci: attributes from a CML XML string.
-
.extract_top_level(xml) ⇒ Object
Extract
aci:top-level elements from CML XML. -
.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.
-
.flatten_formula_atoms(formula) ⇒ Object
Flatten the AsciiChem::Model::Formula's atoms in the same order ToCanonical walks them.
-
.inject(xml, extensions) ⇒ Object
Inject aci: attributes into a CML XML string.
-
.inject_top_level(xml, top_level) ⇒ Object
Inject top-level extensions into CML XML as
aci:elements inside<cml>. -
.restore(formula, canonical_doc, extensions) ⇒ Object
Apply extracted extension data to a freshly-parsed AsciiChem::Model::Formula.
-
.restore_top_level(formula, top_level) ⇒ Object
Restore top-level extension nodes into a freshly-parsed formula.
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 |