1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
//! SVG 后端:把 [`Scene`] 序列化成字符串。**零依赖。**
//!
//! 这里只做序列化,一点几何都不算 —— 几何在 [`render`](crate::render) 里算过
//! 一遍了。加别的后端不必重写几何,而几何错了所有后端一起错、被同一批判据抓住。
//!
//! # 用 `<text>` 而不是把字转成路径
//!
//! 转成路径的 SVG 到哪都长一样,`<text>` 则依赖查看器装了什么字体。这里选
//! `<text>`,因为转路径要内嵌一套字体轮廓数据 —— 那是几十上百 KB 的表,而且
//! 它自己就需要一套判据来保证抄对了。
//!
//! 代价是**实打实的**:装不到 Arial/Helvetica 的机器上,标签宽度会与
//! [`label`](crate::label) 按 AFM 字宽算出来的不一致,于是布局时按标签留出的
//! 空隙对不上。要求到哪都像素级一致的场合,应当先转成位图再分发。
use crate::label::Run;
use crate::render::{Primitive, Scene};
use crate::style::Style;
/// 把一张 [`Scene`] 写成 SVG。
#[must_use]
pub fn to_svg(scene: &Scene, style: &Style) -> String {
let mut s = String::with_capacity(1024 + scene.items.len() * 96);
s.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{:.2}\" height=\"{:.2}\" \
viewBox=\"0 0 {:.2} {:.2}\">\n",
scene.width, scene.height, scene.width, scene.height
));
// 白底:没有它,深色主题的查看器下黑线配黑底什么都看不见
s.push_str(&format!(
"<rect width=\"{:.2}\" height=\"{:.2}\" fill=\"#fff\"/>\n",
scene.width, scene.height
));
for item in &scene.items {
match item {
Primitive::Line { from, to, width } => {
s.push_str(&format!(
"<line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" \
stroke=\"#000\" stroke-width=\"{:.2}\" stroke-linecap=\"round\"/>\n",
from.x, from.y, to.x, to.y, width
));
}
Primitive::Wedge { from, to, wide } => {
// 实心三角:窄端在立体中心(一个点),宽端张开
let d = (*to - *from).normalized();
let n = crate::geom::Point2::new(-d.y, d.x) * (wide / 2.0);
s.push_str(&format!(
"<path d=\"M{:.2},{:.2} L{:.2},{:.2} L{:.2},{:.2} Z\" fill=\"#000\"/>\n",
from.x,
from.y,
to.x + n.x,
to.y + n.y,
to.x - n.x,
to.y - n.y
));
}
Primitive::Hash {
from,
to,
wide,
spacing,
width,
} => {
// 一叠垂直于键的短横线,从窄到宽。间距由规范的 hash_spacing 定。
let len = from.dist(*to);
let n_lines = ((len / spacing).floor() as i32).max(2);
let d = (*to - *from).normalized();
let perp = crate::geom::Point2::new(-d.y, d.x);
for k in 1..=n_lines {
let t = f64::from(k) / f64::from(n_lines);
let c = *from + d * (len * t);
let h = perp * (wide / 2.0 * t);
s.push_str(&format!(
"<line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" \
stroke=\"#000\" stroke-width=\"{:.2}\" stroke-linecap=\"round\"/>\n",
c.x - h.x,
c.y - h.y,
c.x + h.x,
c.y + h.y,
width
));
}
}
Primitive::Text { at, runs, size } => {
s.push_str(&format!(
"<text x=\"{:.2}\" y=\"{:.2}\" font-family=\"{}\" font-size=\"{:.2}\" \
text-anchor=\"middle\" dominant-baseline=\"central\" fill=\"#000\">",
at.x, at.y, style.font_family, size
));
// 每一段自带 `dy`,当前基线偏移显式记着。
//
// **不能用空的 `<tspan dy=…>` 复位。** SVG 的 `dy` 是加在这一段
// 的**字符**上的,段里没有字符就没有东西可加,基线复不回去 ——
// 于是下标后面的正文一直吊在下标的高度上。实测:氨基在右侧写成
// `H₂N` 时,`N` 比 `H` 低了一截,而这两个字母本该在同一条基线上。
//
// 抬升/下沉与字号缩放用的是 [`label`](crate::label) 里的同一组
// 常数 —— 那边按它们算标签占多大,这边按它们画,两边必须一致。
let mut cur = 0.0_f64;
for r in runs {
let (text, want, fs) = match r {
Run::Normal(t) => (t, 0.0, *size),
Run::Sub(t) => (
t,
size * crate::label::SUB_DROP,
size * crate::label::SUB_SUP_SCALE,
),
Run::Sup(t) => (
t,
-size * crate::label::SUP_RISE,
size * crate::label::SUB_SUP_SCALE,
),
};
s.push_str(&format!(
"<tspan font-size=\"{fs:.2}\" dy=\"{:.2}\">{}</tspan>",
want - cur,
escape(text)
));
cur = want;
}
s.push_str("</text>\n");
}
}
}
s.push_str("</svg>\n");
s
}
/// XML 转义。
///
/// 元素符号与数字里没有这些字符,但**电荷用的 `+`/`-` 之外将来可能出现别的记号**,
/// 而漏转义一次就会产出坏 XML —— 那种文件有的查看器打得开、有的打不开,极难定位。
fn escape(t: &str) -> String {
let mut out = String::with_capacity(t.len());
for c in t.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{generate, render::scene, style::Style};
use omgkit_core::MolBuilder;
fn prep(smi: &str) -> MolBuilder {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
m
}
fn svg(smi: &str, style: &Style) -> String {
let m = prep(smi);
to_svg(&scene(&m, &generate(&m, style), style), style)
}
#[test]
fn the_output_is_well_formed_xml() {
// 坏 XML 有的查看器打得开、有的打不开,极难定位。这里做最基本的结构检查:
// 标签成对、属性引号配对。
for smi in [
"c1ccccc1",
"CC(=O)Oc1ccccc1C(=O)O",
"[NH4+]",
"[13CH4]",
"CC#N",
] {
let s = svg(smi, &Style::ACS_1996);
assert!(s.starts_with("<svg "), "{smi} 开头不对");
assert!(s.trim_end().ends_with("</svg>"), "{smi} 结尾不对");
assert_eq!(
s.matches("<text").count(),
s.matches("</text>").count(),
"{smi} 的 <text> 没有成对"
);
assert_eq!(
s.matches("<tspan").count(),
s.matches("</tspan>").count(),
"{smi} 的 <tspan> 没有成对"
);
assert_eq!(s.matches('"').count() % 2, 0, "{smi} 的属性引号数是奇数");
assert!(!s.contains("NaN"), "{smi} 里出现了 NaN 坐标");
}
}
#[test]
fn labels_appear_and_skeleton_carbons_do_not() {
let s = svg("CCO", &Style::ACS_1996);
assert!(s.contains(">OH<") || s.contains(">O<"), "羟基没画出来:{s}");
// 骨架碳不该有文字
assert_eq!(s.matches("<text").count(), 1, "只该有羟基一个标签");
}
#[test]
fn charges_and_isotopes_are_escaped_and_marked_up() {
let s = svg("[NH4+]", &Style::ACS_1996);
assert!(s.contains("<tspan"), "电荷应当是上标");
assert!(s.contains('+'), "电荷符号没画出来");
let iso = svg("[13CH4]", &Style::ACS_1996);
assert!(iso.contains("13"), "同位素没画出来");
}
#[test]
fn the_canvas_scales_with_the_style() {
// 同一个分子,ChemDraw 默认规范的画布应当比 ACS 大近一倍
let a = svg("c1ccc2ccccc2c1", &Style::ACS_1996);
let c = svg("c1ccc2ccccc2c1", &Style::CHEMDRAW_DEFAULT);
let w = |s: &str| {
s.split("width=\"")
.nth(1)
.unwrap()
.split('"')
.next()
.unwrap()
.parse::<f64>()
.unwrap()
};
assert!(w(&c) > w(&a) * 1.8, "画布宽度比只有 {:.2}", w(&c) / w(&a));
}
#[test]
fn xml_special_characters_never_leak_through() {
// 元素符号里现在没有这些字符,但漏转义一次就产出坏 XML。这条守的是
// 那个函数本身,不是当下的元素表。
assert_eq!(escape("a<b>c&d\"e'f"), "a<b>c&d"e'f");
}
#[test]
fn a_stereocentre_is_actually_drawn_with_a_wedge() {
// 楔形只存在 `Depiction` 里而没画出来的话,图上一个手性中心都看不出来 ——
// 而所有"数得出来"的判据(原子数、键长、重合)照样全绿。
let s = svg("N[C@@H](C)O", &Style::ACS_1996);
assert!(
s.contains("<path") || s.matches("<line").count() > 3,
"既没有实楔形的三角也没有虚楔形的横线堆:{s}"
);
}
#[test]
fn the_two_enantiomers_do_not_produce_the_same_svg() {
// **区分力**:对映体的坐标完全全等,唯一的差别在楔形。两张 SVG 一样,
// 就说明楔形要么没画、要么画反了 —— 图上分不出左右手。
let a = svg("N[C@@H](C)O", &Style::ACS_1996);
let b = svg("N[C@H](C)O", &Style::ACS_1996);
assert_ne!(a, b, "两个对映体画出了完全相同的图");
}
#[test]
fn normal_text_stays_on_the_main_baseline() {
// **下标之后的正文必须回到主基线上。**
//
// 先前是用一个空的 `<tspan dy=…>` 复位的,而 SVG 的 `dy` 是加在这一段
// 的**字符**上的 —— 段里没有字符就没有东西可加,基线复不回去,于是
// 下标后面的正文一直吊在下标那个高度。实测:氨基在右侧写成 `H₂N` 时,
// `N` 比 `H` 低了一截,而这两个字母本该在同一条基线上。
//
// 这条把每一段的 `dy` 累加起来,要求正文段落落在偏移 0 上。
for smi in [
"NCc1ccccc1", // 苄胺:键从右边来,氨基写成 H₂N,下标夹在中间
"CC(=O)Nc1ccc(O)cc1",
"[NH4+]", // 上标在末尾
"[13CH4]", // 同位素在开头
"OS(=O)(=O)O",
] {
let out = svg(smi, &Style::ACS_1996);
assert!(
!out.contains("></tspan>"),
"{smi}:出现了空的 <tspan> —— 它的 dy 不会生效"
);
for t in out.split("<text ").skip(1) {
let body = t.split('>').skip(1).collect::<Vec<_>>().join(">");
let body = body.split("</text>").next().expect("有结束标签");
let mut cur = 0.0_f64;
for seg in body.split("<tspan ").skip(1) {
let dy: f64 = seg
.split("dy=\"")
.nth(1)
.and_then(|x| x.split('"').next())
.and_then(|x| x.parse().ok())
.expect("每段都要有 dy");
cur += dy;
let fs: f64 = seg
.split("font-size=\"")
.nth(1)
.and_then(|x| x.split('"').next())
.and_then(|x| x.parse().ok())
.expect("每段都要有 font-size");
let text = seg
.split('>')
.nth(1)
.and_then(|x| x.split('<').next())
.unwrap_or("");
if text.is_empty() {
continue;
}
// 正文段(没缩小字号的)必须在主基线上
if (fs - Style::ACS_1996.atom_label_pt).abs() < 1e-6 {
assert!(
cur.abs() < 1e-6,
"{smi}:正文 {text:?} 画在了偏移 {cur:.2} 上,不在主基线"
);
} else {
assert!(cur.abs() > 1e-6, "{smi}:上下标 {text:?} 却没有偏移");
}
}
}
}
}
#[test]
fn there_is_a_white_background() {
// 没有底色的话,深色主题查看器下黑线配黑底,什么都看不见
let s = svg("CCO", &Style::ACS_1996);
assert!(s.contains("fill=\"#fff\""), "缺少白底");
}
}