use super::render::{EM_RATIO, FONT_BYTES};
use crate::error::GraphError;
use num_traits::ToPrimitive;
use skrifa::charmap::Charmap;
use skrifa::instance::{LocationRef, Size};
use skrifa::metrics::GlyphMetrics;
use skrifa::outline::{DrawSettings, OutlineGlyphCollection, OutlinePen};
use skrifa::{FontRef, GlyphId, MetadataProvider};
use std::sync::OnceLock;
pub(crate) const LINE_WHITESPACE: [char; 3] = ['\t', '\n', '\r'];
pub(crate) struct BundledFont {
charmap: Charmap<'static>,
metrics: GlyphMetrics<'static>,
outlines: OutlineGlyphCollection<'static>,
height: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Horizontal {
Start,
Middle,
End,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Vertical {
Top,
Center,
Bottom,
}
#[cold]
fn out_of_range(what: &str) -> GraphError {
GraphError::Render(format!("{what} is out of the drawable range"))
}
pub(crate) fn whole_pixels(value: f64, what: &str) -> Result<i32, GraphError> {
value.to_i32().ok_or_else(|| out_of_range(what))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Missing {
Notdef,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct InkBox {
pub(crate) left: f64,
pub(crate) top: f64,
pub(crate) right: f64,
pub(crate) bottom: f64,
}
impl InkBox {
pub(crate) fn union(self, other: InkBox) -> InkBox {
InkBox {
left: self.left.min(other.left),
top: self.top.min(other.top),
right: self.right.max(other.right),
bottom: self.bottom.max(other.bottom),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Segment {
Move((f64, f64)),
Line((f64, f64)),
Quad((f64, f64), (f64, f64)),
Cubic((f64, f64), (f64, f64), (f64, f64)),
Close,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct LineLayout {
pub(crate) width: i32,
pub(crate) height: i32,
pub(crate) baseline: f64,
glyphs: Vec<(GlyphId, f32)>,
factor: f32,
}
#[cold]
fn font_error(reason: impl std::fmt::Display) -> GraphError {
GraphError::Render(format!("Open Sans Regular: {reason}"))
}
pub(crate) fn bundled_font() -> Result<&'static BundledFont, GraphError> {
static FONT: OnceLock<Result<BundledFont, String>> = OnceLock::new();
FONT.get_or_init(|| {
let font = FontRef::new(FONT_BYTES).map_err(|error| error.to_string())?;
let vertical = font.metrics(Size::unscaled(), LocationRef::default());
Ok(BundledFont {
charmap: font.charmap(),
metrics: font.glyph_metrics(Size::unscaled(), LocationRef::default()),
outlines: font.outline_glyphs(),
height: vertical.ascent - vertical.descent,
})
})
.as_ref()
.map_err(font_error)
}
impl LineLayout {
pub(crate) fn dx(&self, anchor: Horizontal) -> Result<i32, GraphError> {
match anchor {
Horizontal::Start => Some(0),
Horizontal::Middle => self.width.checked_neg().and_then(|w| w.checked_div(2)),
Horizontal::End => self.width.checked_neg(),
}
.ok_or_else(|| out_of_range("the label width"))
}
pub(crate) fn dy(&self, anchor: Vertical) -> Result<i32, GraphError> {
match anchor {
Vertical::Top => Some(0),
Vertical::Center => self.height.checked_neg().and_then(|h| h.checked_div(2)),
Vertical::Bottom => self.height.checked_neg(),
}
.ok_or_else(|| out_of_range("the label height"))
}
pub(crate) fn with_height(self, height: i32) -> LineLayout {
LineLayout { height, ..self }
}
}
impl BundledFont {
fn glyph(&self, character: char) -> Option<GlyphId> {
let drawn = if LINE_WHITESPACE.contains(&character) {
' '
} else {
character
};
self.charmap.map(drawn)
}
fn advance(&self, glyph: GlyphId) -> f32 {
self.metrics.advance_width(glyph).unwrap_or_default()
}
pub(crate) fn layout(
&self,
text: &str,
plotters_size: f64,
missing: Missing,
) -> Result<LineLayout, GraphError> {
let scale = plotters_size
.to_f32()
.ok_or_else(|| out_of_range("the font size"))?;
let factor = scale / self.height;
let mut glyphs = Vec::with_capacity(text.len());
let mut pen = 0_f32;
for character in text.chars() {
let glyph = match (self.glyph(character), missing) {
(Some(glyph), _) => glyph,
(None, Missing::Notdef) => GlyphId::NOTDEF,
(None, Missing::Error) => {
return Err(GraphError::MissingGlyph {
character,
code: u32::from(character),
text: text.to_string(),
});
}
};
glyphs.push((glyph, pen));
pen += self.advance(glyph) * factor;
}
Ok(LineLayout {
width: pen
.to_i32()
.ok_or_else(|| out_of_range("the label width"))?,
height: whole_pixels(plotters_size / EM_RATIO, "the label height")?,
baseline: f64::from(scale / 2.0),
glyphs,
factor,
})
}
pub(crate) fn trace(
&self,
layout: &LineLayout,
origin: (f64, f64),
mut segment: impl FnMut(Segment),
) -> Result<Option<InkBox>, GraphError> {
let factor = f64::from(layout.factor);
let baseline = origin.1 + layout.baseline;
let mut ink: Option<InkBox> = None;
for (glyph, pen) in &layout.glyphs {
let bounds = self
.metrics
.bounds(*glyph)
.filter(|b| b.x_min < b.x_max && b.y_min < b.y_max);
let (Some(outline), Some(bounds)) = (self.outlines.get(*glyph), bounds) else {
continue;
};
let left = origin.0 + f64::from(*pen);
let place = |x: f32, y: f32| {
(
left + f64::from(x) * factor,
baseline - f64::from(y) * factor,
)
};
let mut pen = Pen {
place: &place,
current: None,
emit: &mut segment,
};
outline
.draw(
DrawSettings::unhinted(Size::unscaled(), LocationRef::default()),
&mut pen,
)
.map_err(font_error)?;
let (left_top, right_bottom) = (
place(bounds.x_min, bounds.y_max),
place(bounds.x_max, bounds.y_min),
);
let glyph_box = InkBox {
left: left_top.0,
top: left_top.1,
right: right_bottom.0,
bottom: right_bottom.1,
};
ink = Some(ink.map_or(glyph_box, |ink| ink.union(glyph_box)));
}
Ok(ink)
}
}
struct Pen<'a, P, E> {
place: &'a P,
current: Option<(f32, f32)>,
emit: &'a mut E,
}
impl<P: Fn(f32, f32) -> (f64, f64), E: FnMut(Segment)> OutlinePen for Pen<'_, P, E> {
fn move_to(&mut self, x: f32, y: f32) {
self.current = Some((x, y));
(self.emit)(Segment::Move((self.place)(x, y)));
}
fn line_to(&mut self, x: f32, y: f32) {
if self.current == Some((x, y)) {
return;
}
self.current = Some((x, y));
(self.emit)(Segment::Line((self.place)(x, y)));
}
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
self.current = Some((x, y));
(self.emit)(Segment::Quad((self.place)(cx, cy), (self.place)(x, y)));
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
self.current = Some((x, y));
(self.emit)(Segment::Cubic(
(self.place)(cx0, cy0),
(self.place)(cx1, cy1),
(self.place)(x, y),
));
}
fn close(&mut self) {
self.current = None;
(self.emit)(Segment::Close);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bundled_font_metrics_are_open_sans() -> Result<(), GraphError> {
let font = bundled_font()?;
assert_eq!(font.height, 2789.0);
assert!(font.glyph('A').is_some());
assert!(font.glyph('\u{4E2D}').is_none());
assert_eq!(font.glyph('\n'), font.glyph(' '));
Ok(())
}
#[test]
fn test_layout_measures_like_plotters() -> Result<(), GraphError> {
let font = bundled_font()?;
let size = 12.0 * EM_RATIO;
let layout = font.layout("Strike", size, Missing::Error)?;
assert!(layout.width > 20 && layout.width < 40, "{}", layout.width);
assert_eq!(layout.height, (size / EM_RATIO) as i32);
assert!((layout.baseline - size / 2.0).abs() < 1e-5);
Ok(())
}
#[test]
fn test_missing_glyphs_follow_the_policy() -> Result<(), GraphError> {
let font = bundled_font()?;
let measured = font.layout("IV 中", 14.88, Missing::Notdef)?;
let plain = font.layout("IV ", 14.88, Missing::Notdef)?;
assert!(measured.width > plain.width);
assert!(matches!(
font.layout("IV 中", 14.88, Missing::Error),
Err(GraphError::MissingGlyph {
character: '中',
..
})
));
Ok(())
}
#[test]
fn test_trace_closes_every_contour_and_skips_spaces() -> Result<(), GraphError> {
let font = bundled_font()?;
let layout = font.layout("o o", 20.0, Missing::Error)?;
let mut segments = Vec::new();
let ink = font.trace(&layout, (10.0, 20.0), |segment| segments.push(segment))?;
let moves = segments
.iter()
.filter(|s| matches!(s, Segment::Move(_)))
.count();
let closes = segments.iter().filter(|s| **s == Segment::Close).count();
assert_eq!((moves, closes), (4, 4));
let ink = ink
.ok_or("ink")
.map_err(|reason| GraphError::Render(reason.to_string()))?;
assert!(ink.left >= 10.0 && ink.right > ink.left && ink.bottom > ink.top);
let blank = font.layout(" ", 20.0, Missing::Error)?;
assert_eq!(font.trace(&blank, (0.0, 0.0), |_| {})?, None);
Ok(())
}
}