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,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module AsciiChem
|
|
6
|
+
module Formatter
|
|
7
|
+
# Renders a Model tree as presentation MathML.
|
|
8
|
+
#
|
|
9
|
+
# The headline semantic fix lives here: prefix isotopes bind to the
|
|
10
|
+
# atom, so `^14C` renders as `<msup><mi>C</mi><mn>14</mn></msup>`
|
|
11
|
+
# (Carbon with 14 as superscript). AsciiMath would emit
|
|
12
|
+
# `<msup><mi></mi><mn>14</mn></msup><mi>C</mi>` — a phantom base
|
|
13
|
+
# followed by a sibling atom, which loses the binding.
|
|
14
|
+
class Mathml < Base
|
|
15
|
+
MATHML_NS = "http://www.w3.org/1998/Math/MathML".freeze
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@doc = Nokogiri::XML::Document.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Model entry point. Returns a `<math>` element as a string.
|
|
22
|
+
def visit_formula(formula)
|
|
23
|
+
math = el("math", xmlns: MATHML_NS)
|
|
24
|
+
mrow = el("mrow")
|
|
25
|
+
formula.nodes.each { |n| mrow.add_child(render_node(n)) }
|
|
26
|
+
math.add_child(mrow)
|
|
27
|
+
@doc.root = math
|
|
28
|
+
# Native UTF-8 output preserves unicode arrow entities so specs
|
|
29
|
+
# and downstream consumers see ⇌ and → instead of ⇌.
|
|
30
|
+
@doc.to_xml(encoding: "UTF-8")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def visit_molecule(molecule)
|
|
34
|
+
mrow = el("mrow")
|
|
35
|
+
if molecule.stereo
|
|
36
|
+
mrow.add_child(mtext("(#{molecule.stereo_letter})-"))
|
|
37
|
+
end
|
|
38
|
+
mrow.add_child(mn(molecule.coefficient)) if molecule.coefficient
|
|
39
|
+
molecule.nodes.each { |n| mrow.add_child(render_node(n)) }
|
|
40
|
+
mrow
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def visit_atom(atom)
|
|
44
|
+
base = mi(atom.element)
|
|
45
|
+
# Isotope is a LEFT superscript per IUPAC (¹⁴C). Use
|
|
46
|
+
# <mmultiscripts> with <mprescripts/> so the binding stays on
|
|
47
|
+
# the atom — the AsciiMath `<msup><mi></mi>...<mi>C</mi>`
|
|
48
|
+
# pattern (empty base + sibling) loses the binding.
|
|
49
|
+
base = attach_isotope_prefix(base, atom) if atom.isotope
|
|
50
|
+
base = wrap_lewis_prefix(base, atom)
|
|
51
|
+
# When the atom has BOTH subscript and a superscript-style
|
|
52
|
+
# marker (charge, oxidation state, or raw superscript), emit
|
|
53
|
+
# a single <msubsup> rather than nesting <msub> inside <msup>.
|
|
54
|
+
base = wrap_sub_and_super(base, atom)
|
|
55
|
+
base = wrap_lewis_suffix(base, atom)
|
|
56
|
+
base = wrap_ring_closures(base, atom)
|
|
57
|
+
base
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def wrap_ring_closures(base, atom)
|
|
61
|
+
return base unless atom.ring_closures
|
|
62
|
+
|
|
63
|
+
mrow = el("mrow")
|
|
64
|
+
mrow.add_child(base)
|
|
65
|
+
mrow.add_child(mn(atom.ring_closures))
|
|
66
|
+
mrow
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def attach_isotope_prefix(base, atom)
|
|
70
|
+
multi = el("mmultiscripts")
|
|
71
|
+
multi.add_child(base)
|
|
72
|
+
multi.add_child(el("none"))
|
|
73
|
+
multi.add_child(el("none"))
|
|
74
|
+
multi.add_child(Nokogiri::XML::Element.new("mprescripts", @doc))
|
|
75
|
+
multi.add_child(el("none"))
|
|
76
|
+
multi.add_child(mn(atom.isotope))
|
|
77
|
+
multi
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def wrap_lewis_prefix(base, atom)
|
|
81
|
+
return base unless atom.lone_pairs
|
|
82
|
+
|
|
83
|
+
mrow = el("mrow")
|
|
84
|
+
mrow.add_child(mtext(":" * atom.lone_pairs))
|
|
85
|
+
mrow.add_child(base)
|
|
86
|
+
mrow
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def wrap_lewis_suffix(base, atom)
|
|
90
|
+
return base unless atom.radical_electrons
|
|
91
|
+
|
|
92
|
+
mrow = el("mrow")
|
|
93
|
+
mrow.add_child(base)
|
|
94
|
+
mrow.add_child(mtext("." * atom.radical_electrons))
|
|
95
|
+
mrow
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Combine subscript + superscript-style marker into the right
|
|
99
|
+
# MathML element. Cases:
|
|
100
|
+
# sub + super -> <msubsup>
|
|
101
|
+
# sub only -> <msub>
|
|
102
|
+
# super only -> <msup> (with charge/oxidation/raw super)
|
|
103
|
+
# neither -> base unchanged
|
|
104
|
+
def wrap_sub_and_super(base, atom)
|
|
105
|
+
has_sub = atom.subscript && !atom.subscript.empty?
|
|
106
|
+
super_node = super_element(atom)
|
|
107
|
+
return base unless has_sub || super_node
|
|
108
|
+
return wrap_in_sub(base, mn(atom.subscript)) if has_sub && !super_node
|
|
109
|
+
return wrap_in_sup(base, super_node) if !has_sub && super_node
|
|
110
|
+
|
|
111
|
+
msubsup = el("msubsup")
|
|
112
|
+
msubsup.add_child(base)
|
|
113
|
+
msubsup.add_child(mn(atom.subscript))
|
|
114
|
+
msubsup.add_child(super_node)
|
|
115
|
+
msubsup
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Build the MathML element for whichever superscript-style
|
|
119
|
+
# marker is set (charge > oxidation_state > raw superscript).
|
|
120
|
+
# Returns nil if none is set.
|
|
121
|
+
def super_element(atom)
|
|
122
|
+
if atom.charge
|
|
123
|
+
row = charge_row(atom.charge)
|
|
124
|
+
return row if row
|
|
125
|
+
end
|
|
126
|
+
if atom.oxidation_state
|
|
127
|
+
mrow = el("mrow")
|
|
128
|
+
mrow.add_child(mi(atom.oxidation_state))
|
|
129
|
+
return mrow
|
|
130
|
+
end
|
|
131
|
+
if atom.superscript
|
|
132
|
+
return mn(atom.superscript)
|
|
133
|
+
end
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def charge_row(charge)
|
|
138
|
+
match = charge.match(/\A(?<n>\d*)(?<sign>[+-])\z/) ||
|
|
139
|
+
charge.match(/\A(?<sign>[+-])(?<n>\d*)\z/)
|
|
140
|
+
return nil unless match
|
|
141
|
+
|
|
142
|
+
mrow = el("mrow")
|
|
143
|
+
mrow.add_child(mn(match[:n])) unless match[:n].empty?
|
|
144
|
+
mrow.add_child(mo(match[:sign]))
|
|
145
|
+
mrow
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def visit_group(group)
|
|
149
|
+
mrow = el("mrow")
|
|
150
|
+
mrow.add_child(mo(group.open_char))
|
|
151
|
+
group.nodes.each { |n| mrow.add_child(render_node(n)) }
|
|
152
|
+
mrow.add_child(mo(group.close_char))
|
|
153
|
+
return mrow unless group.multiplicity
|
|
154
|
+
|
|
155
|
+
msub = el("msub")
|
|
156
|
+
msub.add_child(mrow)
|
|
157
|
+
msub.add_child(mn(group.multiplicity))
|
|
158
|
+
msub
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def visit_bond(bond)
|
|
162
|
+
mo(bond.entity)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def visit_reaction(reaction)
|
|
166
|
+
mrow = el("mrow")
|
|
167
|
+
add_terms(mrow, reaction.reactants)
|
|
168
|
+
mrow.add_child(render_arrow(reaction))
|
|
169
|
+
add_terms(mrow, reaction.products)
|
|
170
|
+
mrow
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def visit_reaction_cascade(cascade)
|
|
174
|
+
mrow = el("mrow")
|
|
175
|
+
return mrow if cascade.steps.empty?
|
|
176
|
+
|
|
177
|
+
head = cascade.steps.first
|
|
178
|
+
add_terms(mrow, head.reactants)
|
|
179
|
+
cascade.steps.each do |step|
|
|
180
|
+
mrow.add_child(render_arrow(step))
|
|
181
|
+
add_terms(mrow, step.products)
|
|
182
|
+
end
|
|
183
|
+
mrow
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def visit_electron_configuration(ec)
|
|
187
|
+
mrow = el("mrow")
|
|
188
|
+
ec.orbitals.each_with_index do |(orbital, occupancy), index|
|
|
189
|
+
mrow.add_child(mo(" ")) if index.positive?
|
|
190
|
+
msup = el("msup")
|
|
191
|
+
msup.add_child(mi(orbital))
|
|
192
|
+
msup.add_child(mn(occupancy))
|
|
193
|
+
mrow.add_child(msup)
|
|
194
|
+
end
|
|
195
|
+
mrow
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def visit_embedded_math(em)
|
|
199
|
+
# Strip the outer <math> wrapper so the embedded fragment slots
|
|
200
|
+
# into our surrounding <mrow>.
|
|
201
|
+
fragment = em.formula.to_mathml
|
|
202
|
+
parsed = Nokogiri::XML(fragment)
|
|
203
|
+
math = parsed.at_xpath("//m:math", m: MATHML_NS)
|
|
204
|
+
return el("mrow") unless math
|
|
205
|
+
|
|
206
|
+
# Detach children into a fresh <mrow>.
|
|
207
|
+
mrow = el("mrow")
|
|
208
|
+
math.children.each { |c| mrow.add_child(c.dup) }
|
|
209
|
+
mrow
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def visit_text(text)
|
|
213
|
+
mtext(text.content)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
def render_node(node)
|
|
219
|
+
node.accept(self)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def add_terms(parent, terms)
|
|
223
|
+
terms.each_with_index do |term, index|
|
|
224
|
+
parent.add_child(mo("+")) if index.positive?
|
|
225
|
+
parent.add_child(render_node(term))
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def render_arrow(reaction)
|
|
230
|
+
op = mo(reaction.arrow_entity)
|
|
231
|
+
return op unless reaction.conditions
|
|
232
|
+
|
|
233
|
+
above = reaction.conditions.above
|
|
234
|
+
below = reaction.conditions.below
|
|
235
|
+
return op unless above || below
|
|
236
|
+
|
|
237
|
+
moverunder = el(above && below ? "munderover" : (above ? "mover" : "munder"))
|
|
238
|
+
moverunder.add_child(op)
|
|
239
|
+
moverunder.add_child(render_condition(above)) if above
|
|
240
|
+
moverunder.add_child(render_condition(below)) if below
|
|
241
|
+
moverunder
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Render a reaction-condition string. The condition is captured
|
|
245
|
+
# as raw text by the grammar, but chemists expect `_N` and `^N`
|
|
246
|
+
# patterns to render as proper sub/superscripts. We parse the
|
|
247
|
+
# condition as AsciiChem and use its MathML output. If the parse
|
|
248
|
+
# fails (e.g. the condition is free-form prose), fall back to
|
|
249
|
+
# plain <mtext>.
|
|
250
|
+
def render_condition(text)
|
|
251
|
+
return mtext("") if text.nil? || text.empty?
|
|
252
|
+
|
|
253
|
+
begin
|
|
254
|
+
inner = AsciiChem.parse(text).to_mathml
|
|
255
|
+
parsed = Nokogiri::XML(inner)
|
|
256
|
+
math = parsed.at_xpath("//m:math", m: MATHML_NS)
|
|
257
|
+
if math
|
|
258
|
+
mrow = el("mrow")
|
|
259
|
+
math.children.each { |c| mrow.add_child(c.dup) }
|
|
260
|
+
return mrow
|
|
261
|
+
end
|
|
262
|
+
rescue AsciiChem::ParseError, AsciiChem::Error
|
|
263
|
+
# fall through to plain text
|
|
264
|
+
end
|
|
265
|
+
mtext(text)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def wrap_in_sub(base, sub)
|
|
269
|
+
msub = el("msub")
|
|
270
|
+
msub.add_child(base)
|
|
271
|
+
msub.add_child(sub)
|
|
272
|
+
msub
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def wrap_in_sup(base, sup)
|
|
276
|
+
msup = el("msup")
|
|
277
|
+
msup.add_child(base)
|
|
278
|
+
msup.add_child(sup)
|
|
279
|
+
msup
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# -- Nokogiri element factories ------------------------------------
|
|
283
|
+
|
|
284
|
+
def el(name, attrs = {})
|
|
285
|
+
element = Nokogiri::XML::Element.new(name, @doc)
|
|
286
|
+
attrs.each { |k, v| element[k.to_s] = v }
|
|
287
|
+
element
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def mi(content)
|
|
291
|
+
e = el("mi", mathvariant: "normal")
|
|
292
|
+
e.content = content.to_s
|
|
293
|
+
e
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def mn(content)
|
|
297
|
+
e = el("mn"); e.content = content.to_s; e
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def mo(content)
|
|
301
|
+
e = el("mo"); e.content = content.to_s; e
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def mtext(content)
|
|
305
|
+
e = el("mtext"); e.content = content.to_s; e
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
|
|
5
|
+
module AsciiChem
|
|
6
|
+
module Formatter
|
|
7
|
+
# Renders a Model tree as a 2D structural SVG diagram using
|
|
8
|
+
# elkrb-computed atom positions. Bonds render as parallel lines
|
|
9
|
+
# (single/double/triple/quadruple), wedges, hashes, or arrows
|
|
10
|
+
# depending on kind.
|
|
11
|
+
#
|
|
12
|
+
# Falls back to the linear `Svg` formatter for inputs that don't
|
|
13
|
+
# contain a molecule with bonds (formulae, reactions, etc.).
|
|
14
|
+
class StructuralSvg < Base
|
|
15
|
+
ATOM_RADIUS = 14
|
|
16
|
+
BOND_SPACING = 4
|
|
17
|
+
WEDGE_WIDTH = 6
|
|
18
|
+
HASH_DASH_COUNT = 5
|
|
19
|
+
ATOM_COLORS = {
|
|
20
|
+
'C' => '#1a1a1a',
|
|
21
|
+
'H' => '#555',
|
|
22
|
+
'O' => '#b91c1c',
|
|
23
|
+
'N' => '#1e40af',
|
|
24
|
+
'S' => '#a16207',
|
|
25
|
+
'Cl' => '#16a34a',
|
|
26
|
+
'F' => '#15803d',
|
|
27
|
+
'Br' => '#7c2d12',
|
|
28
|
+
'I' => '#6b21a8',
|
|
29
|
+
'P' => '#a16207'
|
|
30
|
+
}.freeze
|
|
31
|
+
DEFAULT_ATOM_COLOR = '#0c4a3e'
|
|
32
|
+
|
|
33
|
+
def visit_formula(formula)
|
|
34
|
+
molecule = formula.nodes.find { |n| n.is_a?(AsciiChem::Model::Molecule) }
|
|
35
|
+
return visit_formula_linear(formula) unless molecule
|
|
36
|
+
|
|
37
|
+
has_bonds = molecule.nodes.any?(AsciiChem::Model::Bond)
|
|
38
|
+
return visit_formula_linear(formula) unless has_bonds
|
|
39
|
+
|
|
40
|
+
result = AsciiChem::Layout.layout(molecule)
|
|
41
|
+
return visit_formula_linear(formula) if result.empty?
|
|
42
|
+
|
|
43
|
+
render_svg(result)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def visit_formula_linear(formula)
|
|
49
|
+
AsciiChem::Formatter::Svg.new.render(formula)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render_svg(result)
|
|
53
|
+
doc = Nokogiri::XML::Document.new
|
|
54
|
+
svg = build_svg_root(doc, result)
|
|
55
|
+
bonds_first_then_atoms(result, doc, svg)
|
|
56
|
+
doc.root = svg
|
|
57
|
+
doc.to_xml
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_svg_root(doc, result)
|
|
61
|
+
svg = Nokogiri::XML::Element.new('svg', doc)
|
|
62
|
+
svg['xmlns'] = 'http://www.w3.org/2000/svg'
|
|
63
|
+
svg['width'] = result.width.to_s
|
|
64
|
+
svg['height'] = result.height.to_s
|
|
65
|
+
svg['viewBox'] = "0 0 #{result.width} #{result.height}"
|
|
66
|
+
svg['role'] = 'img'
|
|
67
|
+
title = Nokogiri::XML::Element.new('title', doc)
|
|
68
|
+
title.content = title_text(result)
|
|
69
|
+
svg.add_child(title)
|
|
70
|
+
svg
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def bonds_first_then_atoms(result, doc, svg)
|
|
74
|
+
result.bonds.each do |bond|
|
|
75
|
+
from = result.atoms_by_id[bond.from_id]
|
|
76
|
+
to = result.atoms_by_id[bond.to_id]
|
|
77
|
+
next unless from && to
|
|
78
|
+
|
|
79
|
+
BondRenderer.new(doc, from, to, bond.kind).render_into(svg)
|
|
80
|
+
end
|
|
81
|
+
result.atoms.each do |atom|
|
|
82
|
+
AtomRenderer.new(doc, atom).render_into(svg)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def title_text(result)
|
|
87
|
+
elements = result.atoms.map(&:element).uniq
|
|
88
|
+
"#{result.atoms.length} atoms (#{elements.join(', ')})"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Renders a single atom as a labeled circle. Color comes from
|
|
92
|
+
# the per-element palette (CPK-inspired, simplified) so common
|
|
93
|
+
# elements are visually distinguishable.
|
|
94
|
+
class AtomRenderer
|
|
95
|
+
def initialize(doc, atom)
|
|
96
|
+
@doc = doc
|
|
97
|
+
@atom = atom
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def render_into(parent)
|
|
101
|
+
group = Nokogiri::XML::Element.new('g', @doc)
|
|
102
|
+
group.add_child(circle)
|
|
103
|
+
group.add_child(label)
|
|
104
|
+
parent.add_child(group)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def circle
|
|
110
|
+
el = Nokogiri::XML::Element.new('circle', @doc)
|
|
111
|
+
el['cx'] = @atom.x.to_s
|
|
112
|
+
el['cy'] = @atom.y.to_s
|
|
113
|
+
el['r'] = StructuralSvg::ATOM_RADIUS.to_s
|
|
114
|
+
el['fill'] = 'white'
|
|
115
|
+
el['stroke'] = color
|
|
116
|
+
el['stroke-width'] = '1.5'
|
|
117
|
+
el
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def label
|
|
121
|
+
el = Nokogiri::XML::Element.new('text', @doc)
|
|
122
|
+
el['x'] = @atom.x.to_s
|
|
123
|
+
el['y'] = (@atom.y + 4).to_s
|
|
124
|
+
el['text-anchor'] = 'middle'
|
|
125
|
+
el['font-family'] = 'serif'
|
|
126
|
+
el['font-size'] = '14'
|
|
127
|
+
el['fill'] = color
|
|
128
|
+
el.content = @atom.element
|
|
129
|
+
el
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def color
|
|
133
|
+
StructuralSvg::ATOM_COLORS.fetch(@atom.element, StructuralSvg::DEFAULT_ATOM_COLOR)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
private_constant :AtomRenderer
|
|
137
|
+
|
|
138
|
+
# Renders a single bond between two positioned atoms. Dispatches
|
|
139
|
+
# on bond kind via a registry of Procs. Each Proc receives the
|
|
140
|
+
# renderer (for its public line/offset helpers) and returns an
|
|
141
|
+
# array of Nokogiri elements. Adding a new bond style is a new
|
|
142
|
+
# Proc + one registry entry — no edits to existing renderers.
|
|
143
|
+
class BondRenderer
|
|
144
|
+
def initialize(doc, from_atom, to_atom, kind)
|
|
145
|
+
@doc = doc
|
|
146
|
+
@from = from_atom
|
|
147
|
+
@to = to_atom
|
|
148
|
+
@kind = kind
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def render_into(parent)
|
|
152
|
+
renderer = RENDERERS.fetch(@kind, RENDERERS[:single])
|
|
153
|
+
Array(renderer.call(self)).each { |node| parent.add_child(node) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# -- Public helpers used by the strategy Procs ---------------
|
|
157
|
+
|
|
158
|
+
def base_line
|
|
159
|
+
line(@from.x, @from.y, @to.x, @to.y)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def offset_line(distance)
|
|
163
|
+
dx = @to.x - @from.x
|
|
164
|
+
dy = @to.y - @from.y
|
|
165
|
+
length = Math.sqrt((dx * dx) + (dy * dy))
|
|
166
|
+
return nil if length.zero?
|
|
167
|
+
|
|
168
|
+
px = -dy / length
|
|
169
|
+
py = dx / length
|
|
170
|
+
line(@from.x + (px * distance), @from.y + (py * distance),
|
|
171
|
+
@to.x + (px * distance), @to.y + (py * distance))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def line(start_x, start_y, end_x, end_y)
|
|
175
|
+
el = Nokogiri::XML::Element.new('line', @doc)
|
|
176
|
+
el['x1'] = start_x.to_s
|
|
177
|
+
el['y1'] = start_y.to_s
|
|
178
|
+
el['x2'] = end_x.to_s
|
|
179
|
+
el['y2'] = end_y.to_s
|
|
180
|
+
el['stroke'] = '#1a1a1a'
|
|
181
|
+
el['stroke-width'] = '1.5'
|
|
182
|
+
el
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def polygon(points, fill:)
|
|
186
|
+
el = Nokogiri::XML::Element.new('polygon', @doc)
|
|
187
|
+
el['points'] = points.map { |x, y| "#{x},#{y}" }.join(' ')
|
|
188
|
+
el['fill'] = fill
|
|
189
|
+
el
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def from_point
|
|
193
|
+
[@from.x, @from.y]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def to_point
|
|
197
|
+
[@to.x, @to.y]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def unit_perpendicular
|
|
201
|
+
dx = @to.x - @from.x
|
|
202
|
+
dy = @to.y - @from.y
|
|
203
|
+
length = Math.sqrt((dx * dx) + (dy * dy))
|
|
204
|
+
return [0.0, 0.0] if length.zero?
|
|
205
|
+
|
|
206
|
+
[-dy / length, dx / length]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# -- Strategy registry --------------------------------------
|
|
210
|
+
#
|
|
211
|
+
# Each entry maps a bond kind symbol to a Proc that takes the
|
|
212
|
+
# renderer and returns an array of Nokogiri elements. Procs
|
|
213
|
+
# use the public helpers above; nothing reaches into private
|
|
214
|
+
# state.
|
|
215
|
+
|
|
216
|
+
SPACING = StructuralSvg::BOND_SPACING
|
|
217
|
+
WEDGE_HALF = StructuralSvg::WEDGE_WIDTH / 2.0
|
|
218
|
+
HASH_COUNT = StructuralSvg::HASH_DASH_COUNT
|
|
219
|
+
|
|
220
|
+
RENDERERS = {
|
|
221
|
+
single: ->(r) { [r.base_line] },
|
|
222
|
+
|
|
223
|
+
double: ->(r) { [-SPACING, 0, SPACING].map { |d| r.offset_line(d) }.compact },
|
|
224
|
+
|
|
225
|
+
triple: ->(r) { [0, -SPACING * 1.5, SPACING * 1.5].map { |d| r.offset_line(d) }.compact },
|
|
226
|
+
|
|
227
|
+
quadruple: ->(r) { [0, -SPACING, SPACING, -SPACING * 2.5].map { |d| r.offset_line(d) }.compact },
|
|
228
|
+
|
|
229
|
+
wedge: lambda do |r|
|
|
230
|
+
px, py = r.unit_perpendicular
|
|
231
|
+
fx, fy = r.from_point
|
|
232
|
+
tx, ty = r.to_point
|
|
233
|
+
[r.polygon([[fx, fy],
|
|
234
|
+
[tx + (px * WEDGE_HALF), ty + (py * WEDGE_HALF)],
|
|
235
|
+
[tx - (px * WEDGE_HALF), ty - (py * WEDGE_HALF)]],
|
|
236
|
+
fill: '#1a1a1a')]
|
|
237
|
+
end,
|
|
238
|
+
|
|
239
|
+
hash: lambda do |r|
|
|
240
|
+
px, py = r.unit_perpendicular
|
|
241
|
+
fx, fy = r.from_point
|
|
242
|
+
tx, ty = r.to_point
|
|
243
|
+
dx = tx - fx
|
|
244
|
+
dy = ty - fy
|
|
245
|
+
nodes = []
|
|
246
|
+
(1..HASH_COUNT).each do |i|
|
|
247
|
+
t = i / HASH_COUNT.to_f
|
|
248
|
+
cx = fx + (dx * t)
|
|
249
|
+
cy = fy + (dy * t)
|
|
250
|
+
w = WEDGE_HALF * t
|
|
251
|
+
nodes << r.line(cx + (px * w), cy + (py * w),
|
|
252
|
+
cx - (px * w), cy - (py * w))
|
|
253
|
+
end
|
|
254
|
+
nodes
|
|
255
|
+
end,
|
|
256
|
+
|
|
257
|
+
dative: ->(r) { [r.base_line.tap { |l| l['marker-end'] = 'url(#aci-dative-arrow)' }] },
|
|
258
|
+
|
|
259
|
+
wavy: lambda do |r|
|
|
260
|
+
fx, fy = r.from_point
|
|
261
|
+
tx, ty = r.to_point
|
|
262
|
+
dx = tx - fx
|
|
263
|
+
dy = ty - fy
|
|
264
|
+
px, py = r.unit_perpendicular
|
|
265
|
+
segments = 8
|
|
266
|
+
amplitude = 3.0
|
|
267
|
+
prev_x = fx
|
|
268
|
+
prev_y = fy
|
|
269
|
+
nodes = []
|
|
270
|
+
(1..segments).each do |i|
|
|
271
|
+
t = i / segments.to_f
|
|
272
|
+
offset = Math.sin(t * Math::PI * 2) * amplitude
|
|
273
|
+
x = fx + (dx * t) + (px * offset)
|
|
274
|
+
y = fy + (dy * t) + (py * offset)
|
|
275
|
+
nodes << r.line(prev_x, prev_y, x, y)
|
|
276
|
+
prev_x = x
|
|
277
|
+
prev_y = y
|
|
278
|
+
end
|
|
279
|
+
nodes
|
|
280
|
+
end
|
|
281
|
+
}.freeze
|
|
282
|
+
end
|
|
283
|
+
private_constant :BondRenderer
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|