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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +21 -0
- data/.gitignore +14 -0
- data/.rubocop.yml +50 -0
- data/ARCHITECTURE.adoc +239 -0
- data/CHANGELOG.md +17 -0
- data/CLAUDE.md +318 -0
- data/Gemfile +14 -0
- data/LICENSE +24 -0
- data/README.adoc +55 -0
- data/RELEASING.md +102 -0
- data/Rakefile +8 -0
- data/asciichem.gemspec +39 -0
- data/benchmarks/RESULTS.md +49 -0
- data/benchmarks/benchmark.rb +106 -0
- data/exe/asciichem +6 -0
- data/lib/asciichem/cli.rb +96 -0
- data/lib/asciichem/cml/extensions.rb +360 -0
- data/lib/asciichem/cml/group_extensions.rb +290 -0
- data/lib/asciichem/cml/translator.rb +69 -0
- data/lib/asciichem/cml.rb +40 -0
- data/lib/asciichem/errors.rb +9 -0
- data/lib/asciichem/formatter/base.rb +21 -0
- data/lib/asciichem/formatter/html.rb +111 -0
- data/lib/asciichem/formatter/latex.rb +173 -0
- data/lib/asciichem/formatter/mathml.rb +309 -0
- data/lib/asciichem/formatter/structural_svg.rb +286 -0
- data/lib/asciichem/formatter/svg.rb +141 -0
- data/lib/asciichem/formatter/text.rb +143 -0
- data/lib/asciichem/formatter.rb +43 -0
- data/lib/asciichem/grammar.rb +344 -0
- data/lib/asciichem/greek.rb +80 -0
- data/lib/asciichem/layout.rb +313 -0
- data/lib/asciichem/linter/balance_check.rb +103 -0
- data/lib/asciichem/linter/base.rb +52 -0
- data/lib/asciichem/linter/bracket_balance_check.rb +44 -0
- data/lib/asciichem/linter/diagnostic.rb +16 -0
- data/lib/asciichem/linter/element_validation_check.rb +39 -0
- data/lib/asciichem/linter/isotope_sanity_check.rb +47 -0
- data/lib/asciichem/linter/registry.rb +31 -0
- data/lib/asciichem/linter/unclosed_ring_check.rb +27 -0
- data/lib/asciichem/linter/valence_check.rb +91 -0
- data/lib/asciichem/linter.rb +40 -0
- data/lib/asciichem/model/atom.rb +73 -0
- data/lib/asciichem/model/bond.rb +41 -0
- data/lib/asciichem/model/electron_configuration.rb +36 -0
- data/lib/asciichem/model/embedded_math.rb +26 -0
- data/lib/asciichem/model/formula.rb +31 -0
- data/lib/asciichem/model/group.rb +50 -0
- data/lib/asciichem/model/identifier.rb +14 -0
- data/lib/asciichem/model/molecule.rb +70 -0
- data/lib/asciichem/model/name.rb +15 -0
- data/lib/asciichem/model/node.rb +100 -0
- data/lib/asciichem/model/reaction.rb +49 -0
- data/lib/asciichem/model/reaction_cascade.rb +33 -0
- data/lib/asciichem/model/text.rb +23 -0
- data/lib/asciichem/model.rb +23 -0
- data/lib/asciichem/model_adapter/from_canonical.rb +272 -0
- data/lib/asciichem/model_adapter/to_canonical.rb +429 -0
- data/lib/asciichem/model_adapter.rb +56 -0
- data/lib/asciichem/parser.rb +36 -0
- data/lib/asciichem/periodic_table.rb +112 -0
- data/lib/asciichem/ring_bonds.rb +68 -0
- data/lib/asciichem/transform.rb +513 -0
- data/lib/asciichem/version.rb +5 -0
- data/lib/asciichem/xml_builder.rb +9 -0
- data/lib/asciichem.rb +34 -0
- metadata +197 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Formatter
|
|
5
|
+
# Renders a Model tree as a linear SVG. The SVG draws the formula
|
|
6
|
+
# along a horizontal baseline with elements at fixed spacing.
|
|
7
|
+
#
|
|
8
|
+
# This is the v1 fallback for environments that want a self-contained
|
|
9
|
+
# vector output without MathML. True 2D structural diagrams (skeletal
|
|
10
|
+
# formulae, rings, stereo wedges) require `mn/elk-rb` integration
|
|
11
|
+
# (TODO 13); this formatter does not attempt that.
|
|
12
|
+
class Svg < Base
|
|
13
|
+
LINE_HEIGHT = 40
|
|
14
|
+
CHAR_WIDTH = 14
|
|
15
|
+
BASELINE = 30
|
|
16
|
+
|
|
17
|
+
def visit_formula(formula)
|
|
18
|
+
nodes = formula.nodes
|
|
19
|
+
rows = layout_rows(nodes)
|
|
20
|
+
width = rows.map { |r| layout_width(r) }.max
|
|
21
|
+
height = rows.size * LINE_HEIGHT + 10
|
|
22
|
+
render_svg(width, height, rows)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def visit_molecule(molecule)
|
|
26
|
+
prefix = molecule.coefficient.nil? || molecule.coefficient.to_s.empty? ? "" : molecule.coefficient.to_s
|
|
27
|
+
stereo = molecule.stereo ? "(#{molecule.stereo_letter})-" : ""
|
|
28
|
+
body = molecule.nodes.map { |n| render_node(n) }.join
|
|
29
|
+
"#{stereo}#{prefix}#{body}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def visit_atom(atom)
|
|
33
|
+
parts = []
|
|
34
|
+
parts << "^#{atom.isotope}" if atom.isotope
|
|
35
|
+
parts << atom.element
|
|
36
|
+
parts << "_#{atom.subscript}" if atom.subscript
|
|
37
|
+
if atom.charge
|
|
38
|
+
parts << "^#{atom.charge}"
|
|
39
|
+
elsif atom.oxidation_state
|
|
40
|
+
parts << "^(#{atom.oxidation_state})"
|
|
41
|
+
elsif atom.superscript
|
|
42
|
+
parts << "^#{atom.superscript}"
|
|
43
|
+
end
|
|
44
|
+
parts << atom.ring_closures.to_s if atom.ring_closures
|
|
45
|
+
parts.join
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def visit_group(group)
|
|
49
|
+
body = group.nodes.map { |n| render_node(n) }.join
|
|
50
|
+
suffix = group.multiplicity ? "_#{group.multiplicity}" : ""
|
|
51
|
+
"#{group.open_char}#{body}#{group.close_char}#{suffix}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def visit_bond(bond)
|
|
55
|
+
bond.ascii
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def visit_reaction(reaction)
|
|
59
|
+
left = reaction.reactants.map { |n| render_node(n) }.join(" + ")
|
|
60
|
+
right = reaction.products.map { |n| render_node(n) }.join(" + ")
|
|
61
|
+
arrow = reaction.arrow_ascii
|
|
62
|
+
conds = reaction.conditions
|
|
63
|
+
return "#{left} #{arrow} #{right}" unless conds
|
|
64
|
+
|
|
65
|
+
above = conds.above ? "[#{conds.above}]" : ""
|
|
66
|
+
below = conds.below ? "[#{conds.below}]" : ""
|
|
67
|
+
"#{left} #{arrow}#{above}#{below} #{right}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def visit_reaction_cascade(cascade)
|
|
71
|
+
return "" if cascade.steps.empty?
|
|
72
|
+
|
|
73
|
+
head = cascade.steps.first
|
|
74
|
+
out = head.reactants.map { |n| render_node(n) }.join(" + ")
|
|
75
|
+
cascade.steps.each do |step|
|
|
76
|
+
arrow = step.arrow_ascii
|
|
77
|
+
conds = step.conditions
|
|
78
|
+
if conds
|
|
79
|
+
above = conds.above ? "[#{conds.above}]" : ""
|
|
80
|
+
below = conds.below ? "[#{conds.below}]" : ""
|
|
81
|
+
out += " #{arrow}#{above}#{below}"
|
|
82
|
+
else
|
|
83
|
+
out += " #{arrow}"
|
|
84
|
+
end
|
|
85
|
+
out += " " + step.products.map { |n| render_node(n) }.join(" + ")
|
|
86
|
+
end
|
|
87
|
+
out
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def visit_electron_configuration(ec)
|
|
91
|
+
ec.orbitals.map { |orb, occ| "#{orb}^#{occ}" }.join(" ")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def visit_embedded_math(em)
|
|
95
|
+
em.source.to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def visit_text(text)
|
|
99
|
+
text.content
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def render_node(node)
|
|
105
|
+
node.accept(self)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def layout_rows(nodes)
|
|
109
|
+
# v1: one row. Multi-row layout deferred until 2D structural
|
|
110
|
+
# support arrives (TODO 13).
|
|
111
|
+
[nodes.map { |n| render_node(n) }.join]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def layout_width(row)
|
|
115
|
+
row.length * CHAR_WIDTH + 20
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def render_svg(width, height, rows)
|
|
119
|
+
title = rows.join(" ").gsub(/[<>&]/, "" => "")
|
|
120
|
+
lines = []
|
|
121
|
+
lines << %(<?xml version="1.0" encoding="UTF-8"?>)
|
|
122
|
+
lines << %(<svg xmlns="http://www.w3.org/2000/svg" width="#{width}" height="#{height}" viewBox="0 0 #{width} #{height}" role="img" aria-label="#{escape(title)}">)
|
|
123
|
+
lines << %( <title>#{escape(title)}</title>)
|
|
124
|
+
lines << %( <rect width="100%" height="100%" fill="transparent"/>)
|
|
125
|
+
rows.each_with_index do |row, idx|
|
|
126
|
+
y = BASELINE + (idx * LINE_HEIGHT)
|
|
127
|
+
lines << %( <text x="10" y="#{y}" font-family="serif" font-size="20" fill="currentColor">#{escape(row)}</text>)
|
|
128
|
+
end
|
|
129
|
+
lines << %(</svg>)
|
|
130
|
+
lines.join("\n")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def escape(string)
|
|
134
|
+
string.to_s
|
|
135
|
+
.gsub("&", "&")
|
|
136
|
+
.gsub("<", "<")
|
|
137
|
+
.gsub(">", ">")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
module Formatter
|
|
5
|
+
# Renders a Model tree as canonical AsciiChem text. Round-trip
|
|
6
|
+
# conformance: `AsciiChem.parse(s).to_text == s` for any conformant
|
|
7
|
+
# `s`. The formatter is the canonicaliser — equivalent inputs map
|
|
8
|
+
# to the same output.
|
|
9
|
+
#
|
|
10
|
+
# Canonicalisation rules (v1):
|
|
11
|
+
# - Explicit subscript marker: `H_2`, not `H2`.
|
|
12
|
+
# - Coefficient before molecule: `2H_2O`.
|
|
13
|
+
# - Isotope binds to atom: `^14C` (no `{}` carrier).
|
|
14
|
+
# - Charge is number-then-sign per IUPAC: `Ca^2+`.
|
|
15
|
+
# - Oxidation state in roman numerals with parens: `Fe^(III)`.
|
|
16
|
+
# - Group brackets preserved from input.
|
|
17
|
+
# - Reaction arrows use the canonical ASCII spelling (`->`, `<-`,
|
|
18
|
+
# `<=>`, `<->`).
|
|
19
|
+
class Text < Base
|
|
20
|
+
def visit_formula(formula)
|
|
21
|
+
formula.nodes.map { |n| render_node(n) }.join(" ")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def visit_molecule(molecule)
|
|
25
|
+
prefix = molecule.coefficient.nil? || molecule.coefficient.empty? ? "" : molecule.coefficient.to_s
|
|
26
|
+
stereo = molecule.stereo ? "(#{molecule.stereo_letter})-" : ""
|
|
27
|
+
body = molecule.nodes.map { |n| render_node(n) }.join
|
|
28
|
+
annotations = molecule_annotations(molecule)
|
|
29
|
+
"#{stereo}#{prefix}#{body}#{annotations}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def molecule_annotations(molecule)
|
|
33
|
+
parts = []
|
|
34
|
+
molecule.names.each { |n| parts << %(@name("#{n.content}")) }
|
|
35
|
+
molecule.identifiers.each { |i| parts << %(@#{i.convention}("#{i.value}")) }
|
|
36
|
+
parts << %(@title("#{molecule.title}")) if molecule.title
|
|
37
|
+
molecule.formulas.each { |f| parts << %(@formula("#{f[:concise]}")) if f[:concise] }
|
|
38
|
+
molecule.labels.each { |l| parts << %(@label("#{l[:value]}")) if l[:value] }
|
|
39
|
+
molecule.properties.each { |p| parts << %(@#{p[:title]}("#{p[:value]}")) if p[:title] && p[:value] }
|
|
40
|
+
molecule.metadata.each { |m| parts << %(@meta("#{m[:name]}","#{m[:content]}")) }
|
|
41
|
+
parts.empty? ? "" : " #{parts.join}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def visit_atom(atom)
|
|
45
|
+
parts = []
|
|
46
|
+
parts << (":" * atom.lone_pairs) if atom.lone_pairs
|
|
47
|
+
parts << "^#{atom.isotope}" if atom.isotope
|
|
48
|
+
parts << atom.element
|
|
49
|
+
parts << "_#{atom.subscript}" if atom.subscript
|
|
50
|
+
parts << "^#{atom.superscript}" if atom.superscript
|
|
51
|
+
parts << "^#{atom.charge}" if atom.charge
|
|
52
|
+
parts << "^(#{atom.oxidation_state})" if atom.oxidation_state
|
|
53
|
+
parts << ("." * atom.radical_electrons) if atom.radical_electrons
|
|
54
|
+
parts << atom.ring_closures.to_s if atom.ring_closures
|
|
55
|
+
parts << atom_annotation(atom)
|
|
56
|
+
parts.join
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def atom_annotation(atom)
|
|
60
|
+
annotation = +""
|
|
61
|
+
if atom.x2 && atom.y2
|
|
62
|
+
annotation << "@(#{format_coord(atom.x2)},#{format_coord(atom.y2)}"
|
|
63
|
+
annotation << ",#{format_coord(atom.z2)}" if atom.z2
|
|
64
|
+
annotation << ")"
|
|
65
|
+
elsif atom.atom_parity
|
|
66
|
+
annotation << "@#{atom.atom_parity}"
|
|
67
|
+
end
|
|
68
|
+
annotation
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def format_coord(value)
|
|
72
|
+
value == value.to_i ? value.to_i.to_s : value.to_s
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def visit_group(group)
|
|
76
|
+
body = group.nodes.map { |n| render_node(n) }.join
|
|
77
|
+
suffix = group.multiplicity ? "_#{group.multiplicity}" : ""
|
|
78
|
+
"#{group.open_char}#{body}#{group.close_char}#{suffix}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def visit_bond(bond)
|
|
82
|
+
bond.ascii
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def visit_reaction(reaction)
|
|
86
|
+
left = reaction.reactants.map { |n| render_node(n) }.join(" + ")
|
|
87
|
+
right = reaction.products.map { |n| render_node(n) }.join(" + ")
|
|
88
|
+
arrow = reaction.arrow_ascii
|
|
89
|
+
conds = reaction.conditions
|
|
90
|
+
return "#{left} #{arrow} #{right}" unless conds
|
|
91
|
+
|
|
92
|
+
above = conds.above ? "[#{conds.above}]" : ""
|
|
93
|
+
below = conds.below ? "[#{conds.below}]" : ""
|
|
94
|
+
"#{left} #{arrow}#{above}#{below} #{right}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def visit_reaction_cascade(cascade)
|
|
98
|
+
return "" if cascade.steps.empty?
|
|
99
|
+
|
|
100
|
+
head = cascade.steps.first
|
|
101
|
+
out = "#{render_terms(head.reactants)} #{render_arrow_with_conditions(head)} #{render_terms(head.products)}"
|
|
102
|
+
cascade.steps.drop(1).each do |step|
|
|
103
|
+
out += " #{render_arrow_with_conditions(step)} #{render_terms(step.products)}"
|
|
104
|
+
end
|
|
105
|
+
out
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def visit_electron_configuration(ec)
|
|
109
|
+
parts = ec.orbitals.map { |orb, occ| "#{orb}^#{occ}" }
|
|
110
|
+
parts << ec.term_symbol.to_s if ec.term_symbol
|
|
111
|
+
parts.join(" ")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def visit_embedded_math(em)
|
|
115
|
+
"`#{em.source}`"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def visit_text(text)
|
|
119
|
+
%("#{text.content}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def render_node(node)
|
|
125
|
+
node.accept(self)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def render_terms(terms)
|
|
129
|
+
terms.map { |n| render_node(n) }.join(" + ")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def render_arrow_with_conditions(reaction)
|
|
133
|
+
arrow = reaction.arrow_ascii
|
|
134
|
+
conds = reaction.conditions
|
|
135
|
+
return arrow unless conds
|
|
136
|
+
|
|
137
|
+
above = conds.above ? "[#{conds.above}]" : ""
|
|
138
|
+
below = conds.below ? "[#{conds.below}]" : ""
|
|
139
|
+
"#{arrow}#{above}#{below}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AsciiChem
|
|
4
|
+
# Format registry. Each output (MathML, Text, HTML, LaTeX, SVG) is a
|
|
5
|
+
# class under this module. The model's `to_<name>` shortcuts route
|
|
6
|
+
# through `Formatter[<name>].new.render(node)`.
|
|
7
|
+
#
|
|
8
|
+
# To add a new formatter:
|
|
9
|
+
# 1. Create `lib/asciichem/formatter/<name>.rb` with a class
|
|
10
|
+
# `AsciiChem::Formatter::<ClassCamel> < Base`.
|
|
11
|
+
# 2. Add `autoload :<ClassCamel>, "asciichem/formatter/<name>"` to
|
|
12
|
+
# this file.
|
|
13
|
+
# 3. Add `def to_<name>` to `Model::Node` if a shortcut is desired.
|
|
14
|
+
#
|
|
15
|
+
# No edits to existing formatters — OCP.
|
|
16
|
+
module Formatter
|
|
17
|
+
autoload :Base, "asciichem/formatter/base"
|
|
18
|
+
autoload :Html, "asciichem/formatter/html"
|
|
19
|
+
autoload :Latex, "asciichem/formatter/latex"
|
|
20
|
+
autoload :Mathml, "asciichem/formatter/mathml"
|
|
21
|
+
autoload :StructuralSvg, "asciichem/formatter/structural_svg"
|
|
22
|
+
autoload :Svg, "asciichem/formatter/svg"
|
|
23
|
+
autoload :Text, "asciichem/formatter/text"
|
|
24
|
+
|
|
25
|
+
# Lookup by format name. Triggers autoload; raises FormatError if
|
|
26
|
+
# the name is not registered. Accepts either snake_case
|
|
27
|
+
# (`:structural_svg`) or camelCase (`:mathml`) — both resolve to
|
|
28
|
+
# the matching constant.
|
|
29
|
+
def self.[](name)
|
|
30
|
+
const_get(camelize(name.to_s))
|
|
31
|
+
rescue NameError => e
|
|
32
|
+
raise AsciiChem::FormatError, "unknown formatter #{name.inspect}: #{e.message}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.render(name, node)
|
|
36
|
+
self[name].new.render(node)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private_class_method def self.camelize(string)
|
|
40
|
+
string.split("_").map(&:capitalize).join
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parslet"
|
|
4
|
+
|
|
5
|
+
module AsciiChem
|
|
6
|
+
# Parslet grammar for AsciiChem v1.
|
|
7
|
+
#
|
|
8
|
+
# Design notes:
|
|
9
|
+
#
|
|
10
|
+
# - Leaf rules return strings; `.as(:name)` is applied at the
|
|
11
|
+
# combination site so the parse tree is a flat hash of named strings
|
|
12
|
+
# wherever practical.
|
|
13
|
+
# - The prefix-isotope binding (`^14C`) is enforced structurally: the
|
|
14
|
+
# `prefixed_atom` production consumes `^digits element` as a unit.
|
|
15
|
+
# A bare `^digits` without an element is rejected.
|
|
16
|
+
# - Marker rules (`subscript_marker`, `superscript_marker`) capture
|
|
17
|
+
# ONLY the value, not the leading `_` or `^`. The literal prefix is
|
|
18
|
+
# consumed by the rule but not included in the named capture.
|
|
19
|
+
class Grammar < Parslet::Parser
|
|
20
|
+
root :formula
|
|
21
|
+
|
|
22
|
+
# -- top level ---------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
rule(:formula) do
|
|
25
|
+
spaces? >> nodes.as(:formula) >> spaces?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
rule(:nodes) { node >> (spaces? >> node).repeat }
|
|
29
|
+
|
|
30
|
+
rule(:node) { reaction_cascade | reaction | electron_config | annotated_molecule | molecule | embedded_math | text_run.as(:text_run) }
|
|
31
|
+
|
|
32
|
+
# Annotated molecule: a molecule followed by one or more
|
|
33
|
+
# `@key("value")` annotations for CML metadata (names,
|
|
34
|
+
# identifiers, title, formula, labels).
|
|
35
|
+
rule(:annotated_molecule) do
|
|
36
|
+
molecule.as(:mol) >> molecule_annotation.repeat(1).as(:annotations)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
rule(:molecule_annotation) do
|
|
40
|
+
spaces?.maybe >>
|
|
41
|
+
(metadata_annotation | simple_annotation)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Metadata: @meta("key","value") — two quoted args, comma-separated.
|
|
45
|
+
# Produces CML <metadata name="key" content="value"/>.
|
|
46
|
+
# Uses distinct capture keys (meta_key/meta_value) so the transform
|
|
47
|
+
# can distinguish metadata from regular @key("value") annotations.
|
|
48
|
+
rule(:metadata_annotation) do
|
|
49
|
+
str('@meta(') >> str('"') >>
|
|
50
|
+
(str('"').absent? >> any).repeat.as(:meta_key) >> str('"') >>
|
|
51
|
+
str(',') >> str('"') >>
|
|
52
|
+
(str('"').absent? >> any).repeat.as(:meta_value) >> str('"') >>
|
|
53
|
+
str(')')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Simple annotation: @key("value") — one quoted arg.
|
|
57
|
+
# Known types (name, inchi, etc.) are handled specially by the
|
|
58
|
+
# transform. Unknown types become properties.
|
|
59
|
+
rule(:simple_annotation) do
|
|
60
|
+
str('@') >> annotation_type.as(:ann_type) >>
|
|
61
|
+
str('(') >> str('"') >>
|
|
62
|
+
(str('"').absent? >> any).repeat.as(:ann_value) >>
|
|
63
|
+
str('"') >> str(')')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Known annotation types first; property_name is a catch-all so
|
|
67
|
+
# any lowercase word (e.g. "mw", "density", "logP") becomes a
|
|
68
|
+
# property annotation.
|
|
69
|
+
rule(:annotation_type) do
|
|
70
|
+
str('name') | str('title') | str('formula') | str('label') |
|
|
71
|
+
str('inchi') | str('smiles') | str('cas') | str('iupac') |
|
|
72
|
+
str('cid') | str('chebi') |
|
|
73
|
+
property_name
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
rule(:property_name) do
|
|
77
|
+
match('[a-z]').repeat(1)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# -- reactions ---------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
# A reaction cascade is two or more reactions chained together:
|
|
83
|
+
# the products of step N become the reactants of step N+1, with
|
|
84
|
+
# an arrow between them. The grammar reuses `reaction` for the
|
|
85
|
+
# first leg and `arrow >> terms` for each subsequent leg; the
|
|
86
|
+
# transform promotes the whole chain to a `ReactionCascade`.
|
|
87
|
+
rule(:reaction_cascade) do
|
|
88
|
+
(reaction.as(:first) >>
|
|
89
|
+
(arrow.as(:arrow) >> spaces? >> terms.as(:products)).repeat(1)).as(:cascade)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
rule(:reaction) do
|
|
93
|
+
terms.as(:reactants) >>
|
|
94
|
+
arrow.as(:arrow) >>
|
|
95
|
+
spaces? >>
|
|
96
|
+
terms.as(:products)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
rule(:terms) do
|
|
100
|
+
molecule >> (spaces? >> plus >> spaces? >> molecule).repeat
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
rule(:plus) { str("+") }
|
|
104
|
+
|
|
105
|
+
rule(:arrow) do
|
|
106
|
+
spaces? >>
|
|
107
|
+
arrow_token.as(:kind) >>
|
|
108
|
+
condition.maybe.as(:above) >>
|
|
109
|
+
condition.maybe.as(:below)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
rule(:condition) do
|
|
113
|
+
str("[") >> (str("]").absent? >> any).repeat.as(:text) >> str("]")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
rule(:arrow_token) do
|
|
117
|
+
str("<=>") | str("<->") | str("->") | str("<-")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# -- molecules ---------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
rule(:molecule) do
|
|
123
|
+
stereo_prefix.maybe >> coefficient.maybe >> units.as(:units)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Stereochemistry prefix: `(R)-`, `(S)-`, `(E)-`, `(Z)-`,
|
|
127
|
+
# `(a)-`/`(α)-` (alpha), `(b)-`/`(β)-` (beta). Tried before
|
|
128
|
+
# `coefficient` so the lookahead-via-failure on the closed letter
|
|
129
|
+
# set disambiguates from a parenthesised group: `(R)` matches
|
|
130
|
+
# because `R` is in the stereo set; `(OH)` fails because `OH` is
|
|
131
|
+
# not a single stereo letter, and the molecule rule falls through
|
|
132
|
+
# to the regular group parse.
|
|
133
|
+
rule(:stereo_prefix) do
|
|
134
|
+
str("(") >> stereo_letter.as(:stereo) >> str(")") >> str("-")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
rule(:stereo_letter) do
|
|
138
|
+
str("alpha") | str("beta") |
|
|
139
|
+
str("R") | str("S") | str("E") | str("Z") |
|
|
140
|
+
str("α") | str("β") |
|
|
141
|
+
str("a") | str("b")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
rule(:units) { (unit | bond).repeat(1) }
|
|
145
|
+
rule(:unit) { prefixed_atom | group | plain_atom }
|
|
146
|
+
|
|
147
|
+
# Bonds appear inside molecules as separators between units.
|
|
148
|
+
# Supported kinds, in alternation order (longest match first to
|
|
149
|
+
# avoid `>-` shadowing `-`):
|
|
150
|
+
# single `-`
|
|
151
|
+
# double `=`
|
|
152
|
+
# triple `#`
|
|
153
|
+
# quadruple `##`
|
|
154
|
+
# wedge `>-` (solid wedge toward viewer)
|
|
155
|
+
# hash `-<` (hashed wedge away from viewer)
|
|
156
|
+
# dative `~>` (electron-pair donor → acceptor; `->` is taken
|
|
157
|
+
# by the reaction arrow)
|
|
158
|
+
# wavy `~~` (resonance / delocalised)
|
|
159
|
+
rule(:bond) do
|
|
160
|
+
str("##").as(:quadruple) |
|
|
161
|
+
str(">-").as(:wedge) |
|
|
162
|
+
str("-<").as(:hash) |
|
|
163
|
+
str("~>").as(:dative) |
|
|
164
|
+
str("~~").as(:wavy) |
|
|
165
|
+
str("#").as(:triple) |
|
|
166
|
+
str("=").as(:double) |
|
|
167
|
+
str("-").as(:single)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
rule(:coefficient) do
|
|
171
|
+
(digits.as(:value) >> (element_symbol | open_bracket).present?).as(:coefficient)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
rule(:open_bracket) { str("(") | str("[") | str("{") }
|
|
175
|
+
|
|
176
|
+
# -- atoms -------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
rule(:prefixed_atom) do
|
|
179
|
+
(lewis_prefix.maybe >>
|
|
180
|
+
isotope_marker.as(:isotope) >>
|
|
181
|
+
element_symbol.as(:element) >>
|
|
182
|
+
atom_suffix >>
|
|
183
|
+
lewis_radicals.maybe >>
|
|
184
|
+
ring_closures.maybe.as(:ring_closures) >>
|
|
185
|
+
atom_annotation.maybe).as(:atom)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
rule(:plain_atom) do
|
|
189
|
+
(lewis_prefix.maybe >>
|
|
190
|
+
element_symbol.as(:element) >>
|
|
191
|
+
atom_suffix >>
|
|
192
|
+
lewis_radicals.maybe >>
|
|
193
|
+
ring_closures.maybe.as(:ring_closures) >>
|
|
194
|
+
atom_annotation.maybe).as(:atom)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Atom annotations: stereo parity (@R / @S) or 2D/3D coordinates
|
|
198
|
+
# (@(x,y) / @(x,y,z)). Both use the `@` prefix. An atom can carry
|
|
199
|
+
# at most one annotation in the grammar; multiple annotations
|
|
200
|
+
# would require compound syntax (deferred).
|
|
201
|
+
rule(:atom_annotation) do
|
|
202
|
+
coordinate_annotation | parity_annotation
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
rule(:parity_annotation) do
|
|
206
|
+
str('@') >> (str('R') | str('S')).as(:atom_parity)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
rule(:coordinate_annotation) do
|
|
210
|
+
str('@(') >>
|
|
211
|
+
float_number.as(:x2) >> str(',') >>
|
|
212
|
+
float_number.as(:y2) >>
|
|
213
|
+
(str(',') >> float_number.as(:z2)).maybe >>
|
|
214
|
+
str(')')
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
rule(:float_number) do
|
|
218
|
+
str('-').maybe >> match('[0-9]').repeat(1) >> (str('.') >> match('[0-9]').repeat(0)).maybe
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Ring closure digits (SMILES-style). A digit suffix on an atom
|
|
222
|
+
# opens or closes a ring; two atoms with the same digit become
|
|
223
|
+
# bonded. Captured as a string (e.g. "1", "12") so multiple
|
|
224
|
+
# closures on one atom are preserved.
|
|
225
|
+
rule(:ring_closures) do
|
|
226
|
+
match('[0-9]').repeat(1)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
rule(:atom_suffix) do
|
|
230
|
+
subscript_marker.maybe.as(:subscript) >>
|
|
231
|
+
superscript_marker.maybe.as(:superscript)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Lewis markers. Prefix `:` count = lone_pairs (binds to following
|
|
235
|
+
# atom, like the isotope prefix). Suffix `.` count = radical
|
|
236
|
+
# electrons. Position-specific lone pairs (`:O:` style) collapse
|
|
237
|
+
# into a total count — the renderer decides layout.
|
|
238
|
+
rule(:lewis_prefix) { str(":").repeat(1).as(:lone_pairs) }
|
|
239
|
+
rule(:lewis_radicals) { str(".").repeat(1).as(:radical_electrons) }
|
|
240
|
+
|
|
241
|
+
# Markers consume the leading `_` / `^` but capture only the value.
|
|
242
|
+
rule(:isotope_marker) do
|
|
243
|
+
(str("^") | str("_")) >> digits
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
rule(:subscript_marker) do
|
|
247
|
+
str("_") >> subscript_value
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
rule(:superscript_marker) do
|
|
251
|
+
str("^") >> superscript_value
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
rule(:element_symbol) do
|
|
255
|
+
match("[A-Z]") >> match("[a-z]").maybe
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
rule(:subscript_value) do
|
|
259
|
+
(str("{") >> (str("}").absent? >> any).repeat >> str("}")) |
|
|
260
|
+
digits
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
rule(:superscript_value) do
|
|
264
|
+
oxidation_state | charge | braced_or_bare
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
rule(:charge) do
|
|
268
|
+
(digits >> match("[+-]")) |
|
|
269
|
+
(match("[+-]") >> digits.maybe) |
|
|
270
|
+
digits
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
rule(:oxidation_state) do
|
|
274
|
+
str("(") >> roman_numeral >> str(")")
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
rule(:roman_numeral) { match("[IVXLCDM]").repeat(1) }
|
|
278
|
+
|
|
279
|
+
rule(:braced_or_bare) do
|
|
280
|
+
(str("{") >> (str("}").absent? >> any).repeat >> str("}")) |
|
|
281
|
+
match("[0-9a-zA-Z]").repeat(1)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# -- groups ------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
rule(:group) do
|
|
287
|
+
group_open.as(:open_bracket) >>
|
|
288
|
+
group_nodes >>
|
|
289
|
+
group_close.as(:close_bracket) >>
|
|
290
|
+
multiplicity.maybe.as(:multiplicity)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# `group_nodes` is a separate rule so we can carve out the closing
|
|
294
|
+
# bracket from `node`'s text fallback. Without this, `text_run`
|
|
295
|
+
# would consume the closing bracket and the group never terminates.
|
|
296
|
+
rule(:group_nodes) do
|
|
297
|
+
group_node.repeat(1).as(:group_nodes)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
rule(:group_node) do
|
|
301
|
+
reaction | electron_config | molecule | embedded_math | group_text_run
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
rule(:group_text_run) do
|
|
305
|
+
str('"') >> (str('"').absent? >> any).repeat >> str('"')
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
rule(:group_open) { str("(") | str("[") | str("{") }
|
|
309
|
+
rule(:group_close) { str(")") | str("]") | str("}") }
|
|
310
|
+
|
|
311
|
+
rule(:multiplicity) { str("_") >> digits }
|
|
312
|
+
|
|
313
|
+
# -- electron configuration -------------------------------------------
|
|
314
|
+
|
|
315
|
+
rule(:electron_config) do
|
|
316
|
+
(orbital.as(:orbital) >> str("^") >> digits.as(:occupancy) >> spaces?.maybe).repeat(2).as(:electron_config)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
rule(:orbital) { digits >> match("[spdfgh]") }
|
|
320
|
+
|
|
321
|
+
# -- embedded math ----------------------------------------------------
|
|
322
|
+
|
|
323
|
+
rule(:embedded_math) do
|
|
324
|
+
str("`") >>
|
|
325
|
+
(str("`").absent? >> any).repeat.as(:math_source) >>
|
|
326
|
+
str("`")
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# -- text (top level) -------------------------------------------------
|
|
330
|
+
|
|
331
|
+
# Free-form text uses `"..."` delimiters, matching AsciiMath's
|
|
332
|
+
# convention. The quoted content becomes a Text node with the
|
|
333
|
+
# surrounding quotes stripped (handled by the transform).
|
|
334
|
+
rule(:text_run) do
|
|
335
|
+
str('"') >> (str('"').absent? >> any).repeat >> str('"')
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# -- primitives -------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
rule(:digits) { match("[0-9]").repeat(1) }
|
|
341
|
+
rule(:spaces) { match(/\s/).repeat(1) }
|
|
342
|
+
rule(:spaces?) { spaces.maybe }
|
|
343
|
+
end
|
|
344
|
+
end
|