asciichem 0.3.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 (68) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +21 -0
  3. data/.gitignore +14 -0
  4. data/.rubocop.yml +50 -0
  5. data/ARCHITECTURE.adoc +239 -0
  6. data/CHANGELOG.md +17 -0
  7. data/CLAUDE.md +318 -0
  8. data/Gemfile +14 -0
  9. data/LICENSE +24 -0
  10. data/README.adoc +55 -0
  11. data/RELEASING.md +102 -0
  12. data/Rakefile +8 -0
  13. data/asciichem.gemspec +39 -0
  14. data/benchmarks/RESULTS.md +49 -0
  15. data/benchmarks/benchmark.rb +106 -0
  16. data/exe/asciichem +6 -0
  17. data/lib/asciichem/cli.rb +96 -0
  18. data/lib/asciichem/cml/extensions.rb +360 -0
  19. data/lib/asciichem/cml/group_extensions.rb +290 -0
  20. data/lib/asciichem/cml/translator.rb +69 -0
  21. data/lib/asciichem/cml.rb +40 -0
  22. data/lib/asciichem/errors.rb +9 -0
  23. data/lib/asciichem/formatter/base.rb +21 -0
  24. data/lib/asciichem/formatter/html.rb +111 -0
  25. data/lib/asciichem/formatter/latex.rb +173 -0
  26. data/lib/asciichem/formatter/mathml.rb +309 -0
  27. data/lib/asciichem/formatter/structural_svg.rb +286 -0
  28. data/lib/asciichem/formatter/svg.rb +141 -0
  29. data/lib/asciichem/formatter/text.rb +143 -0
  30. data/lib/asciichem/formatter.rb +43 -0
  31. data/lib/asciichem/grammar.rb +344 -0
  32. data/lib/asciichem/greek.rb +80 -0
  33. data/lib/asciichem/layout.rb +313 -0
  34. data/lib/asciichem/linter/balance_check.rb +103 -0
  35. data/lib/asciichem/linter/base.rb +52 -0
  36. data/lib/asciichem/linter/bracket_balance_check.rb +44 -0
  37. data/lib/asciichem/linter/diagnostic.rb +16 -0
  38. data/lib/asciichem/linter/element_validation_check.rb +39 -0
  39. data/lib/asciichem/linter/isotope_sanity_check.rb +47 -0
  40. data/lib/asciichem/linter/registry.rb +31 -0
  41. data/lib/asciichem/linter/unclosed_ring_check.rb +27 -0
  42. data/lib/asciichem/linter/valence_check.rb +91 -0
  43. data/lib/asciichem/linter.rb +40 -0
  44. data/lib/asciichem/model/atom.rb +73 -0
  45. data/lib/asciichem/model/bond.rb +41 -0
  46. data/lib/asciichem/model/electron_configuration.rb +36 -0
  47. data/lib/asciichem/model/embedded_math.rb +26 -0
  48. data/lib/asciichem/model/formula.rb +31 -0
  49. data/lib/asciichem/model/group.rb +50 -0
  50. data/lib/asciichem/model/identifier.rb +14 -0
  51. data/lib/asciichem/model/molecule.rb +70 -0
  52. data/lib/asciichem/model/name.rb +15 -0
  53. data/lib/asciichem/model/node.rb +100 -0
  54. data/lib/asciichem/model/reaction.rb +49 -0
  55. data/lib/asciichem/model/reaction_cascade.rb +33 -0
  56. data/lib/asciichem/model/text.rb +23 -0
  57. data/lib/asciichem/model.rb +23 -0
  58. data/lib/asciichem/model_adapter/from_canonical.rb +272 -0
  59. data/lib/asciichem/model_adapter/to_canonical.rb +429 -0
  60. data/lib/asciichem/model_adapter.rb +56 -0
  61. data/lib/asciichem/parser.rb +36 -0
  62. data/lib/asciichem/periodic_table.rb +112 -0
  63. data/lib/asciichem/ring_bonds.rb +68 -0
  64. data/lib/asciichem/transform.rb +513 -0
  65. data/lib/asciichem/version.rb +5 -0
  66. data/lib/asciichem/xml_builder.rb +9 -0
  67. data/lib/asciichem.rb +34 -0
  68. metadata +197 -0
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chemicalml"
4
+
5
+ module AsciiChem
6
+ module Cml
7
+ # Thin composition of the AsciiChem <-> canonical adapter
8
+ # (`AsciiChem::ModelAdapter`) and the canonical <-> CML adapter
9
+ # (`Chemicalml::Cml::Translator`). Owns no chemistry logic — just
10
+ # wiring, the schema-registration dance that chemicalml requires
11
+ # on first use, and the aci: extension namespace side-channel
12
+ # that carries AsciiChem-specific fields through CML round-trip.
13
+ class Translator
14
+ class << self
15
+ # AsciiChem::Model::Formula -> CML XML string.
16
+ def from_asciichem(formula)
17
+ ensure_schema_registered!
18
+ translation = AsciiChem::ModelAdapter.to_canonical_with_mapping(formula)
19
+ wire_doc = Chemicalml::Cml::Translator.from_canonical(translation.document)
20
+ xml = wire_doc.to_xml
21
+ xml = inject_atom_extensions(xml, translation.atom_mapping)
22
+ xml = inject_group_extensions(xml, translation.groups)
23
+ inject_top_level_extensions(xml, formula)
24
+ end
25
+
26
+ # CML XML string -> AsciiChem::Model::Formula.
27
+ def to_asciichem(xml)
28
+ ensure_schema_registered!
29
+ top_level = Extensions.extract_top_level(xml)
30
+ atom_extensions = Extensions.extract(xml)
31
+ group_extensions = GroupExtensions.extract(xml)
32
+ wire_doc = Chemicalml::Cml::Document.from_xml(xml)
33
+ canonical = Chemicalml::Cml::Translator.to_canonical(wire_doc)
34
+ formula = AsciiChem::ModelAdapter.from_canonical(canonical)
35
+ Extensions.restore(formula, canonical, atom_extensions)
36
+ GroupExtensions.restore(formula, canonical, group_extensions)
37
+ Extensions.restore_top_level(formula, top_level)
38
+ formula
39
+ end
40
+
41
+ private
42
+
43
+ def inject_atom_extensions(xml, atom_mapping)
44
+ extensions = Extensions.collect(atom_mapping)
45
+ Extensions.inject(xml, extensions)
46
+ end
47
+
48
+ def inject_group_extensions(xml, groups_by_molecule)
49
+ collected = GroupExtensions.collect(groups_by_molecule)
50
+ GroupExtensions.inject(xml, collected)
51
+ end
52
+
53
+ def inject_top_level_extensions(xml, formula)
54
+ top_level = Extensions.collect_top_level(formula)
55
+ Extensions.inject_top_level(xml, top_level)
56
+ end
57
+
58
+ # chemicalml registers wire classes into a lutaml-model
59
+ # TypeRegistry on first use. The registry call is idempotent
60
+ # (guarded by `@models_registered`) but must run before any
61
+ # `from_xml` so element types like `molecule` and `atom` are
62
+ # resolvable.
63
+ def ensure_schema_registered!
64
+ Chemicalml::Cml::Schema3.ensure_registered!
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # CML (Chemical Markup Language) support for AsciiChem.
5
+ #
6
+ # Bidirectional conversion between the AsciiChem text world and the
7
+ # CML XML world. The pipeline is:
8
+ #
9
+ # AsciiChem::Model <-> AsciiChem::ModelAdapter <-> Chemicalml::Model
10
+ # ^
11
+ # |
12
+ # Chemicalml::Cml::Translator
13
+ # |
14
+ # v
15
+ # Chemicalml::Cml::* (wire)
16
+ #
17
+ # The canonical model (Chemicalml::Model) is the format-agnostic
18
+ # hub. AsciiChem and CML each have their own adapter; the adapters
19
+ # never talk to each other directly. Adding a new format (SMILES,
20
+ # InChI, MOL) is a new adapter — none of the existing code changes.
21
+ #
22
+ # Public API:
23
+ # AsciiChem::Cml.from_asciichem(formula) # => CML XML string
24
+ # AsciiChem::Cml.parse(xml) # => AsciiChem::Model::Formula
25
+ module Cml
26
+ autoload :Extensions, "asciichem/cml/extensions"
27
+ autoload :GroupExtensions, "asciichem/cml/group_extensions"
28
+ autoload :Translator, "asciichem/cml/translator"
29
+
30
+ # Serialise an AsciiChem::Model::Formula to CML XML.
31
+ def self.from_asciichem(formula)
32
+ Translator.from_asciichem(formula)
33
+ end
34
+
35
+ # Parse CML XML into an AsciiChem::Model::Formula.
36
+ def self.parse(xml)
37
+ Translator.to_asciichem(xml)
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ class Error < StandardError; end
5
+
6
+ class ParseError < Error; end
7
+
8
+ class FormatError < Error; end
9
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Formatter
5
+ # Visitor base class. Subclasses implement one `visit_<node_name>`
6
+ # method per Model::Node subclass. Missing visits raise
7
+ # NotImplementedError so gaps surface at first use, not silently.
8
+ class Base
9
+ def render(node)
10
+ node.accept(self)
11
+ end
12
+
13
+ private
14
+
15
+ def not_implemented_for(node)
16
+ raise NotImplementedError,
17
+ "#{self.class} cannot render #{node.class.name}"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Formatter
5
+ # Renders a Model tree as inline HTML using `<sub>` and `<sup>` tags.
6
+ # Suitable for inline display in pages that don't want to load
7
+ # MathJax or render MathML.
8
+ #
9
+ # Example: `H_2O` -> <code>H<sub>2</sub>O</code>.
10
+ class Html < Base
11
+ def visit_formula(formula)
12
+ inner = formula.nodes.map { |n| render_node(n) }.join
13
+ "<span class=\"asciichem\">#{inner}</span>"
14
+ end
15
+
16
+ def visit_molecule(molecule)
17
+ prefix = molecule.coefficient.nil? || molecule.coefficient.to_s.empty? ? "" : "#{escape(molecule.coefficient)}"
18
+ stereo = molecule.stereo ? "(#{escape(molecule.stereo_letter)})-" : ""
19
+ body = molecule.nodes.map { |n| render_node(n) }.join
20
+ "#{stereo}#{prefix}#{body}"
21
+ end
22
+
23
+ def visit_atom(atom)
24
+ parts = []
25
+ parts << "<sup>#{escape(atom.isotope)}</sup>" if atom.isotope
26
+ parts << escape(atom.element)
27
+ parts << "<sub>#{escape(atom.subscript)}</sub>" if atom.subscript
28
+ parts << "<sup>#{escape(atom.superscript)}</sup>" if atom.superscript
29
+ parts << "<sup>#{escape(atom.charge)}</sup>" if atom.charge
30
+ if atom.oxidation_state
31
+ parts << "<sup>(#{escape(atom.oxidation_state)})</sup>"
32
+ end
33
+ parts << escape(atom.ring_closures) if atom.ring_closures
34
+ parts.join
35
+ end
36
+
37
+ def visit_group(group)
38
+ body = group.nodes.map { |n| render_node(n) }.join
39
+ suffix = group.multiplicity ? "<sub>#{escape(group.multiplicity)}</sub>" : ""
40
+ "#{escape(group.open_char)}#{body}#{escape(group.close_char)}#{suffix}"
41
+ end
42
+
43
+ def visit_bond(bond)
44
+ escape(bond.ascii)
45
+ end
46
+
47
+ def visit_reaction(reaction)
48
+ left = reaction.reactants.map { |n| render_node(n) }.join(" + ")
49
+ right = reaction.products.map { |n| render_node(n) }.join(" + ")
50
+ arrow = escape(reaction.arrow_entity)
51
+ conds = reaction.conditions
52
+ return "#{left} #{arrow} #{right}" unless conds
53
+
54
+ above = conds.above ? "<sup>#{escape(conds.above)}</sup>" : ""
55
+ below = conds.below ? "<sub>#{escape(conds.below)}</sub>" : ""
56
+ "#{left} #{above}#{arrow}#{below} #{right}"
57
+ end
58
+
59
+ def visit_reaction_cascade(cascade)
60
+ return "" if cascade.steps.empty?
61
+
62
+ head = cascade.steps.first
63
+ out = head.reactants.map { |n| render_node(n) }.join(" + ")
64
+ cascade.steps.each do |step|
65
+ arrow = escape(step.arrow_entity)
66
+ conds = step.conditions
67
+ if conds
68
+ above = conds.above ? "<sup>#{escape(conds.above)}</sup>" : ""
69
+ below = conds.below ? "<sub>#{escape(conds.below)}</sub>" : ""
70
+ out += " #{above}#{arrow}#{below}"
71
+ else
72
+ out += " #{arrow}"
73
+ end
74
+ out += " " + step.products.map { |n| render_node(n) }.join(" + ")
75
+ end
76
+ out
77
+ end
78
+
79
+ def visit_electron_configuration(ec)
80
+ ec.orbitals.map { |orb, occ| "#{escape(orb)}<sup>#{escape(occ)}</sup>" }.join(" ")
81
+ end
82
+
83
+ def visit_embedded_math(em)
84
+ em.formula.to_mathml
85
+ end
86
+
87
+ def visit_text(text)
88
+ escape(text.content)
89
+ end
90
+
91
+ private
92
+
93
+ def render_node(node)
94
+ node.accept(self)
95
+ end
96
+
97
+ # Minimal HTML escaper. The model content is plain text (digits,
98
+ # element symbols, conditions text) so we only need the four
99
+ # XML-mandated characters.
100
+ def escape(string)
101
+ return "" if string.nil?
102
+
103
+ string.to_s
104
+ .gsub("&", "&amp;")
105
+ .gsub("<", "&lt;")
106
+ .gsub(">", "&gt;")
107
+ .gsub('"', "&quot;")
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Formatter
5
+ # Renders a Model tree as LaTeX, wrapped in `mhchem`'s `\ce{...}` at
6
+ # the formula level only. Output requires `mhchem` in the LaTeX
7
+ # preamble:
8
+ #
9
+ # \\usepackage[version=4]{mhchem}
10
+ #
11
+ # Example: `H_2O` -> `\ce{H2O}`.
12
+ class Latex < Base
13
+ def initialize
14
+ @inside_ce = false
15
+ end
16
+
17
+ def visit_formula(formula)
18
+ within_ce { "\\ce{" + formula.nodes.map { |n| render_node(n) }.join + "}" }
19
+ end
20
+
21
+ def visit_molecule(molecule)
22
+ prefix = blank?(molecule.coefficient) ? "" : molecule.coefficient.to_s
23
+ stereo = molecule.stereo ? "(#{molecule.stereo_letter})-" : ""
24
+ body = molecule.nodes.map { |n| render_node(n) }.join
25
+ if @inside_ce
26
+ "#{stereo}#{prefix}#{body}"
27
+ else
28
+ "\\ce{#{stereo}#{prefix}#{body}}"
29
+ end
30
+ end
31
+
32
+ def visit_atom(atom)
33
+ parts = []
34
+ parts << (":" * atom.lone_pairs) if atom.lone_pairs
35
+ parts << "^#{wrap(atom.isotope)}" if atom.isotope
36
+ parts << wrap(atom.element)
37
+ parts << mhchem_subscript(atom.subscript) if atom.subscript
38
+ if atom.charge
39
+ parts << "^#{wrap(atom.charge)}"
40
+ elsif atom.oxidation_state
41
+ parts << "^{(#{wrap(atom.oxidation_state)})}"
42
+ elsif atom.superscript
43
+ parts << "^#{wrap(atom.superscript)}"
44
+ end
45
+ parts << ("." * atom.radical_electrons) if atom.radical_electrons
46
+ parts << atom.ring_closures.to_s if atom.ring_closures
47
+ parts.join
48
+ end
49
+
50
+ def visit_group(group)
51
+ body = group.nodes.map { |n| render_node(n) }.join
52
+ open, close = bracket_chars(group.bracket)
53
+ suffix = group.multiplicity ? mhchem_subscript(group.multiplicity) : ""
54
+ "#{open}#{body}#{close}#{suffix}"
55
+ end
56
+
57
+ def visit_bond(bond)
58
+ case bond.kind
59
+ when :single then "-"
60
+ when :double then "="
61
+ when :triple then "\\\\equiv{}"
62
+ else bond.ascii
63
+ end
64
+ end
65
+
66
+ def visit_reaction(reaction)
67
+ left = reaction.reactants.map { |n| render_node(n) }.join(" + ")
68
+ right = reaction.products.map { |n| render_node(n) }.join(" + ")
69
+ arrow = arrow_for(reaction.arrow)
70
+ conds = reaction.conditions
71
+ return "#{left} #{arrow} #{right}" unless conds
72
+
73
+ above = conditions_bracket(conds.above)
74
+ below = conditions_bracket(conds.below)
75
+ "#{left} #{arrow}#{above}#{below} #{right}"
76
+ end
77
+
78
+ def visit_reaction_cascade(cascade)
79
+ return "" if cascade.steps.empty?
80
+
81
+ head = cascade.steps.first
82
+ out = head.reactants.map { |n| render_node(n) }.join(" + ")
83
+ cascade.steps.each do |step|
84
+ arrow = arrow_for(step.arrow)
85
+ conds = step.conditions
86
+ if conds
87
+ above = conditions_bracket(conds.above)
88
+ below = conditions_bracket(conds.below)
89
+ out += " #{arrow}#{above}#{below}"
90
+ else
91
+ out += " #{arrow}"
92
+ end
93
+ out += " " + step.products.map { |n| render_node(n) }.join(" + ")
94
+ end
95
+ out
96
+ end
97
+
98
+ def visit_electron_configuration(ec)
99
+ inner = ec.orbitals.map { |orb, occ| "#{wrap(orb)}^#{wrap(occ)}" }.join(" ")
100
+ "\\ce{#{inner}}"
101
+ end
102
+
103
+ def visit_embedded_math(em)
104
+ "$#{em.formula.to_latex}$"
105
+ end
106
+
107
+ def visit_text(text)
108
+ text.content
109
+ end
110
+
111
+ private
112
+
113
+ def render_node(node)
114
+ node.accept(self)
115
+ end
116
+
117
+ def within_ce
118
+ prev = @inside_ce
119
+ @inside_ce = true
120
+ yield
121
+ ensure
122
+ @inside_ce = prev
123
+ end
124
+
125
+ # mhchem accepts bare digits after an element as subscripts. Use
126
+ # bare form for digit-only values; brace form for anything else.
127
+ def mhchem_subscript(value)
128
+ s = value.to_s
129
+ return s if s.match?(/\A\d+\z/)
130
+
131
+ "_{#{s}}"
132
+ end
133
+
134
+ # mhchem accepts bare digits for multi-digit values (`^14`, `_12`).
135
+ # Use braces only for non-digit values to keep output idiomatic.
136
+ def wrap(value)
137
+ s = value.to_s
138
+ return s if s.match?(/\A[a-zA-Z0-9]+\z/)
139
+
140
+ "{#{s}}"
141
+ end
142
+
143
+ def blank?(value)
144
+ value.nil? || value.to_s.empty?
145
+ end
146
+
147
+ def bracket_chars(kind)
148
+ case kind
149
+ when :paren then ["(", ")"]
150
+ when :square then ["[", "]"]
151
+ when :brace then ["{", "}"]
152
+ else ["(", ")"]
153
+ end
154
+ end
155
+
156
+ def arrow_for(kind)
157
+ case kind
158
+ when :forward then "->"
159
+ when :reverse then "<-"
160
+ when :equilibrium then "<=>"
161
+ when :resonance then "<->"
162
+ else "->"
163
+ end
164
+ end
165
+
166
+ def conditions_bracket(value)
167
+ return "" if value.nil? || value.to_s.empty?
168
+
169
+ "[#{value}]"
170
+ end
171
+ end
172
+ end
173
+ end