use omgkit_core::{element, AtomFlags, MolBuilder};
use crate::geom::Point2;
use crate::style::Style;
pub(crate) const SUB_SUP_SCALE: f64 = 0.6;
pub(crate) const SUP_RISE: f64 = 0.36;
pub(crate) const SUB_DROP: f64 = 0.14;
const CAP_HEIGHT: f64 = 0.718;
fn glyph(c: char) -> (f64, f64, f64, f64, f64) {
const FALLBACK: (i32, i32, i32, i32, i32) = (1000, -166, -225, 1000, 931);
let m: (i32, i32, i32, i32, i32) = match c {
'0'..='9' => (556, 25, -19, 523, 703),
'A' => (667, 14, 0, 654, 718),
'B' => (667, 74, 0, 627, 718),
'C' => (722, 44, -19, 681, 737),
'D' => (722, 81, 0, 674, 718),
'E' => (667, 86, 0, 616, 718),
'F' => (611, 86, 0, 583, 718),
'G' => (778, 48, -19, 704, 737),
'H' => (722, 77, 0, 646, 718),
'I' => (278, 91, 0, 188, 718),
'J' => (500, 17, -19, 428, 718),
'K' => (667, 76, 0, 663, 718),
'L' => (556, 76, 0, 537, 718),
'M' => (833, 73, 0, 761, 718),
'N' => (722, 76, 0, 646, 718),
'O' => (778, 39, -19, 739, 737),
'P' => (667, 86, 0, 622, 718),
'Q' => (778, 39, -56, 739, 737),
'R' => (722, 88, 0, 684, 718),
'S' => (667, 49, -19, 620, 737),
'T' => (611, 14, 0, 597, 718),
'U' => (722, 79, -19, 644, 718),
'V' => (667, 20, 0, 647, 718),
'W' => (944, 16, 0, 928, 718),
'X' => (667, 19, 0, 648, 718),
'Y' => (667, 14, 0, 653, 718),
'Z' => (611, 23, 0, 588, 718),
'a' => (556, 36, -15, 530, 538),
'b' => (556, 58, -15, 517, 718),
'c' => (500, 30, -15, 477, 538),
'd' => (556, 35, -15, 499, 718),
'e' => (556, 40, -15, 516, 538),
'f' => (278, 14, 0, 262, 728),
'g' => (556, 40, -220, 499, 538),
'h' => (556, 65, 0, 491, 718),
'i' => (222, 67, 0, 155, 718),
'j' => (222, -16, -210, 155, 718),
'k' => (500, 67, 0, 501, 718),
'l' => (222, 67, 0, 155, 718),
'm' => (833, 65, 0, 769, 538),
'n' => (556, 65, 0, 491, 538),
'o' => (556, 35, -14, 521, 538),
'p' => (556, 58, -207, 517, 538),
'q' => (556, 35, -207, 494, 538),
'r' => (333, 77, 0, 332, 538),
's' => (500, 32, -15, 464, 538),
't' => (278, 14, -7, 257, 669),
'u' => (556, 68, -15, 489, 523),
'v' => (500, 8, 0, 492, 523),
'w' => (722, 14, 0, 709, 523),
'x' => (500, 11, 0, 490, 523),
'y' => (500, 11, -214, 489, 523),
'z' => (500, 31, 0, 469, 523),
'+' => (584, 39, 0, 545, 505),
'-' => (333, 44, 232, 289, 322),
'*' => (389, 39, 431, 349, 718),
_ => FALLBACK,
};
let k = |v: i32| f64::from(v) / 1000.0;
(k(m.0), k(m.1), k(m.2), k(m.3), k(m.4))
}
fn glyph_width(c: char) -> f64 {
glyph(c).0
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct InkBox {
pub centre: Point2,
pub half_w: f64,
pub half_h: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Run {
Normal(String),
Sub(String),
Sup(String),
}
impl Run {
pub(crate) fn text(&self) -> &str {
match self {
Run::Normal(s) | Run::Sub(s) | Run::Sup(s) => s,
}
}
fn scale(&self) -> f64 {
match self {
Run::Normal(_) => 1.0,
Run::Sub(_) | Run::Sup(_) => SUB_SUP_SCALE,
}
}
pub(crate) fn width_em(&self) -> f64 {
self.text().chars().map(glyph_width).sum::<f64>() * self.scale()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HSide {
Right,
Left,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LabelDir {
East,
West,
North,
South,
}
impl LabelDir {
#[must_use]
pub fn is_vertical(self) -> bool {
matches!(self, LabelDir::North | LabelDir::South)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Label {
pub runs: Vec<Run>,
pub half_w: f64,
pub half_h: f64,
pub dx: f64,
pub dy: f64,
pub gap: f64,
pub ink: Vec<InkBox>,
pub stacked: Option<usize>,
}
impl Label {
#[must_use]
pub fn plain(&self) -> String {
self.runs.iter().map(Run::text).collect()
}
#[must_use]
pub fn offset(&self) -> Point2 {
Point2::new(self.dx, self.dy)
}
#[must_use]
pub fn offset_canvas(&self) -> Point2 {
Point2::new(self.dx, -self.dy)
}
#[must_use]
pub fn lines(&self) -> Vec<(Point2, &[Run])> {
match self.stacked {
None => vec![(Point2::new(self.dx, 0.0), &self.runs[..])],
Some(at) => {
vec![
(Point2::new(0.0, 0.0), &self.runs[..at]),
(Point2::new(0.0, self.gap), &self.runs[at..]),
]
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LabelPlace {
Horizontal(HSide),
Stacked {
below: bool,
},
}
#[must_use]
pub fn can_stack(mol: &MolBuilder, atom: u32) -> bool {
let a = mol.atoms()[atom as usize];
total_hs(mol, atom) > 0
&& a.formal_charge == 0
&& a.isotope == 0
&& a.num_radical_electrons == 0
}
#[must_use]
pub fn label_for(mol: &MolBuilder, atom: u32, style: &Style, place: LabelPlace) -> Option<Label> {
let a = mol.atoms()[atom as usize];
let plain_carbon = a.atomic_num == 6
&& a.formal_charge == 0
&& a.isotope == 0
&& a.num_radical_electrons == 0
&& mol.degree(atom) > 0;
if plain_carbon {
return None;
}
Some(build(mol, atom, style, place))
}
#[must_use]
pub fn label_forced(mol: &MolBuilder, atom: u32, style: &Style, place: LabelPlace) -> Label {
build(mol, atom, style, place)
}
fn run_baseline(r: &Run) -> (f64, f64) {
let s = r.scale();
let base = -CAP_HEIGHT * s / 2.0;
match r {
Run::Normal(_) => (base, s),
Run::Sub(_) => (base - SUB_DROP, s),
Run::Sup(_) => (base + SUP_RISE, s),
}
}
fn row_ink(runs: &[Run], x0: f64, y0: f64, em: f64, out: &mut Vec<InkBox>) {
let mut x = x0;
for r in runs {
let (base, s) = run_baseline(r);
for c in r.text().chars() {
let (adv, gx0, gy0, gx1, gy1) = glyph(c);
let (lo_x, hi_x) = (x + gx0 * s * em, x + gx1 * s * em);
let (lo_y, hi_y) = (y0 + (base + gy0 * s) * em, y0 + (base + gy1 * s) * em);
out.push(InkBox {
centre: Point2::new((lo_x + hi_x) / 2.0, (lo_y + hi_y) / 2.0),
half_w: (hi_x - lo_x) / 2.0,
half_h: (hi_y - lo_y) / 2.0,
});
x += adv * s * em;
}
}
}
fn build(mol: &MolBuilder, atom: u32, style: &Style, place: LabelPlace) -> Label {
let a = mol.atoms()[atom as usize];
let hs = total_hs(mol, atom);
let symbol = element::by_atomic_num(a.atomic_num).map_or("*", |e| e.symbol);
let em = style.label_size();
let h_runs = |runs: &mut Vec<Run>| {
if hs > 0 {
runs.push(Run::Normal("H".into()));
if hs > 1 {
runs.push(Run::Sub(hs.to_string()));
}
}
};
let place = match place {
LabelPlace::Stacked { .. } if !can_stack(mol, atom) => LabelPlace::Horizontal(HSide::Right),
other => other,
};
if let LabelPlace::Stacked { below } = place {
let mut runs: Vec<Run> = vec![Run::Normal(symbol.into())];
let at = runs.len();
h_runs(&mut runs);
let sym_w = Run::Normal(symbol.into()).width_em();
let h_w: f64 = runs[at..].iter().map(Run::width_em).sum();
let h_has_sub = runs[at..].iter().any(|r| matches!(r, Run::Sub(_)));
let gap_em = CAP_HEIGHT * 1.1 + if h_has_sub && !below { SUB_DROP } else { 0.0 };
let gap = if below { -gap_em * em } else { gap_em * em };
let half_cap = CAP_HEIGHT / 2.0 * em;
let h_low = gap - half_cap - if h_has_sub { SUB_DROP * em } else { 0.0 };
let h_high = gap + half_cap;
let top = half_cap.max(h_high);
let bottom = (-half_cap).min(h_low);
let mut ink = Vec::with_capacity(runs.len());
row_ink(&runs[..at], -sym_w * em / 2.0, 0.0, em, &mut ink);
row_ink(&runs[at..], -h_w * em / 2.0, gap, em, &mut ink);
return Label {
runs,
half_w: sym_w.max(h_w) * em / 2.0,
half_h: (top - bottom) / 2.0,
dx: 0.0,
dy: (top + bottom) / 2.0,
gap,
ink,
stacked: Some(at),
};
}
let LabelPlace::Horizontal(h_side) = place else {
unreachable!("竖排在上面已经返回")
};
let mut runs: Vec<Run> = Vec::new();
let mut before_sym_em = 0.0_f64;
match h_side {
HSide::Left => {
h_runs(&mut runs);
if a.isotope != 0 {
runs.push(Run::Sup(a.isotope.to_string()));
}
before_sym_em = runs.iter().map(Run::width_em).sum();
runs.push(Run::Normal(symbol.into()));
}
HSide::Right => {
if a.isotope != 0 {
runs.push(Run::Sup(a.isotope.to_string()));
before_sym_em = runs.iter().map(Run::width_em).sum();
}
runs.push(Run::Normal(symbol.into()));
h_runs(&mut runs);
}
}
let sym_w_em = Run::Normal(symbol.into()).width_em();
if a.formal_charge != 0 {
runs.push(Run::Sup(charge_text(a.formal_charge)));
}
if a.num_radical_electrons > 0 {
runs.push(Run::Sup(
"*".repeat(a.num_radical_electrons.min(3) as usize),
));
}
let width_em: f64 = runs.iter().map(Run::width_em).sum();
let has_sup = runs.iter().any(|r| matches!(r, Run::Sup(_)));
let has_sub = runs.iter().any(|r| matches!(r, Run::Sub(_)));
let top = CAP_HEIGHT / 2.0 + if has_sup { SUP_RISE } else { 0.0 };
let bottom = CAP_HEIGHT / 2.0 + if has_sub { SUB_DROP } else { 0.0 };
let dx = (width_em / 2.0 - before_sym_em - sym_w_em / 2.0) * em;
let half_w = width_em * em / 2.0;
let mut ink = Vec::with_capacity(runs.len());
row_ink(&runs, dx - half_w, 0.0, em, &mut ink);
Label {
runs,
half_w,
half_h: top.max(bottom) * em,
dx,
dy: 0.0,
gap: 0.0,
ink,
stacked: None,
}
}
fn charge_text(q: i8) -> String {
let sign = if q > 0 { '+' } else { '-' };
let n = q.unsigned_abs();
if n == 1 {
sign.to_string()
} else {
format!("{n}{sign}")
}
}
fn total_hs(mol: &MolBuilder, atom: u32) -> u8 {
let a = mol.atoms()[atom as usize];
debug_assert!(
!a.flags.contains(AtomFlags::NO_IMPLICIT) || a.num_implicit_hs == 0,
"置了 NO_IMPLICIT 却还有隐式氢,两个字段不再互斥,相加就会重复计数"
);
a.num_explicit_hs.saturating_add(a.num_implicit_hs)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Style;
fn prep(smi: &str) -> MolBuilder {
let mut m = omgkit_io::smiles::parse(smi).unwrap();
omgkit_chem::pipeline::sanitize(&mut m).unwrap();
m
}
fn plain(smi: &str, atom: u32, side: HSide) -> Option<String> {
let m = prep(smi);
label_for(&m, atom, &Style::ACS_1996, LabelPlace::Horizontal(side)).map(|l| l.plain())
}
#[test]
fn skeleton_carbons_are_not_drawn_but_lone_ones_are() {
assert_eq!(plain("CCO", 0, HSide::Right), None, "链上的碳不该有标签");
assert_eq!(plain("CCO", 1, HSide::Right), None);
assert_eq!(plain("CCO", 2, HSide::Right).as_deref(), Some("OH"));
assert_eq!(
plain("C", 0, HSide::Right).as_deref(),
Some("CH4"),
"甲烷必须画出来"
);
}
#[test]
fn hydrogens_sit_on_the_side_the_bond_does_not_come_from() {
assert_eq!(plain("NCC", 0, HSide::Right).as_deref(), Some("NH2"));
assert_eq!(plain("NCC", 0, HSide::Left).as_deref(), Some("H2N"));
}
#[test]
fn charge_isotope_and_radical_show_up() {
assert_eq!(plain("[NH4+]", 0, HSide::Right).as_deref(), Some("NH4+"));
assert_eq!(plain("[O-]C", 0, HSide::Right).as_deref(), Some("O-"));
assert_eq!(plain("[13CH4]", 0, HSide::Right).as_deref(), Some("13CH4"));
assert_eq!(plain("[Fe+2]", 0, HSide::Right).as_deref(), Some("Fe2+"));
}
#[test]
fn the_element_symbol_sits_on_the_atom_not_on_the_string_centre() {
let mut checked = 0usize;
for (smi, atom) in [
("CCO", 2u32),
("NCC", 0),
("CC(=O)Nc1ccc(O)cc1", 7),
("[NH4+]", 0),
("[13CH4]", 0),
] {
let m = prep(smi);
let sym = element::by_atomic_num(m.atoms()[atom as usize].atomic_num)
.expect("元素表里有这个原子")
.symbol;
for style in &Style::ALL {
for side in [HSide::Right, HSide::Left] {
let Some(l) = label_for(&m, atom, style, LabelPlace::Horizontal(side)) else {
continue;
};
let em = style.label_size();
let mut x = 0.0_f64;
let mut centre = None;
for r in &l.runs {
let w = r.width_em();
if matches!(r, Run::Normal(t) if t == sym) {
centre = Some(x + w / 2.0);
}
x += w;
}
let c = centre.expect("标签里该有元素符号");
checked += 1;
let off = l.dx - l.half_w + c * em;
assert!(
off.abs() < 1e-9,
"[{}] {smi} 原子 {atom} 标签 {}:元素符号 {sym} 的中心离原子 {off:.4} 个键长,该是 0",
style.name,
l.plain()
);
}
}
}
assert!(checked > 0, "一个标签都没取到,判据空过了");
}
#[test]
fn the_box_is_wider_when_there_is_more_to_draw() {
let m = prep("NCC");
let n = label_for(
&m,
0,
&Style::ACS_1996,
LabelPlace::Horizontal(HSide::Right),
)
.unwrap();
let m2 = prep("[NH4+]");
let nh4 = label_for(
&m2,
0,
&Style::ACS_1996,
LabelPlace::Horizontal(HSide::Right),
)
.unwrap();
assert!(
nh4.half_w > n.half_w,
"NH4+ 应当比 NH2 宽:{} vs {}",
nh4.half_w,
n.half_w
);
assert!(nh4.half_h > n.half_h, "带上标的标签应当更高");
}
#[test]
fn the_same_label_takes_twice_the_room_under_acs() {
let m = prep("NCC");
let acs = label_for(
&m,
0,
&Style::ACS_1996,
LabelPlace::Horizontal(HSide::Right),
)
.unwrap();
let cd = label_for(
&m,
0,
&Style::CHEMDRAW_DEFAULT,
LabelPlace::Horizontal(HSide::Right),
)
.unwrap();
assert_eq!(acs.plain(), cd.plain(), "文本本身与规范无关");
let ratio = acs.half_w / cd.half_w;
assert!(
(ratio - 30.0 / 14.4).abs() < 1e-9,
"宽度比应当正好是键长之比 30/14.4 = 2.083,实得 {ratio}"
);
}
#[test]
fn glyph_widths_are_the_real_helvetica_ones() {
assert!((glyph_width('I') - 0.278).abs() < 1e-12);
assert!((glyph_width('W') - 0.944).abs() < 1e-12);
assert!((glyph_width('C') - 0.722).abs() < 1e-12);
assert!((glyph_width('O') - 0.778).abs() < 1e-12);
assert!((glyph_width('5') - 0.556).abs() < 1e-12);
assert!(glyph_width('W') > glyph_width('I') * 3.0);
}
}