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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +52 -0
- data/ARCHITECTURE.adoc +153 -0
- data/CHANGELOG.md +363 -5
- data/asciichem.gemspec +7 -4
- data/benchmarks/benchmark.rb +6 -1
- data/lib/asciichem/cli.rb +23 -1
- data/lib/asciichem/cml/conditions_extensions.rb +132 -0
- data/lib/asciichem/cml/extensions/atom_attributes.rb +192 -0
- data/lib/asciichem/cml/extensions/top_level.rb +186 -0
- data/lib/asciichem/cml/extensions.rb +32 -313
- data/lib/asciichem/cml/group_extensions.rb +10 -20
- data/lib/asciichem/cml/metadata_extensions.rb +145 -0
- data/lib/asciichem/cml/opaque_extensions.rb +144 -0
- data/lib/asciichem/cml/translator.rb +46 -144
- data/lib/asciichem/cml.rb +14 -0
- data/lib/asciichem/formatter/html.rb +117 -0
- data/lib/asciichem/formatter/latex.rb +117 -0
- data/lib/asciichem/formatter/mathml.rb +134 -0
- data/lib/asciichem/formatter/structural_svg.rb +38 -0
- data/lib/asciichem/formatter/svg.rb +38 -6
- data/lib/asciichem/formatter/text.rb +14 -10
- data/lib/asciichem/grammar.rb +27 -1
- data/lib/asciichem/identifiers/base.rb +25 -0
- data/lib/asciichem/identifiers/cas.rb +56 -0
- data/lib/asciichem/identifiers/inchi.rb +104 -0
- data/lib/asciichem/identifiers/inchikey.rb +33 -0
- data/lib/asciichem/identifiers/smiles.rb +147 -0
- data/lib/asciichem/identifiers.rb +66 -0
- data/lib/asciichem/linter/charge_balance_check.rb +117 -0
- data/lib/asciichem/linter/crystal_sanity_check.rb +90 -0
- data/lib/asciichem/linter/identifier_consistency_check.rb +100 -0
- data/lib/asciichem/linter/identifier_format_check.rb +48 -0
- data/lib/asciichem/linter/spectrum_peak_check.rb +72 -0
- data/lib/asciichem/linter/zmatrix_reference_check.rb +89 -0
- data/lib/asciichem/linter.rb +13 -2
- data/lib/asciichem/model/atom.rb +31 -0
- data/lib/asciichem/model/bond.rb +22 -0
- data/lib/asciichem/model/calculation.rb +4 -0
- data/lib/asciichem/model/crystal.rb +35 -6
- data/lib/asciichem/model/group.rb +30 -12
- data/lib/asciichem/model/mechanism.rb +3 -0
- data/lib/asciichem/model/molecule.rb +134 -0
- data/lib/asciichem/model/node.rb +8 -0
- data/lib/asciichem/model/opaque_cml.rb +34 -0
- data/lib/asciichem/model/reaction.rb +19 -4
- data/lib/asciichem/model/spectrum.rb +5 -0
- data/lib/asciichem/model.rb +1 -0
- data/lib/asciichem/model_adapter/from_canonical.rb +139 -24
- data/lib/asciichem/model_adapter/to_canonical.rb +211 -28
- data/lib/asciichem/parser.rb +57 -5
- data/lib/asciichem/periodic_table.rb +30 -1
- data/lib/asciichem/transform.rb +76 -47
- data/lib/asciichem/version.rb +1 -1
- data/lib/asciichem.rb +1 -0
- metadata +28 -12
- data/TODO.beyond-formulas/01-crystallography.md +0 -69
- data/TODO.beyond-formulas/02-spectroscopy.md +0 -39
- data/TODO.beyond-formulas/03-compchem.md +0 -31
- data/TODO.beyond-formulas/04-structural.md +0 -48
- data/TODO.beyond-formulas/05-mechanisms.md +0 -40
- 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
|
|
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
|