From 69d13ed3902efc250ffce13e20a1d8cf56106421 Mon Sep 17 00:00:00 2001 From: Philip Deuchler Date: Fri, 24 Apr 2026 23:48:12 -0600 Subject: [PATCH 1/9] feat(font): add fontique-backed system_source module --- plotters/Cargo.toml | 14 ++- plotters/src/style/font/system_source.rs | 149 +++++++++++++++++++++++ plotters/src/style/font/ttf.rs | 9 +- 3 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 plotters/src/style/font/system_source.rs diff --git a/plotters/Cargo.toml b/plotters/Cargo.toml index 848bb151..dc88cdf5 100644 --- a/plotters/Cargo.toml +++ b/plotters/Cargo.toml @@ -40,6 +40,8 @@ path = "../plotters-svg" [target.'cfg(not(all(target_arch = "wasm32", not(target_os = "wasi"))))'.dependencies] ttf-parser = { version = "0.25.1", optional = true } lazy_static = { version = "1.4.0", optional = true } +fontique = { version = "0.9.0", optional = true } +swash = { version = "0.2.7", optional = true } pathfinder_geometry = { version = "0.5.1", optional = true } font-kit = { version = "0.14.2", optional = true } ab_glyph = { version = "0.2.12", optional = true } @@ -105,10 +107,17 @@ point_series = [] surface_series = [] # Font implementation -ttf = ["font-kit", "ttf-parser", "lazy_static", "pathfinder_geometry"] +ttf = [ + "font-kit", + "fontique", + "swash", + "ttf-parser", + "lazy_static", + "pathfinder_geometry", +] # dlopen fontconfig C library at runtime instead of linking at build time # Can be useful for cross compiling, especially considering fontconfig has lots of C dependencies -fontconfig-dlopen = ["font-kit/source-fontconfig-dlopen"] +fontconfig-dlopen = ["font-kit/source-fontconfig-dlopen", "fontique/fontconfig-dlopen"] ab_glyph = ["dep:ab_glyph", "once_cell"] @@ -143,4 +152,3 @@ path = "benches/main.rs" [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "doc_cfg"] - diff --git a/plotters/src/style/font/system_source.rs b/plotters/src/style/font/system_source.rs new file mode 100644 index 00000000..670da389 --- /dev/null +++ b/plotters/src/style/font/system_source.rs @@ -0,0 +1,149 @@ +// pattern: Imperative Shell +#![allow(dead_code)] + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, RwLock}; + +use fontique::{ + Attributes, Collection, CollectionOptions, FontStyle as FontiqueStyle, FontWeight, FontWidth, + GenericFamily, QueryFamily, QueryStatus, SourceCache, +}; +use lazy_static::lazy_static; + +use super::{FontError, FontFamily, FontResult, FontStyle}; + +#[derive(Clone, Debug)] +pub(super) struct SystemFontData { + pub(super) bytes: Arc>, + pub(super) index: usize, +} + +lazy_static! { + static ref COLLECTION: Mutex<(Collection, SourceCache)> = Mutex::new(( + Collection::new(CollectionOptions { + system_fonts: true, + ..CollectionOptions::default() + }), + SourceCache::new_shared(), + )); + static ref BYTE_CACHE: RwLock>> = + RwLock::new(HashMap::new()); +} + +pub(super) fn load(family: FontFamily<'_>, style: FontStyle) -> FontResult { + let key = cache_key(family, style); + + if let Some(data) = BYTE_CACHE + .read() + .map_err(|_| FontError::LockError)? + .get(&key) + { + return data.clone(); + } + + let loaded = load_uncached(family, style); + BYTE_CACHE + .write() + .map_err(|_| FontError::LockError)? + .insert(key, loaded.clone()); + loaded +} + +#[cfg(test)] +fn cache_contains(family: FontFamily<'_>, style: FontStyle) -> bool { + BYTE_CACHE + .read() + .map(|cache| cache.contains_key(&cache_key(family, style))) + .unwrap_or(false) +} + +fn load_uncached(family: FontFamily<'_>, style: FontStyle) -> FontResult { + let mut hit = None; + let mut collection = COLLECTION.lock().map_err(|_| FontError::LockError)?; + let (collection, source_cache) = &mut *collection; + let mut query = collection.query(source_cache); + + let query_families = query_families(family); + query.set_families(query_families.iter().copied()); + query.set_attributes(attributes(style)); + query.matches_with(|font| { + hit = Some(SystemFontData { + bytes: Arc::new(font.blob.as_ref().to_vec()), + index: font.index as usize, + }); + QueryStatus::Stop + }); + + hit.ok_or_else(|| FontError::NoSuchFont(family.as_str().to_owned(), style.as_str().to_owned())) +} + +fn cache_key(family: FontFamily<'_>, style: FontStyle) -> String { + match style { + FontStyle::Normal => family.as_str().to_owned(), + _ => format!("{}, {}", family.as_str(), style.as_str()), + } +} + +fn query_families(family: FontFamily<'_>) -> Vec> { + match family { + FontFamily::Serif => vec![ + QueryFamily::Generic(GenericFamily::Serif), + QueryFamily::Generic(GenericFamily::SansSerif), + ], + FontFamily::SansSerif => vec![QueryFamily::Generic(GenericFamily::SansSerif)], + FontFamily::Monospace => vec![ + QueryFamily::Generic(GenericFamily::Monospace), + QueryFamily::Generic(GenericFamily::SansSerif), + ], + FontFamily::Name(name) => vec![QueryFamily::Named(name)], + } +} + +fn attributes(style: FontStyle) -> Attributes { + let (font_style, font_weight) = match style { + FontStyle::Normal => (FontiqueStyle::Normal, FontWeight::NORMAL), + FontStyle::Italic => (FontiqueStyle::Italic, FontWeight::NORMAL), + FontStyle::Oblique => (FontiqueStyle::Oblique(None), FontWeight::NORMAL), + FontStyle::Bold => (FontiqueStyle::Normal, FontWeight::BOLD), + }; + Attributes::new(FontWidth::NORMAL, font_style, font_weight) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_system_font_variants() -> FontResult<()> { + let cases = [ + ("serif_normal", FontFamily::Serif, FontStyle::Normal), + ("sans_bold", FontFamily::SansSerif, FontStyle::Bold), + ("monospace_italic", FontFamily::Monospace, FontStyle::Italic), + ]; + + for (name, family, style) in cases { + let font = load(family, style).unwrap_or_else(|err| { + panic!("case {} failed to load a system font: {}", name, err) + }); + assert!( + !font.bytes.is_empty(), + "case {} loaded empty font data", + name + ); + assert!( + cache_contains(family, style), + "case {} did not populate the font byte cache", + name + ); + } + + Ok(()) + } + + #[test] + fn missing_named_font_returns_error() { + let family = FontFamily::Name("plotters-font-that-should-not-exist"); + let err = load(family, FontStyle::Normal).unwrap_err(); + assert!(matches!(err, FontError::NoSuchFont(_, _))); + } +} diff --git a/plotters/src/style/font/ttf.rs b/plotters/src/style/font/ttf.rs index 1f7b5037..ba30a122 100644 --- a/plotters/src/style/font/ttf.rs +++ b/plotters/src/style/font/ttf.rs @@ -1,3 +1,8 @@ +// pattern: Mixed (needs refactoring) + +#[path = "system_source.rs"] +mod system_source; + use std::borrow::{Borrow, Cow}; use std::cell::RefCell; use std::collections::HashMap; @@ -79,9 +84,7 @@ impl Drop for FontExt { impl FontExt { fn new(font: Font) -> FontResult { - let handle = font - .handle() - .ok_or(FontError::FontHandleUnavailable)?; + let handle = font.handle().ok_or(FontError::FontHandleUnavailable)?; let face = match handle { Handle::Memory { bytes, font_index } => { let face = ttf_parser::Face::parse(bytes.as_slice(), font_index) From 092125dcf4ee3753dabbf01b8883524173fb85a0 Mon Sep 17 00:00:00 2001 From: Philip Deuchler Date: Fri, 24 Apr 2026 23:55:10 -0600 Subject: [PATCH 2/9] refactor(font): rewrite ttf backend on fontique and swash --- plotters/src/style/font/system_source.rs | 13 +- plotters/src/style/font/ttf.rs | 386 +++++++++++++---------- 2 files changed, 222 insertions(+), 177 deletions(-) diff --git a/plotters/src/style/font/system_source.rs b/plotters/src/style/font/system_source.rs index 670da389..d4e95ece 100644 --- a/plotters/src/style/font/system_source.rs +++ b/plotters/src/style/font/system_source.rs @@ -1,5 +1,4 @@ // pattern: Imperative Shell -#![allow(dead_code)] use std::collections::HashMap; use std::sync::{Arc, Mutex, RwLock}; @@ -95,7 +94,10 @@ fn query_families(family: FontFamily<'_>) -> Vec> { QueryFamily::Generic(GenericFamily::Monospace), QueryFamily::Generic(GenericFamily::SansSerif), ], - FontFamily::Name(name) => vec![QueryFamily::Named(name)], + FontFamily::Name(name) => vec![ + QueryFamily::Named(name), + QueryFamily::Generic(GenericFamily::SansSerif), + ], } } @@ -141,9 +143,10 @@ mod tests { } #[test] - fn missing_named_font_returns_error() { + fn missing_named_font_falls_back_to_sans_serif() -> FontResult<()> { let family = FontFamily::Name("plotters-font-that-should-not-exist"); - let err = load(family, FontStyle::Normal).unwrap_err(); - assert!(matches!(err, FontError::NoSuchFont(_, _))); + let font = load(family, FontStyle::Normal)?; + assert!(!font.bytes.is_empty()); + Ok(()) } } diff --git a/plotters/src/style/font/ttf.rs b/plotters/src/style/font/ttf.rs index ba30a122..b1334d93 100644 --- a/plotters/src/style/font/ttf.rs +++ b/plotters/src/style/font/ttf.rs @@ -3,28 +3,21 @@ #[path = "system_source.rs"] mod system_source; -use std::borrow::{Borrow, Cow}; +use std::borrow::Borrow; use std::cell::RefCell; use std::collections::HashMap; use std::sync::{Arc, RwLock}; use lazy_static::lazy_static; -use font_kit::{ - canvas::{Canvas, Format, RasterizationOptions}, - error::{FontLoadingError, GlyphLoadingError}, - family_name::FamilyName, - font::Font, - handle::Handle, - hinting::HintingOptions, - properties::{Properties, Style, Weight}, - source::SystemSource, +use swash::{ + scale::{Render, ScaleContext, Source}, + zeno::Format, + Charmap, FontRef, GlyphId, }; -use ttf_parser::{Face, GlyphId}; - -use pathfinder_geometry::transform2d::Transform2F; -use pathfinder_geometry::vector::{Vector2F, Vector2I}; +use system_source::SystemFontData; +use ttf_parser::{Face, GlyphId as TtfGlyphId}; use super::{FontData, FontFamily, FontStyle, LayoutBox}; @@ -34,23 +27,19 @@ type FontResult = Result; pub enum FontError { LockError, NoSuchFont(String, String), - FontLoadError(Arc), - GlyphError(Arc), - FontHandleUnavailable, + FontLoadError(String), FaceParseError(String), } impl std::fmt::Display for FontError { fn fmt(&self, fmt: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { match self { - FontError::LockError => write!(fmt, "Could not lock mutex"), + FontError::LockError => write!(fmt, "could not lock mutex"), FontError::NoSuchFont(family, style) => { - write!(fmt, "No such font: {} {}", family, style) + write!(fmt, "no such font: {} {}", family, style) } - FontError::FontLoadError(e) => write!(fmt, "Font loading error {}", e), - FontError::GlyphError(e) => write!(fmt, "Glyph error {}", e), - FontError::FontHandleUnavailable => write!(fmt, "Font handle is not available"), - FontError::FaceParseError(e) => write!(fmt, "Font face parse error {}", e), + FontError::FontLoadError(e) => write!(fmt, "font loading error: {}", e), + FontError::FaceParseError(e) => write!(fmt, "font face parse error: {}", e), } } } @@ -58,75 +47,67 @@ impl std::fmt::Display for FontError { impl std::error::Error for FontError {} lazy_static! { - static ref DATA_CACHE: RwLock>> = + static ref DATA_CACHE: RwLock>> = RwLock::new(HashMap::new()); } thread_local! { - static FONT_SOURCE: SystemSource = SystemSource::new(); static FONT_OBJECT_CACHE: RefCell> = RefCell::new(HashMap::new()); + static SCALE_CONTEXT: RefCell = RefCell::new(ScaleContext::new()); } const PLACEHOLDER_CHAR: char = '�'; #[derive(Clone)] struct FontExt { - inner: Font, - face: Option>, + bytes: Arc>, + index: usize, } -impl Drop for FontExt { - fn drop(&mut self) { - // We should make sure the face object dead first - self.face.take(); +impl FontExt { + fn new(data: SystemFontData) -> FontResult { + FontRef::from_index(data.bytes.as_slice(), data.index).ok_or_else(|| { + FontError::FontLoadError(format!("invalid font data at index {}", data.index)) + })?; + Face::parse(data.bytes.as_slice(), data.index as u32) + .map_err(|err| FontError::FaceParseError(err.to_string()))?; + Ok(Self { + bytes: data.bytes, + index: data.index, + }) } -} -impl FontExt { - fn new(font: Font) -> FontResult { - let handle = font.handle().ok_or(FontError::FontHandleUnavailable)?; - let face = match handle { - Handle::Memory { bytes, font_index } => { - let face = ttf_parser::Face::parse(bytes.as_slice(), font_index) - .map_err(|err| FontError::FaceParseError(err.to_string()))?; - Some(unsafe { std::mem::transmute::, Face<'static>>(face) }) - } - _ => None, - }; - Ok(Self { inner: font, face }) + fn font_ref(&self) -> FontResult> { + FontRef::from_index(self.bytes.as_slice(), self.index).ok_or_else(|| { + FontError::FontLoadError(format!("invalid font data at index {}", self.index)) + }) } - fn query_kerning_table(&self, prev: u32, next: u32) -> f32 { - if let Some(face) = self.face.as_ref() { - if let Some(kern) = face.tables().kern { - let kern = kern - .subtables - .into_iter() - .filter(|st| st.horizontal && !st.variable) - .filter_map(|st| st.glyphs_kerning(GlyphId(prev as u16), GlyphId(next as u16))) - .next() - .unwrap_or(0); - return kern as f32; - } - } - 0.0 + fn cache_id(&self) -> [u64; 2] { + [Arc::as_ptr(&self.bytes) as usize as u64, self.index as u64] } -} -impl std::ops::Deref for FontExt { - type Target = Font; - fn deref(&self) -> &Font { - &self.inner + fn query_kerning_table(&self, prev: GlyphId, next: GlyphId) -> f32 { + let Ok(face) = Face::parse(self.bytes.as_slice(), self.index as u32) else { + return 0.0; + }; + if let Some(kern) = face.tables().kern { + let kern = kern + .subtables + .into_iter() + .filter(|st| st.horizontal && !st.variable) + .find_map(|st| st.glyphs_kerning(TtfGlyphId(prev), TtfGlyphId(next))) + .unwrap_or(0); + return kern as f32; + } + 0.0 } } /// Lazily load font data. Font type doesn't own actual data, which /// lives in the cache. fn load_font_data(face: FontFamily, style: FontStyle) -> FontResult { - let key = match style { - FontStyle::Normal => Cow::Borrowed(face.as_str()), - _ => Cow::Owned(format!("{}, {}", face.as_str(), style.as_str())), - }; + let key = cache_key(face, style); // First, we try to find the font object for current thread if let Some(font_object) = FONT_OBJECT_CACHE.with(|font_object_cache| { @@ -139,66 +120,52 @@ fn load_font_data(face: FontFamily, style: FontStyle) -> FontResult { } // Then we need to check if the data cache contains the font data - let cache = DATA_CACHE.read().unwrap(); - if let Some(data) = cache.get(Borrow::::borrow(&key)) { - data.clone().map(load_font_from_handle)??; - } - drop(cache); - - // Otherwise we should load from system - let mut properties = Properties::new(); - match style { - FontStyle::Normal => properties.style(Style::Normal), - FontStyle::Italic => properties.style(Style::Italic), - FontStyle::Oblique => properties.style(Style::Oblique), - FontStyle::Bold => properties.weight(Weight::BOLD), - }; - - let family = match face { - FontFamily::Serif => FamilyName::Serif, - FontFamily::SansSerif => FamilyName::SansSerif, - FontFamily::Monospace => FamilyName::Monospace, - FontFamily::Name(name) => FamilyName::Title(name.to_owned()), - }; - - let make_not_found_error = - || FontError::NoSuchFont(face.as_str().to_owned(), style.as_str().to_owned()); - - if let Ok(handle) = FONT_SOURCE - .with(|source| source.select_best_match(&[family, FamilyName::SansSerif], &properties)) + if let Some(data) = DATA_CACHE + .read() + .map_err(|_| FontError::LockError)? + .get(Borrow::::borrow(&key)) + .cloned() { - let font = load_font_from_handle(handle); - let (should_cache, data) = match font.as_ref().map(|f| f.handle()) { - Ok(None) => (false, Err(FontError::LockError)), - Ok(Some(handle)) => (true, Ok(handle)), - Err(e) => (true, Err(e.clone())), - }; + let font = FontExt::new(data?)?; + cache_font_object(key, &font); + return Ok(font); + } - if should_cache { - DATA_CACHE - .write() - .map_err(|_| FontError::LockError)? - .insert(key.clone().into_owned(), data); - } + let data = system_source::load(face, style); + DATA_CACHE + .write() + .map_err(|_| FontError::LockError)? + .insert(key.clone(), data.clone()); - if let Ok(font) = font.as_ref() { - FONT_OBJECT_CACHE.with(|font_object_cache| { - font_object_cache - .borrow_mut() - .insert(key.into_owned(), font.clone()); - }); - } + let font = FontExt::new(data?)?; + cache_font_object(key, &font); + Ok(font) +} - return font; +fn cache_key(face: FontFamily<'_>, style: FontStyle) -> String { + match style { + FontStyle::Normal => face.as_str().to_owned(), + _ => format!("{}, {}", face.as_str(), style.as_str()), } - Err(make_not_found_error()) } -fn load_font_from_handle(handle: Handle) -> FontResult { - let font = handle - .load() - .map_err(|e| FontError::FontLoadError(Arc::new(e)))?; - FontExt::new(font) +fn cache_font_object(key: String, font: &FontExt) { + FONT_OBJECT_CACHE.with(|font_object_cache| { + font_object_cache.borrow_mut().insert(key, font.clone()); + }); +} + +fn glyph_for_char(charmap: &Charmap<'_>, c: char) -> Option { + let glyph_id = charmap.map(c); + (glyph_id != 0).then_some(glyph_id) +} + +fn scale_design_units(value: f32, em: f32, units_per_em: u16) -> f32 { + if units_per_em == 0 { + 0.0 + } else { + value * em / units_per_em as f32 + } } #[derive(Clone)] @@ -212,31 +179,32 @@ impl FontData for FontDataInternal { } fn estimate_layout(&self, size: f64, text: &str) -> Result { - let font = &self.0; let pixel_per_em = size / 1.24; - let metrics = font.metrics(); - let font = &self.0; + let font_ref = font.font_ref()?; + let metrics = font_ref.metrics(&[]); + let glyph_metrics = font_ref.glyph_metrics(&[]).scale(pixel_per_em as f32); + let charmap = font_ref.charmap(); - let mut x_in_unit = 0f32; + let mut x_pixels = 0f32; let mut prev = None; - let place_holder = font.glyph_for_char(PLACEHOLDER_CHAR); + let place_holder = glyph_for_char(&charmap, PLACEHOLDER_CHAR); for c in text.chars() { - if let Some(glyph_id) = font.glyph_for_char(c).or(place_holder) { - if let Ok(size) = font.advance(glyph_id) { - x_in_unit += size.x(); - } + if let Some(glyph_id) = glyph_for_char(&charmap, c).or(place_holder) { + x_pixels += glyph_metrics.advance_width(glyph_id); if let Some(pc) = prev { - x_in_unit += font.query_kerning_table(pc, glyph_id); + x_pixels += scale_design_units( + font.query_kerning_table(pc, glyph_id), + pixel_per_em as f32, + metrics.units_per_em, + ); } prev = Some(glyph_id); } } - let x_pixels = x_in_unit * pixel_per_em as f32 / metrics.units_per_em as f32; - Ok(((0, 0), (x_pixels as i32, pixel_per_em as i32))) } @@ -251,56 +219,66 @@ impl FontData for FontDataInternal { let mut x = base_x as f32; let font = &self.0; - let metrics = font.metrics(); - - let canvas_size = size as usize; + let font_ref = font.font_ref()?; + let metrics = font_ref.metrics(&[]); + let glyph_metrics = font_ref.glyph_metrics(&[]).scale(em); + let charmap = font_ref.charmap(); base_y -= (0.24 * em) as i32; let mut prev = None; - let place_holder = font.glyph_for_char(PLACEHOLDER_CHAR); - - let mut result = Ok(()); - - for c in text.chars() { - if let Some(glyph_id) = font.glyph_for_char(c).or(place_holder) { - if let Some(pc) = prev { - x += font.query_kerning_table(pc, glyph_id) * em / metrics.units_per_em as f32; - } + let place_holder = glyph_for_char(&charmap, PLACEHOLDER_CHAR); + + let render_sources = [Source::Outline]; + + let draw_result = SCALE_CONTEXT.with(|scale_context| { + let mut scale_context = scale_context.borrow_mut(); + let mut scaler = scale_context + .builder_with_id(font_ref, font.cache_id()) + .size(em) + .hint(true) + .build(); + let mut renderer = Render::new(&render_sources); + renderer.format(Format::Alpha); + + for c in text.chars() { + if let Some(glyph_id) = glyph_for_char(&charmap, c).or(place_holder) { + if let Some(pc) = prev { + x += scale_design_units( + font.query_kerning_table(pc, glyph_id), + em, + metrics.units_per_em, + ); + } - let mut canvas = Canvas::new(Vector2I::splat(canvas_size as i32), Format::A8); - - result = font - .rasterize_glyph( - &mut canvas, - glyph_id, - em, - Transform2F::from_translation(Vector2F::new(0.0, em)), - HintingOptions::None, - RasterizationOptions::GrayscaleAa, - ) - .map_err(|e| FontError::GlyphError(Arc::new(e))) - .and(result); - - let base_x = x as i32; - - for dy in 0..canvas_size { - for dx in 0..canvas_size { - let alpha = canvas.pixels[dy * canvas_size + dx] as f32 / 255.0; - if let Err(e) = draw(base_x + dx as i32, base_y + dy as i32, alpha) { - return Ok(Err(e)); + let base_x = x as i32; + + if let Some(image) = renderer.render(&mut scaler, glyph_id) { + let width = image.placement.width as usize; + let height = image.placement.height as usize; + + for dy in 0..height { + for dx in 0..width { + let alpha = image.data[dy * width + dx] as f32 / 255.0; + if let Err(e) = draw( + base_x + image.placement.left + dx as i32, + base_y - image.placement.top + dy as i32, + alpha, + ) { + return Err(e); + } + } } } - } - x += font.advance(glyph_id).map(|size| size.x()).unwrap_or(0.0) * em - / metrics.units_per_em as f32; + x += glyph_metrics.advance_width(glyph_id); - prev = Some(glyph_id); + prev = Some(glyph_id); + } } - } - result?; - Ok(Ok(())) + Ok(()) + }); + Ok(draw_result) } } @@ -324,4 +302,68 @@ mod test { Ok(()) } + + #[test] + fn draw_glyphs_stay_in_expected_bounds() -> FontResult<()> { + let cases = [ + (FontFamily::SansSerif, FontStyle::Normal), + (FontFamily::Serif, FontStyle::Bold), + ]; + + for (family, style) in cases { + assert_draw_sanity(family, style)?; + } + + Ok(()) + } + + fn assert_draw_sanity(family: FontFamily<'_>, style: FontStyle) -> FontResult<()> { + let size = 32.0; + let em = size / 1.24; + let font = FontDataInternal::new(family, style)?; + let mut samples = Vec::new(); + + let draw_result = font.draw((0, size as i32), size, "Hg", |x, y, alpha| { + samples.push((x, y, alpha)); + Ok::<(), ()>(()) + })?; + assert!(draw_result.is_ok()); + + assert!( + samples.iter().any(|(_, _, alpha)| *alpha > 0.8), + "expected at least one high-alpha glyph sample" + ); + assert!( + samples + .iter() + .all(|(_, _, alpha)| alpha.is_finite() && (0.0..=1.0).contains(alpha)), + "all alpha samples should be finite and normalized" + ); + + let touched: Vec<_> = samples + .iter() + .filter(|(_, _, alpha)| *alpha > 0.0) + .collect(); + assert!(!touched.is_empty(), "expected non-empty touched bounds"); + + let min_x = touched.iter().map(|(x, _, _)| *x).min().unwrap(); + let max_x = touched.iter().map(|(x, _, _)| *x).max().unwrap(); + let min_y = touched.iter().map(|(_, y, _)| *y).min().unwrap(); + let max_y = touched.iter().map(|(_, y, _)| *y).max().unwrap(); + + assert!(min_x >= 0, "glyphs drifted left: min_x={}", min_x); + assert!(min_y >= 0, "glyphs drifted above canvas: min_y={}", min_y); + assert!( + max_x <= (3.0 * em) as i32, + "glyphs drifted right: max_x={}", + max_x + ); + assert!( + max_y <= (1.5 * em) as i32, + "glyphs drifted below canvas: max_y={}", + max_y + ); + + Ok(()) + } } From 8fd862d8e89de4efcdd1b88f63c8234775f67318 Mon Sep 17 00:00:00 2001 From: Philip Deuchler Date: Sat, 25 Apr 2026 10:49:22 -0600 Subject: [PATCH 3/9] fix(svg): adapt bitmap encoding to image 0.25 --- plotters-svg/src/svg.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plotters-svg/src/svg.rs b/plotters-svg/src/svg.rs index e24623d7..b9bbd7bf 100644 --- a/plotters-svg/src/svg.rs +++ b/plotters-svg/src/svg.rs @@ -1,3 +1,5 @@ +// pattern: Mixed (needs refactoring) + /*! The SVG image drawing backend */ @@ -602,7 +604,7 @@ impl<'a> DrawingBackend for SVGBackend<'a> { let color = image::ColorType::Rgb8; - encoder.write_image(src, w, h, color).map_err(|e| { + encoder.write_image(src, w, h, color.into()).map_err(|e| { DrawingErrorKind::DrawingError(Error::new( std::io::ErrorKind::Other, format!("Image error: {}", e), From 99218fec53cb1ea4badc4377b0f55d5aac72d3cb Mon Sep 17 00:00:00 2001 From: Philip Deuchler Date: Sat, 25 Apr 2026 12:50:54 -0600 Subject: [PATCH 4/9] chore(deps): drop font-kit and pathfinder_geometry --- CHANGELOG.md | 13 +++++++++++++ README.md | 10 +++++++--- RELEASE-NOTES.md | 12 ++++++++++++ doc-template/readme.template.md | 10 +++++++--- plotters/Cargo.toml | 6 +----- plotters/src/lib.rs | 9 +++++++-- plotters/src/style/font/naive.rs | 6 ++++-- 7 files changed, 51 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 664ba133..bb345303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## Unreleased + +### Changed + +- Replace the `ttf` feature's `font-kit`-backed font backend with `fontique` + for native system font discovery and `swash` for glyph rasterization. +- Enabling the `ttf` feature now requires Rust 1.88 or newer; non-`ttf` builds + retain the crate's declared MSRV. + +### Removed + +- Remove `font-kit` and `pathfinder_geometry` from the `ttf` dependency tree. + ## Plotters 0.3.6 (2024-05-20) ### Added diff --git a/README.md b/README.md index 4acc060c..0643e71e 100644 --- a/README.md +++ b/README.md @@ -532,7 +532,7 @@ The following list is a complete list of features that can be opted in or out. | Name | Description | Additional Dependency |Default?| |---------|--------------|--------|------------| -| bitmap\_encoder | Allow `BitMapBackend` to save the result to bitmap files | image, rusttype, font-kit | Yes | +| bitmap\_encoder | Allow `BitMapBackend` to save the result to bitmap files | image | Yes | | svg\_backend | Enable `SVGBackend` Support | None | Yes | | bitmap\_gif| Opt-in GIF animation Rendering support for `BitMapBackend`, implies `bitmap` enabled | gif | Yes | @@ -540,9 +540,14 @@ The following list is a complete list of features that can be opted in or out. | Name | Description | Additional Dependency | Default? | |----------|------------------------------------------|-----------------------|----------| -| ttf | Allows TrueType font support | font-kit | Yes | +| ttf | Allows TrueType font support | fontique, swash, ttf-parser, lazy_static | Yes | | ab_glyph | Skips loading system fonts, unlike `ttf` | ab_glyph | No | +The `ttf` feature uses native system font discovery through `fontique` and +glyph rasterization through `swash`, while retaining `ttf-parser` for legacy +kerning table support. Enabling `ttf` requires Rust 1.88 or newer; builds that +disable `ttf` keep the crate's declared MSRV. + `ab_glyph` supports TrueType and OpenType fonts, but does not attempt to load fonts provided by the system on which it is running. It is pure Rust, and easier to cross compile. @@ -646,4 +651,3 @@ pub struct RGBAColor(pub u8, pub u8, pub u8, pub f64); In the case that error handling is important, you need manually call the `present()` method before the backend gets dropped. For more information, please see the examples. - diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 060e922a..eb159bdb 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -2,6 +2,18 @@ This documents contains the release notes for every major release since v0.3. +## Development Notes + +### TTF Font Backend + +The default `ttf` feature no longer depends on `font-kit` or +`pathfinder_geometry`. It uses `fontique` for native system font discovery and +`swash` for glyph rasterization, while retaining `ttf-parser` for legacy +kerning table support. + +Enabling `ttf` now requires Rust 1.88 or newer. Builds that disable `ttf` +retain the crate's declared MSRV. + ## Plotters v0.3 Plotters v0.3 is shipped with multiple major improvements. diff --git a/doc-template/readme.template.md b/doc-template/readme.template.md index 980be43e..7f92e798 100644 --- a/doc-template/readme.template.md +++ b/doc-template/readme.template.md @@ -270,7 +270,7 @@ The following list is a complete list of features that can be opted in or out. | Name | Description | Additional Dependency |Default?| |---------|--------------|--------|------------| -| bitmap\_encoder | Allow `BitMapBackend` to save the result to bitmap files | image, rusttype, font-kit | Yes | +| bitmap\_encoder | Allow `BitMapBackend` to save the result to bitmap files | image | Yes | | svg\_backend | Enable `SVGBackend` Support | None | Yes | | bitmap\_gif| Opt-in GIF animation Rendering support for `BitMapBackend`, implies `bitmap` enabled | gif | Yes | @@ -278,9 +278,14 @@ The following list is a complete list of features that can be opted in or out. | Name | Description | Additional Dependency | Default? | |----------|------------------------------------------|-----------------------|----------| -| ttf | Allows TrueType font support | font-kit | Yes | +| ttf | Allows TrueType font support | fontique, swash, ttf-parser, lazy_static | Yes | | ab_glyph | Skips loading system fonts, unlike `ttf` | ab_glyph | No | +The `ttf` feature uses native system font discovery through `fontique` and +glyph rasterization through `swash`, while retaining `ttf-parser` for legacy +kerning table support. Enabling `ttf` requires Rust 1.88 or newer; builds that +disable `ttf` keep the crate's declared MSRV. + `ab_glyph` supports TrueType and OpenType fonts, but does not attempt to load fonts provided by the system on which it is running. It is pure Rust, and easier to cross compile. @@ -369,4 +374,3 @@ pub fn register_font( For more information, please see the examples. $$style$$ - diff --git a/plotters/Cargo.toml b/plotters/Cargo.toml index dc88cdf5..d51ca2b8 100644 --- a/plotters/Cargo.toml +++ b/plotters/Cargo.toml @@ -42,8 +42,6 @@ ttf-parser = { version = "0.25.1", optional = true } lazy_static = { version = "1.4.0", optional = true } fontique = { version = "0.9.0", optional = true } swash = { version = "0.2.7", optional = true } -pathfinder_geometry = { version = "0.5.1", optional = true } -font-kit = { version = "0.14.2", optional = true } ab_glyph = { version = "0.2.12", optional = true } once_cell = { version = "1.8.0", optional = true } @@ -108,16 +106,14 @@ surface_series = [] # Font implementation ttf = [ - "font-kit", "fontique", "swash", "ttf-parser", "lazy_static", - "pathfinder_geometry", ] # dlopen fontconfig C library at runtime instead of linking at build time # Can be useful for cross compiling, especially considering fontconfig has lots of C dependencies -fontconfig-dlopen = ["font-kit/source-fontconfig-dlopen", "fontique/fontconfig-dlopen"] +fontconfig-dlopen = ["fontique/fontconfig-dlopen"] ab_glyph = ["dep:ab_glyph", "once_cell"] diff --git a/plotters/src/lib.rs b/plotters/src/lib.rs index b3731333..12850731 100644 --- a/plotters/src/lib.rs +++ b/plotters/src/lib.rs @@ -670,7 +670,7 @@ The following list is a complete list of features that can be opted in or out. | Name | Description | Additional Dependency |Default?| |---------|--------------|--------|------------| -| bitmap\_encoder | Allow `BitMapBackend` to save the result to bitmap files | image, rusttype, font-kit | Yes | +| bitmap\_encoder | Allow `BitMapBackend` to save the result to bitmap files | image | Yes | | svg\_backend | Enable `SVGBackend` Support | None | Yes | | bitmap\_gif| Opt-in GIF animation Rendering support for `BitMapBackend`, implies `bitmap` enabled | gif | Yes | @@ -678,9 +678,14 @@ The following list is a complete list of features that can be opted in or out. | Name | Description | Additional Dependency | Default? | |----------|------------------------------------------|-----------------------|----------| -| ttf | Allows TrueType font support | font-kit | Yes | +| ttf | Allows TrueType font support | fontique, swash, ttf-parser, lazy_static | Yes | | ab_glyph | Skips loading system fonts, unlike `ttf` | ab_glyph | No | +The `ttf` feature uses native system font discovery through `fontique` and +glyph rasterization through `swash`, while retaining `ttf-parser` for legacy +kerning table support. Enabling `ttf` requires Rust 1.88 or newer; builds that +disable `ttf` keep the crate's declared MSRV. + `ab_glyph` supports TrueType and OpenType fonts, but does not attempt to load fonts provided by the system on which it is running. It is pure Rust, and easier to cross compile. diff --git a/plotters/src/style/font/naive.rs b/plotters/src/style/font/naive.rs index 99530401..51790de4 100644 --- a/plotters/src/style/font/naive.rs +++ b/plotters/src/style/font/naive.rs @@ -1,3 +1,5 @@ +// pattern: Functional Core + use super::{FontData, FontFamily, FontStyle, LayoutBox}; #[derive(Debug, Clone)] @@ -25,8 +27,8 @@ impl FontData for FontDataInternal { } /// Note: This is only a crude estimatation, since for some backend such as SVG, we have no way to - /// know the real size of the text anyway. Thus using font-kit is an overkill and doesn't helps - /// the layout. + /// know the real size of the text anyway. Thus using the system font backend is overkill and + /// doesn't help the layout. fn estimate_layout(&self, size: f64, text: &str) -> Result { let em = size / 1.24 / 1.24; Ok(( From e70056be802ad39f314e8dbaf2f4ee762bdb23ec Mon Sep 17 00:00:00 2001 From: Philip Deuchler Date: Sat, 25 Apr 2026 13:02:49 -0600 Subject: [PATCH 5/9] chore: remove pattern comments --- plotters-svg/src/svg.rs | 2 -- plotters/src/style/font/naive.rs | 2 -- plotters/src/style/font/system_source.rs | 2 -- plotters/src/style/font/ttf.rs | 2 -- 4 files changed, 8 deletions(-) diff --git a/plotters-svg/src/svg.rs b/plotters-svg/src/svg.rs index b9bbd7bf..adc6a478 100644 --- a/plotters-svg/src/svg.rs +++ b/plotters-svg/src/svg.rs @@ -1,5 +1,3 @@ -// pattern: Mixed (needs refactoring) - /*! The SVG image drawing backend */ diff --git a/plotters/src/style/font/naive.rs b/plotters/src/style/font/naive.rs index 51790de4..4697b3e9 100644 --- a/plotters/src/style/font/naive.rs +++ b/plotters/src/style/font/naive.rs @@ -1,5 +1,3 @@ -// pattern: Functional Core - use super::{FontData, FontFamily, FontStyle, LayoutBox}; #[derive(Debug, Clone)] diff --git a/plotters/src/style/font/system_source.rs b/plotters/src/style/font/system_source.rs index d4e95ece..24aca973 100644 --- a/plotters/src/style/font/system_source.rs +++ b/plotters/src/style/font/system_source.rs @@ -1,5 +1,3 @@ -// pattern: Imperative Shell - use std::collections::HashMap; use std::sync::{Arc, Mutex, RwLock}; diff --git a/plotters/src/style/font/ttf.rs b/plotters/src/style/font/ttf.rs index b1334d93..f906296d 100644 --- a/plotters/src/style/font/ttf.rs +++ b/plotters/src/style/font/ttf.rs @@ -1,5 +1,3 @@ -// pattern: Mixed (needs refactoring) - #[path = "system_source.rs"] mod system_source; From 86d07055b4913fb4f37f9f8f995b0f1c473660f1 Mon Sep 17 00:00:00 2001 From: Philip Deuchler Date: Sat, 25 Apr 2026 14:00:54 -0600 Subject: [PATCH 6/9] Address Claude code review issues, try and fix placement issue --- CHANGELOG.md | 14 ++ RELEASE-NOTES.md | 20 +++ plotters/src/style/font/system_source.rs | 102 ++++++++++--- plotters/src/style/font/ttf.rs | 182 +++++++++++------------ 4 files changed, 203 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb345303..67a2eb47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ for native system font discovery and `swash` for glyph rasterization. - Enabling the `ttf` feature now requires Rust 1.88 or newer; non-`ttf` builds retain the crate's declared MSRV. +- The `fontconfig-dlopen` cargo feature now routes through + `fontique/fontconfig-dlopen`; behavior on Linux is unchanged. + +### Breaking + +- The `ttf` backend's `FontError` enum has been reshaped to drop + `font-kit`-typed payloads. Removed variants: + `GlyphError(Arc)` and + `FontHandleUnavailable`. Changed: + `FontLoadError(Arc)` is now + `FontLoadError(String)`. Downstream code that pattern-matches on these + variants must be updated. Glyph-loading failures are now reported via + `FontLoadError` / `FaceParseError` at font-load time rather than at draw + time. ### Removed diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index eb159bdb..5b01b69e 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -14,6 +14,26 @@ kerning table support. Enabling `ttf` now requires Rust 1.88 or newer. Builds that disable `ttf` retain the crate's declared MSRV. +The `fontconfig-dlopen` cargo feature continues to enable runtime loading of +the fontconfig C library on Linux, now routed through `fontique`'s feature of +the same name. + +#### Breaking changes in `FontError` + +The `ttf` backend's public `FontError` enum has been reshaped to drop +`font-kit`-typed payloads: + +- Removed: `GlyphError(Arc)` and + `FontHandleUnavailable`. +- Changed: `FontLoadError(Arc)` is now + `FontLoadError(String)`. + +Downstream code that pattern-matches on these variants must be updated. +Glyph-loading failures previously surfaced through `GlyphError` at draw time; +they are now reported via `FontLoadError` / `FaceParseError` at font-load +time, so error handling around `FontDataInternal::new` and the surrounding +`text_anchor` / `IntoTextStyle` machinery should be reviewed. + ## Plotters v0.3 Plotters v0.3 is shipped with multiple major improvements. diff --git a/plotters/src/style/font/system_source.rs b/plotters/src/style/font/system_source.rs index 24aca973..a136943b 100644 --- a/plotters/src/style/font/system_source.rs +++ b/plotters/src/style/font/system_source.rs @@ -1,4 +1,12 @@ +//! Native system font discovery via [`fontique`]. +//! +//! Compiled only with the `ttf` feature; `ttf.rs` is the sole consumer. +//! The byte cache here is the canonical store for font data — `ttf.rs` +//! keeps a thread-local `FontExt` cache on top but does not re-cache the +//! bytes themselves. + use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use fontique::{ @@ -6,13 +14,28 @@ use fontique::{ GenericFamily, QueryFamily, QueryStatus, SourceCache, }; use lazy_static::lazy_static; +use swash::FontRef; +use ttf_parser::Face; use super::{FontError, FontFamily, FontResult, FontStyle}; +/// Bytes for one resolved font face. Cheap to clone. #[derive(Clone, Debug)] pub(super) struct SystemFontData { + /// Font binary, kept alive for as long as any consumer references it. pub(super) bytes: Arc>, + /// Index of the face inside the font collection (TTC). pub(super) index: usize, + /// Stable cache identity allocated at first load. Used by swash's + /// `ScaleContext` so glyphs cannot be served from a freed-and-reused + /// pointer if the underlying `Arc` is ever pruned. + pub(super) id: u64, +} + +static FONT_ID_COUNTER: AtomicU64 = AtomicU64::new(1); + +fn next_font_id() -> u64 { + FONT_ID_COUNTER.fetch_add(1, Ordering::Relaxed) } lazy_static! { @@ -38,12 +61,15 @@ pub(super) fn load(family: FontFamily<'_>, style: FontStyle) -> FontResult, style: FontStyle) -> bool { } fn load_uncached(family: FontFamily<'_>, style: FontStyle) -> FontResult { + // Resolve the font under the collection lock, but drop the lock before + // copying the byte buffer so concurrent loads of *different* fonts do + // not serialize on a multi-MB Vec::to_vec. let mut hit = None; - let mut collection = COLLECTION.lock().map_err(|_| FontError::LockError)?; - let (collection, source_cache) = &mut *collection; - let mut query = collection.query(source_cache); - - let query_families = query_families(family); - query.set_families(query_families.iter().copied()); - query.set_attributes(attributes(style)); - query.matches_with(|font| { - hit = Some(SystemFontData { - bytes: Arc::new(font.blob.as_ref().to_vec()), - index: font.index as usize, + { + let mut collection = COLLECTION.lock().map_err(|_| FontError::LockError)?; + let (collection, source_cache) = &mut *collection; + let mut query = collection.query(source_cache); + + let query_families = query_families(family); + query.set_families(query_families.iter().copied()); + query.set_attributes(attributes(style)); + query.matches_with(|font| { + // peniko::Blob is internally reference-counted; clone is cheap. + hit = Some((font.blob.clone(), font.index)); + QueryStatus::Stop }); - QueryStatus::Stop - }); + } - hit.ok_or_else(|| FontError::NoSuchFont(family.as_str().to_owned(), style.as_str().to_owned())) + let (blob, index) = hit.ok_or_else(|| { + FontError::NoSuchFont(family.as_str().to_owned(), style.as_str().to_owned()) + })?; + + let bytes = Arc::new(blob.as_ref().to_vec()); + let index = index as usize; + + // Validate once at load time so consumers can treat parsed FontRef / + // Face as infallible from the cached bytes. + FontRef::from_index(bytes.as_slice(), index).ok_or_else(|| { + FontError::FontLoadError(format!("invalid font data at index {}", index)) + })?; + Face::parse(bytes.as_slice(), index as u32) + .map_err(|err| FontError::FaceParseError(err.to_string()))?; + + Ok(SystemFontData { + bytes, + index, + id: next_font_id(), + }) } fn cache_key(family: FontFamily<'_>, style: FontStyle) -> String { @@ -135,6 +183,7 @@ mod tests { "case {} did not populate the font byte cache", name ); + assert!(font.id > 0, "case {} got an unallocated font id", name); } Ok(()) @@ -142,9 +191,24 @@ mod tests { #[test] fn missing_named_font_falls_back_to_sans_serif() -> FontResult<()> { + // Matches font-kit's behavior: select_best_match was called with + // [requested, SansSerif] as the candidate list, so unknown names + // resolve to the sans-serif fallback rather than erroring. let family = FontFamily::Name("plotters-font-that-should-not-exist"); let font = load(family, FontStyle::Normal)?; assert!(!font.bytes.is_empty()); Ok(()) } + + #[test] + fn cached_loads_share_arc() -> FontResult<()> { + let a = load(FontFamily::Serif, FontStyle::Normal)?; + let b = load(FontFamily::Serif, FontStyle::Normal)?; + assert!( + Arc::ptr_eq(&a.bytes, &b.bytes), + "cached loads should return the same Arc" + ); + assert_eq!(a.id, b.id, "cached loads should share font id"); + Ok(()) + } } diff --git a/plotters/src/style/font/ttf.rs b/plotters/src/style/font/ttf.rs index f906296d..3b309fa1 100644 --- a/plotters/src/style/font/ttf.rs +++ b/plotters/src/style/font/ttf.rs @@ -1,12 +1,9 @@ #[path = "system_source.rs"] mod system_source; -use std::borrow::Borrow; use std::cell::RefCell; use std::collections::HashMap; -use std::sync::{Arc, RwLock}; - -use lazy_static::lazy_static; +use std::sync::Arc; use swash::{ scale::{Render, ScaleContext, Source}, @@ -44,99 +41,74 @@ impl std::fmt::Display for FontError { impl std::error::Error for FontError {} -lazy_static! { - static ref DATA_CACHE: RwLock>> = - RwLock::new(HashMap::new()); -} - thread_local! { static FONT_OBJECT_CACHE: RefCell> = RefCell::new(HashMap::new()); static SCALE_CONTEXT: RefCell = RefCell::new(ScaleContext::new()); } -const PLACEHOLDER_CHAR: char = '�'; +/// Substituted when the requested glyph is missing from the font. +const PLACEHOLDER_CHAR: char = '\u{FFFD}'; + +const RENDER_SOURCES: [Source; 1] = [Source::Outline]; #[derive(Clone)] struct FontExt { bytes: Arc>, index: usize, + id: u64, } impl FontExt { - fn new(data: SystemFontData) -> FontResult { - FontRef::from_index(data.bytes.as_slice(), data.index).ok_or_else(|| { - FontError::FontLoadError(format!("invalid font data at index {}", data.index)) - })?; - Face::parse(data.bytes.as_slice(), data.index as u32) - .map_err(|err| FontError::FaceParseError(err.to_string()))?; - Ok(Self { + fn from_data(data: SystemFontData) -> Self { + Self { bytes: data.bytes, index: data.index, - }) + id: data.id, + } } - fn font_ref(&self) -> FontResult> { - FontRef::from_index(self.bytes.as_slice(), self.index).ok_or_else(|| { - FontError::FontLoadError(format!("invalid font data at index {}", self.index)) - }) + fn font_ref(&self) -> FontRef<'_> { + FontRef::from_index(self.bytes.as_slice(), self.index) + .expect("font validated at system_source::load") } - fn cache_id(&self) -> [u64; 2] { - [Arc::as_ptr(&self.bytes) as usize as u64, self.index as u64] + fn face(&self) -> Face<'_> { + Face::parse(self.bytes.as_slice(), self.index as u32) + .expect("face validated at system_source::load") } - fn query_kerning_table(&self, prev: GlyphId, next: GlyphId) -> f32 { - let Ok(face) = Face::parse(self.bytes.as_slice(), self.index as u32) else { - return 0.0; - }; - if let Some(kern) = face.tables().kern { - let kern = kern - .subtables - .into_iter() - .filter(|st| st.horizontal && !st.variable) - .find_map(|st| st.glyphs_kerning(TtfGlyphId(prev), TtfGlyphId(next))) - .unwrap_or(0); - return kern as f32; - } - 0.0 + fn cache_id(&self) -> [u64; 2] { + [self.id, self.index as u64] } } -/// Lazily load font data. Font type doesn't own actual data, which -/// lives in the cache. +fn kerning_units(face: &Face<'_>, prev: GlyphId, next: GlyphId) -> i16 { + let Some(kern) = face.tables().kern else { + return 0; + }; + kern.subtables + .into_iter() + .filter(|st| st.horizontal && !st.variable) + .find_map(|st| st.glyphs_kerning(TtfGlyphId(prev), TtfGlyphId(next))) + .unwrap_or(0) +} + +/// Fetch the font for `(face, style)`, hitting the thread-local cache when +/// possible and falling back to the global byte cache in `system_source`. fn load_font_data(face: FontFamily, style: FontStyle) -> FontResult { let key = cache_key(face, style); - // First, we try to find the font object for current thread - if let Some(font_object) = FONT_OBJECT_CACHE.with(|font_object_cache| { - font_object_cache - .borrow() - .get(Borrow::::borrow(&key)) - .cloned() - }) { - return Ok(font_object); - } - - // Then we need to check if the data cache contains the font data - if let Some(data) = DATA_CACHE - .read() - .map_err(|_| FontError::LockError)? - .get(Borrow::::borrow(&key)) - .cloned() + if let Some(font_object) = + FONT_OBJECT_CACHE.with(|cache| cache.borrow().get(key.as_str()).cloned()) { - let font = FontExt::new(data?)?; - cache_font_object(key, &font); - return Ok(font); + return Ok(font_object); } - let data = system_source::load(face, style); - DATA_CACHE - .write() - .map_err(|_| FontError::LockError)? - .insert(key.clone(), data.clone()); - - let font = FontExt::new(data?)?; - cache_font_object(key, &font); + let data = system_source::load(face, style)?; + let font = FontExt::from_data(data); + FONT_OBJECT_CACHE.with(|cache| { + cache.borrow_mut().insert(key, font.clone()); + }); Ok(font) } @@ -147,12 +119,6 @@ fn cache_key(face: FontFamily<'_>, style: FontStyle) -> String { } } -fn cache_font_object(key: String, font: &FontExt) { - FONT_OBJECT_CACHE.with(|font_object_cache| { - font_object_cache.borrow_mut().insert(key, font.clone()); - }); -} - fn glyph_for_char(charmap: &Charmap<'_>, c: char) -> Option { let glyph_id = charmap.map(c); (glyph_id != 0).then_some(glyph_id) @@ -177,28 +143,28 @@ impl FontData for FontDataInternal { } fn estimate_layout(&self, size: f64, text: &str) -> Result { - let pixel_per_em = size / 1.24; + let pixel_per_em = (size / 1.24) as f32; let font = &self.0; - let font_ref = font.font_ref()?; + let font_ref = font.font_ref(); + let face = font.face(); let metrics = font_ref.metrics(&[]); - let glyph_metrics = font_ref.glyph_metrics(&[]).scale(pixel_per_em as f32); + let glyph_metrics = font_ref.glyph_metrics(&[]).scale(pixel_per_em); let charmap = font_ref.charmap(); let mut x_pixels = 0f32; - let mut prev = None; let place_holder = glyph_for_char(&charmap, PLACEHOLDER_CHAR); for c in text.chars() { if let Some(glyph_id) = glyph_for_char(&charmap, c).or(place_holder) { - x_pixels += glyph_metrics.advance_width(glyph_id); if let Some(pc) = prev { x_pixels += scale_design_units( - font.query_kerning_table(pc, glyph_id), - pixel_per_em as f32, + kerning_units(&face, pc, glyph_id) as f32, + pixel_per_em, metrics.units_per_em, ); } + x_pixels += glyph_metrics.advance_width(glyph_id); prev = Some(glyph_id); } } @@ -217,7 +183,8 @@ impl FontData for FontDataInternal { let mut x = base_x as f32; let font = &self.0; - let font_ref = font.font_ref()?; + let font_ref = font.font_ref(); + let face = font.face(); let metrics = font_ref.metrics(&[]); let glyph_metrics = font_ref.glyph_metrics(&[]).scale(em); let charmap = font_ref.charmap(); @@ -227,8 +194,6 @@ impl FontData for FontDataInternal { let mut prev = None; let place_holder = glyph_for_char(&charmap, PLACEHOLDER_CHAR); - let render_sources = [Source::Outline]; - let draw_result = SCALE_CONTEXT.with(|scale_context| { let mut scale_context = scale_context.borrow_mut(); let mut scaler = scale_context @@ -236,14 +201,14 @@ impl FontData for FontDataInternal { .size(em) .hint(true) .build(); - let mut renderer = Render::new(&render_sources); + let mut renderer = Render::new(&RENDER_SOURCES); renderer.format(Format::Alpha); for c in text.chars() { if let Some(glyph_id) = glyph_for_char(&charmap, c).or(place_holder) { if let Some(pc) = prev { x += scale_design_units( - font.query_kerning_table(pc, glyph_id), + kerning_units(&face, pc, glyph_id) as f32, em, metrics.units_per_em, ); @@ -287,17 +252,13 @@ mod test { #[test] fn test_font_cache() -> FontResult<()> { - // We cannot only check the size of font cache, because - // the test case may be run in parallel. Thus the font cache - // may contains other fonts. - let _a = load_font_data(FontFamily::Serif, FontStyle::Normal)?; - assert!(DATA_CACHE.read().unwrap().contains_key("serif")); - - let _b = load_font_data(FontFamily::Serif, FontStyle::Normal)?; - assert!(DATA_CACHE.read().unwrap().contains_key("serif")); - - // TODO: Check they are the same - + let a = load_font_data(FontFamily::Serif, FontStyle::Normal)?; + let b = load_font_data(FontFamily::Serif, FontStyle::Normal)?; + assert!( + Arc::ptr_eq(&a.bytes, &b.bytes), + "cached loads should share the underlying byte buffer" + ); + assert_eq!(a.id, b.id, "cached loads should share font id"); Ok(()) } @@ -318,6 +279,7 @@ mod test { fn assert_draw_sanity(family: FontFamily<'_>, style: FontStyle) -> FontResult<()> { let size = 32.0; let em = size / 1.24; + let baseline = (size as i32) - (0.24 * em as f32) as i32; let font = FontDataInternal::new(family, style)?; let mut samples = Vec::new(); @@ -362,6 +324,34 @@ mod test { max_y ); + // 'g' descender must land below the baseline; if placement.top were + // added rather than subtracted, every glyph would render above + // baseline and this would fail. + assert!( + max_y > baseline, + "expected 'g' descender below baseline {}: max_y={}", + baseline, + max_y + ); + + // The touched bbox should span roughly one em vertically; this + // guards against placement.top being applied with the wrong scale. + let bbox_height = (max_y - min_y) as f32; + assert!( + (0.5 * em as f32..=1.5 * em as f32).contains(&bbox_height), + "bbox height {} not within [0.5*em, 1.5*em] (em={})", + bbox_height, + em + ); + + // 'g' is the second glyph; it must be drawn well to the right of + // 'H'. Catches a missing advance_width or placement.left bug. + assert!( + max_x > (0.6 * em) as i32, + "expected second glyph drawn after 'H'; max_x={}", + max_x + ); + Ok(()) } } From 75539a00217f9f9fde875f1c16a0ecdc2b8d06de Mon Sep 17 00:00:00 2001 From: Philip Deuchler Date: Sat, 25 Apr 2026 14:35:52 -0600 Subject: [PATCH 7/9] chore: change rasterization baseline constant to maintain existing behavior --- plotters/src/style/font/ttf.rs | 43 +++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/plotters/src/style/font/ttf.rs b/plotters/src/style/font/ttf.rs index 3b309fa1..00548006 100644 --- a/plotters/src/style/font/ttf.rs +++ b/plotters/src/style/font/ttf.rs @@ -189,7 +189,14 @@ impl FontData for FontDataInternal { let glyph_metrics = font_ref.glyph_metrics(&[]).scale(em); let charmap = font_ref.charmap(); - base_y -= (0.24 * em) as i32; + // Place the swash pen at the baseline. font-kit rasterized into a + // `size`-square canvas whose top sat at `pos.y - 0.24*em`, then + // applied a `(0, em)` rasterization translation, putting the + // effective baseline at `pos.y + 0.76*em`. Swash places glyphs + // relative to the pen directly, so we shift the pen to that same + // baseline; otherwise glyphs render ~one em above where callers + // expect them. + base_y += (0.76 * em) as i32; let mut prev = None; let place_holder = glyph_for_char(&charmap, PLACEHOLDER_CHAR); @@ -277,13 +284,15 @@ mod test { } fn assert_draw_sanity(family: FontFamily<'_>, style: FontStyle) -> FontResult<()> { - let size = 32.0; - let em = size / 1.24; - let baseline = (size as i32) - (0.24 * em as f32) as i32; + let size = 32.0_f64; + let em = (size / 1.24) as f32; + let pos_y = size as i32; + // Baseline must match the pen position chosen in `draw`. + let baseline = pos_y + (0.76 * em) as i32; let font = FontDataInternal::new(family, style)?; let mut samples = Vec::new(); - let draw_result = font.draw((0, size as i32), size, "Hg", |x, y, alpha| { + let draw_result = font.draw((0, pos_y), size, "Hg", |x, y, alpha| { samples.push((x, y, alpha)); Ok::<(), ()>(()) })?; @@ -311,16 +320,24 @@ mod test { let min_y = touched.iter().map(|(_, y, _)| *y).min().unwrap(); let max_y = touched.iter().map(|(_, y, _)| *y).max().unwrap(); + // Baseline-anchored bounds. The pen is at output y = `baseline`, + // ascenders extend up by ~em and descenders down by ~0.3*em. assert!(min_x >= 0, "glyphs drifted left: min_x={}", min_x); - assert!(min_y >= 0, "glyphs drifted above canvas: min_y={}", min_y); + assert!( + min_y >= baseline - (1.2 * em) as i32, + "glyphs drifted too high above baseline {}: min_y={}", + baseline, + min_y + ); assert!( max_x <= (3.0 * em) as i32, "glyphs drifted right: max_x={}", max_x ); assert!( - max_y <= (1.5 * em) as i32, - "glyphs drifted below canvas: max_y={}", + max_y <= baseline + (0.6 * em) as i32, + "glyphs drifted too far below baseline {}: max_y={}", + baseline, max_y ); @@ -334,11 +351,19 @@ mod test { max_y ); + // Cap height should sit above the baseline by a meaningful amount. + assert!( + min_y < baseline, + "expected glyph tops above baseline {}: min_y={}", + baseline, + min_y + ); + // The touched bbox should span roughly one em vertically; this // guards against placement.top being applied with the wrong scale. let bbox_height = (max_y - min_y) as f32; assert!( - (0.5 * em as f32..=1.5 * em as f32).contains(&bbox_height), + (0.5 * em..=1.5 * em).contains(&bbox_height), "bbox height {} not within [0.5*em, 1.5*em] (em={})", bbox_height, em From ea68b3ff97bf2decd1dfdad32beb57c13ed9cf4e Mon Sep 17 00:00:00 2001 From: Philip Deuchler Date: Sat, 25 Apr 2026 22:57:08 -0600 Subject: [PATCH 8/9] chore: address clippy comment, add fontconfig install to ubuntu runners --- .github/workflows/plotters-backend.yml | 3 + .github/workflows/plotters-bitmap.yml | 3 + .github/workflows/plotters-core.yml | 81 +++++++++++++++----------- .github/workflows/plotters-svg.yml | 3 + .github/workflows/rust-clippy.yml | 3 + .github/workflows/wasm.yml | 2 + plotters/src/style/font/ttf.rs | 6 +- 7 files changed, 62 insertions(+), 39 deletions(-) diff --git a/.github/workflows/plotters-backend.yml b/.github/workflows/plotters-backend.yml index 556f39b8..6ef1eb62 100644 --- a/.github/workflows/plotters-backend.yml +++ b/.github/workflows/plotters-backend.yml @@ -12,6 +12,9 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + - name: Install fontconfig + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y fontconfig - uses: actions-rs/toolchain@v1 with: toolchain: stable diff --git a/.github/workflows/plotters-bitmap.yml b/.github/workflows/plotters-bitmap.yml index 79b4f259..2ba20d70 100644 --- a/.github/workflows/plotters-bitmap.yml +++ b/.github/workflows/plotters-bitmap.yml @@ -12,6 +12,9 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + - name: Install fontconfig + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y fontconfig - uses: actions-rs/toolchain@v1 with: toolchain: stable diff --git a/.github/workflows/plotters-core.yml b/.github/workflows/plotters-core.yml index 1e9a952c..7f4bb9f0 100644 --- a/.github/workflows/plotters-core.yml +++ b/.github/workflows/plotters-core.yml @@ -8,6 +8,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Install fontconfig + run: sudo apt-get update && sudo apt-get install -y fontconfig - uses: actions-rs/toolchain@v1 with: profile: minimal @@ -20,68 +22,77 @@ jobs: msrv: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: + - uses: actions/checkout@v4 + with: submodules: recursive - - uses: actions-rs/toolchain@v1 - with: - toolchain: 1.56.0 + - name: Install fontconfig + run: sudo apt-get update && sudo apt-get install -y fontconfig + - uses: actions-rs/toolchain@v1 + with: + toolchain: 1.88.0 override: true args: --all-features build_and_test: runs-on: ${{ matrix.os }} strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] steps: - - uses: actions/checkout@v4 - with: + - uses: actions/checkout@v4 + with: submodules: recursive - - uses: actions-rs/toolchain@v1 - with: + - name: Install fontconfig + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y fontconfig + - uses: actions-rs/toolchain@v1 + with: toolchain: stable override: true - - uses: actions-rs/cargo@v1 - with: + - uses: actions-rs/cargo@v1 + with: command: test args: --verbose - - uses: actions-rs/cargo@v1 - with: + - uses: actions-rs/cargo@v1 + with: command: test args: --verbose --no-default-features --features=svg_backend --lib test_all_features: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: + - uses: actions/checkout@v4 + with: submodules: recursive - - uses: actions-rs/toolchain@v1 - with: + - name: Install fontconfig + run: sudo apt-get update && sudo apt-get install -y fontconfig + - uses: actions-rs/toolchain@v1 + with: toolchain: stable override: true - - uses: actions-rs/cargo@v1 - with: + - uses: actions-rs/cargo@v1 + with: command: test args: --verbose --all-features run_all_examples: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: + - uses: actions/checkout@v4 + with: submodules: recursive - - uses: actions-rs/cargo@v1 - with: + - name: Install fontconfig + run: sudo apt-get update && sudo apt-get install -y fontconfig + - uses: actions-rs/cargo@v1 + with: command: build args: --verbose --release --examples - - name: Run all the examples - run: | - cd plotters - for example in examples/*.rs - do - ../target/release/examples/$(basename ${example} .rs) - done - tar -czvf example-outputs.tar.gz plotters-doc-data - - uses: actions/upload-artifact@v4 - with: + - name: Run all the examples + run: | + cd plotters + for example in examples/*.rs + do + ../target/release/examples/$(basename ${example} .rs) + done + tar -czvf example-outputs.tar.gz plotters-doc-data + - uses: actions/upload-artifact@v4 + with: name: example-outputs path: plotters/example-outputs.tar.gz diff --git a/.github/workflows/plotters-svg.yml b/.github/workflows/plotters-svg.yml index d44a400c..1d9c0052 100644 --- a/.github/workflows/plotters-svg.yml +++ b/.github/workflows/plotters-svg.yml @@ -12,6 +12,9 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + - name: Install fontconfig + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y fontconfig - uses: actions-rs/toolchain@v1 with: toolchain: stable diff --git a/.github/workflows/rust-clippy.yml b/.github/workflows/rust-clippy.yml index 8536ab98..e29ffe20 100644 --- a/.github/workflows/rust-clippy.yml +++ b/.github/workflows/rust-clippy.yml @@ -29,6 +29,9 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Install fontconfig + run: sudo apt-get update && sudo apt-get install -y fontconfig + - name: Install Rust toolchain uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1 with: diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index e9ae4ad3..ed6e07ea 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -11,6 +11,8 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + - name: Install fontconfig + run: sudo apt-get update && sudo apt-get install -y fontconfig - name: Install WASM tool chain run: rustup target add wasm32-unknown-unknown - name: Check WASM Target Compiles diff --git a/plotters/src/style/font/ttf.rs b/plotters/src/style/font/ttf.rs index 00548006..3f3bf922 100644 --- a/plotters/src/style/font/ttf.rs +++ b/plotters/src/style/font/ttf.rs @@ -230,13 +230,11 @@ impl FontData for FontDataInternal { for dy in 0..height { for dx in 0..width { let alpha = image.data[dy * width + dx] as f32 / 255.0; - if let Err(e) = draw( + draw( base_x + image.placement.left + dx as i32, base_y - image.placement.top + dy as i32, alpha, - ) { - return Err(e); - } + )? } } } From d8c958ef4b900c8bfcb8fdd94fc8c7bc22d7046b Mon Sep 17 00:00:00 2001 From: Philip Deuchler Date: Sat, 25 Apr 2026 23:00:35 -0600 Subject: [PATCH 9/9] chore: install libfontconfig-dev instead --- .github/workflows/plotters-backend.yml | 2 +- .github/workflows/plotters-bitmap.yml | 2 +- .github/workflows/plotters-core.yml | 10 +++++----- .github/workflows/plotters-svg.yml | 2 +- .github/workflows/rust-clippy.yml | 2 +- .github/workflows/wasm.yml | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/plotters-backend.yml b/.github/workflows/plotters-backend.yml index 6ef1eb62..d5b2798c 100644 --- a/.github/workflows/plotters-backend.yml +++ b/.github/workflows/plotters-backend.yml @@ -14,7 +14,7 @@ jobs: submodules: recursive - name: Install fontconfig if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y fontconfig + run: sudo apt-get update && sudo apt-get install -y libfontconfig-dev - uses: actions-rs/toolchain@v1 with: toolchain: stable diff --git a/.github/workflows/plotters-bitmap.yml b/.github/workflows/plotters-bitmap.yml index 2ba20d70..b6569d50 100644 --- a/.github/workflows/plotters-bitmap.yml +++ b/.github/workflows/plotters-bitmap.yml @@ -14,7 +14,7 @@ jobs: submodules: recursive - name: Install fontconfig if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y fontconfig + run: sudo apt-get update && sudo apt-get install -y libfontconfig-dev - uses: actions-rs/toolchain@v1 with: toolchain: stable diff --git a/.github/workflows/plotters-core.yml b/.github/workflows/plotters-core.yml index 7f4bb9f0..e804c432 100644 --- a/.github/workflows/plotters-core.yml +++ b/.github/workflows/plotters-core.yml @@ -9,7 +9,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Install fontconfig - run: sudo apt-get update && sudo apt-get install -y fontconfig + run: sudo apt-get update && sudo apt-get install -y libfontconfig-dev - uses: actions-rs/toolchain@v1 with: profile: minimal @@ -26,7 +26,7 @@ jobs: with: submodules: recursive - name: Install fontconfig - run: sudo apt-get update && sudo apt-get install -y fontconfig + run: sudo apt-get update && sudo apt-get install -y libfontconfig-dev - uses: actions-rs/toolchain@v1 with: toolchain: 1.88.0 @@ -43,7 +43,7 @@ jobs: submodules: recursive - name: Install fontconfig if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y fontconfig + run: sudo apt-get update && sudo apt-get install -y libfontconfig-dev - uses: actions-rs/toolchain@v1 with: toolchain: stable @@ -63,7 +63,7 @@ jobs: with: submodules: recursive - name: Install fontconfig - run: sudo apt-get update && sudo apt-get install -y fontconfig + run: sudo apt-get update && sudo apt-get install -y libfontconfig-dev - uses: actions-rs/toolchain@v1 with: toolchain: stable @@ -79,7 +79,7 @@ jobs: with: submodules: recursive - name: Install fontconfig - run: sudo apt-get update && sudo apt-get install -y fontconfig + run: sudo apt-get update && sudo apt-get install -y libfontconfig-dev - uses: actions-rs/cargo@v1 with: command: build diff --git a/.github/workflows/plotters-svg.yml b/.github/workflows/plotters-svg.yml index 1d9c0052..e6e53d7a 100644 --- a/.github/workflows/plotters-svg.yml +++ b/.github/workflows/plotters-svg.yml @@ -14,7 +14,7 @@ jobs: submodules: recursive - name: Install fontconfig if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y fontconfig + run: sudo apt-get update && sudo apt-get install -y libfontconfig-dev - uses: actions-rs/toolchain@v1 with: toolchain: stable diff --git a/.github/workflows/rust-clippy.yml b/.github/workflows/rust-clippy.yml index e29ffe20..13469e44 100644 --- a/.github/workflows/rust-clippy.yml +++ b/.github/workflows/rust-clippy.yml @@ -30,7 +30,7 @@ jobs: uses: actions/checkout@v4 - name: Install fontconfig - run: sudo apt-get update && sudo apt-get install -y fontconfig + run: sudo apt-get update && sudo apt-get install -y libfontconfig-dev - name: Install Rust toolchain uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af #@v1 diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index ed6e07ea..07648b58 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -12,7 +12,7 @@ jobs: with: submodules: recursive - name: Install fontconfig - run: sudo apt-get update && sudo apt-get install -y fontconfig + run: sudo apt-get update && sudo apt-get install -y libfontconfig-dev - name: Install WASM tool chain run: rustup target add wasm32-unknown-unknown - name: Check WASM Target Compiles