use std::collections::BTreeSet;
use crate::geom::Point2;
use crate::label::Run;
use crate::render::{Primitive, Scene};
use crate::style::Style;
#[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
));
let mut balls: BTreeSet<[u8; 3]> = BTreeSet::new();
let mut sticks: BTreeSet<Cyl> = BTreeSet::new();
for it in &scene.items {
match it {
Primitive::Ball { color, .. } => {
balls.insert(*color);
}
Primitive::Stick {
from,
to,
width,
color,
} => {
sticks.insert(cyl_of(*from, *to, *width, *color));
}
_ => {}
}
}
if !balls.is_empty() || !sticks.is_empty() {
s.push_str("<defs>\n");
for c in &balls {
s.push_str(&sphere_gradient(*c));
}
for c in &sticks {
s.push_str(&cylinder_gradient(*c));
}
s.push_str("</defs>\n");
}
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,
} => {
let len = from.dist(*to);
let n_lines = if *spacing > 0.0 {
((len / spacing).floor() as i32).max(2)
} else {
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::Ball { at, r, color } => {
s.push_str(&format!(
"<circle cx=\"{:.2}\" cy=\"{:.2}\" r=\"{:.2}\" fill=\"url(#{})\" \
stroke=\"{}\" stroke-width=\"{:.2}\"/>\n",
at.x,
at.y,
r,
gradient_id(*color),
hex(shade(*color, -RIM)),
(r * RIM_WIDTH).max(0.25)
));
}
Primitive::Stick {
from,
to,
width,
color,
} => {
s.push_str(&format!(
"<line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" \
stroke=\"url(#{})\" stroke-width=\"{:.2}\" stroke-linecap=\"round\"/>\n",
from.x,
from.y,
to.x,
to.y,
cyl_id(cyl_of(*from, *to, *width, *color)),
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,
escape(style.font_family),
size
));
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
}
const HIGHLIGHT: f64 = 0.55;
const RIM: f64 = 0.45;
const RIM_WIDTH: f64 = 0.04;
fn shade(c: [u8; 3], t: f64) -> [u8; 3] {
let mut out = [0u8; 3];
for k in 0..3 {
let v = f64::from(c[k]);
let x = if t >= 0.0 {
v + (255.0 - v) * t
} else {
v * (1.0 + t)
};
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
{
out[k] = x.round().clamp(0.0, 255.0) as u8;
}
}
out
}
fn hex(c: [u8; 3]) -> String {
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
fn gradient_id(c: [u8; 3]) -> String {
format!("s{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
fn sphere_gradient(c: [u8; 3]) -> String {
format!(
"<radialGradient id=\"{}\" cx=\"0.5\" cy=\"0.5\" r=\"0.55\" \
fx=\"0.32\" fy=\"0.30\">\
<stop offset=\"0\" stop-color=\"{}\"/>\
<stop offset=\"0.55\" stop-color=\"{}\"/>\
<stop offset=\"1\" stop-color=\"{}\"/>\
</radialGradient>\n",
gradient_id(c),
hex(shade(c, HIGHLIGHT)),
hex(c),
hex(shade(c, -RIM))
)
}
type Cyl = ([i64; 2], i64, i64, [u8; 3]);
const GRAD_QUANT: f64 = 1000.0;
const LIGHT: [f64; 2] = [
-std::f64::consts::FRAC_1_SQRT_2,
-std::f64::consts::FRAC_1_SQRT_2,
];
fn qg(x: f64) -> i64 {
let v = (x * GRAD_QUANT).round();
if v > i64::MAX as f64 {
i64::MAX
} else if v < i64::MIN as f64 {
i64::MIN
} else {
v as i64
}
}
fn cyl_of(from: Point2, to: Point2, width: f64, color: [u8; 3]) -> Cyl {
let d = to - from;
let len = (d.x * d.x + d.y * d.y).sqrt();
let mut n = if len > f64::EPSILON {
[-d.y / len, d.x / len]
} else {
[1.0, 0.0]
};
let lit = n[0] * LIGHT[0] + n[1] * LIGHT[1];
if lit < 0.0 || (lit == 0.0 && n[0] < 0.0) {
n = [-n[0], -n[1]];
}
let offset = from.x * n[0] + from.y * n[1];
([qg(n[0]), qg(n[1])], qg(offset), qg(width / 2.0), color)
}
fn cyl_id(c: Cyl) -> String {
let ([nx, ny], off, w, col) = c;
format!(
"c{nx}_{ny}_{off}_{w}_{:02x}{:02x}{:02x}",
col[0], col[1], col[2]
)
.replace('-', "m")
}
fn cylinder_gradient(c: Cyl) -> String {
let ([nx, ny], off, w, col) = c;
let (nx, ny, off, w) = (
nx as f64 / GRAD_QUANT,
ny as f64 / GRAD_QUANT,
off as f64 / GRAD_QUANT,
w as f64 / GRAD_QUANT,
);
let (x1, y1) = (nx * (off - w), ny * (off - w));
let (x2, y2) = (nx * (off + w), ny * (off + w));
format!(
"<linearGradient id=\"{}\" gradientUnits=\"userSpaceOnUse\" \
x1=\"{x1:.3}\" y1=\"{y1:.3}\" x2=\"{x2:.3}\" y2=\"{y2:.3}\">\
<stop offset=\"0\" stop-color=\"{}\"/>\
<stop offset=\"0.4\" stop-color=\"{}\"/>\
<stop offset=\"0.75\" stop-color=\"{}\"/>\
<stop offset=\"1\" stop-color=\"{}\"/>\
</linearGradient>\n",
cyl_id(c),
hex(shade(col, -CYL_RIM)),
hex(col),
hex(shade(col, CYL_HIGHLIGHT)),
hex(shade(col, -CYL_RIM))
)
}
const CYL_RIM: f64 = 0.42;
const CYL_HIGHLIGHT: f64 = 0.42;
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 纯白的原子与键在白底上分得出来() {
use crate::three::{depict, Style3D};
let mut m = omgkit_io::smiles::parse("CO").unwrap();
let c = omgkit_conf::pipeline::conformer_for(&mut m).unwrap();
let d = depict(&m, &c.coords, &Style3D::SPACE_FILLING).unwrap();
let svg = to_svg(&d.scene, &Style::ACS_1996);
let white_balls: Vec<&str> = svg
.lines()
.filter(|l| l.contains("<circle") && l.contains("url(#sffffff)"))
.collect();
assert!(!white_balls.is_empty(), "甲醇该有白色的氢球,判据没东西可判");
for line in white_balls {
let stroke = line
.split("stroke=\"")
.nth(1)
.and_then(|t| t.split('"').next())
.expect("球该有描边");
assert!(
stroke != "#ffffff" && stroke != "#fff",
"白球的描边也是白的,在白底上看不见了:{line}"
);
}
let d = depict(&m, &c.coords, &Style3D::STICK).unwrap();
let svg = to_svg(&d.scene, &Style::ACS_1996);
let grads: Vec<&str> = svg
.lines()
.filter(|l| l.contains("<linearGradient") && l.contains("#ffffff"))
.collect();
assert!(!grads.is_empty(), "甲醇该有白色的 C–H 圆柱,判据没东西可判");
for g in grads {
let stops: Vec<&str> = g
.split("stop-color=\"")
.skip(1)
.filter_map(|t| t.split('"').next())
.collect();
assert!(stops.len() >= 2, "白圆柱的渐变只有 {} 档:{g}", stops.len());
assert!(
stops.iter().any(|c| *c != "#ffffff"),
"白圆柱的渐变从头到尾都是白的,在白底上看不见了:{g}"
);
}
}
#[test]
fn the_output_is_well_formed_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() {
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() {
assert_eq!(escape("a<b>c&d\"e'f"), "a<b>c&d"e'f");
}
#[test]
fn a_hostile_style_does_not_produce_broken_xml_or_eat_the_memory() {
let mut style = Style::ACS_1996;
style.font_family = r#"Ari"al & <script>"#;
style.hash_spacing_pt = 0.0;
let svg = svg("C[C@H](N)C(=O)O", &style);
assert!(
!svg.contains(r#"font-family="Ari"al"#),
"字体名没转义,`\"` 把属性截断了"
);
assert!(svg.contains("&") && svg.contains("<script>"));
assert!(svg.len() < 1_000_000, "间距为 0 画出了 {} 字节", svg.len());
}
#[test]
fn a_stereocentre_is_actually_drawn_with_a_wedge() {
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() {
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() {
for smi in [
"NCc1ccccc1", "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\""), "缺少白底");
}
}