diff --git a/.github/workflows/plotters-backend.yml b/.github/workflows/plotters-backend.yml index 556f39b8..d5b2798c 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 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 79b4f259..b6569d50 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 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 1e9a952c..e804c432 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 libfontconfig-dev - 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 libfontconfig-dev + - 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 libfontconfig-dev + - 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 libfontconfig-dev + - 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 libfontconfig-dev + - 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..e6e53d7a 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 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 8536ab98..13469e44 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 libfontconfig-dev + - 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..07648b58 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 libfontconfig-dev - name: Install WASM tool chain run: rustup target add wasm32-unknown-unknown - name: Check WASM Target Compiles diff --git a/CHANGELOG.md b/CHANGELOG.md index 664ba133..67a2eb47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # 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. +- 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 + +- 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..5b01b69e 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -2,6 +2,38 @@ 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. + +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/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-svg/src/svg.rs b/plotters-svg/src/svg.rs index e24623d7..adc6a478 100644 --- a/plotters-svg/src/svg.rs +++ b/plotters-svg/src/svg.rs @@ -602,7 +602,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), diff --git a/plotters/Cargo.toml b/plotters/Cargo.toml index 848bb151..d51ca2b8 100644 --- a/plotters/Cargo.toml +++ b/plotters/Cargo.toml @@ -40,8 +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 } -pathfinder_geometry = { version = "0.5.1", optional = true } -font-kit = { version = "0.14.2", optional = true } +fontique = { version = "0.9.0", optional = true } +swash = { version = "0.2.7", optional = true } ab_glyph = { version = "0.2.12", optional = true } once_cell = { version = "1.8.0", optional = true } @@ -105,10 +105,15 @@ point_series = [] surface_series = [] # Font implementation -ttf = ["font-kit", "ttf-parser", "lazy_static", "pathfinder_geometry"] +ttf = [ + "fontique", + "swash", + "ttf-parser", + "lazy_static", +] # 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 = ["fontique/fontconfig-dlopen"] ab_glyph = ["dep:ab_glyph", "once_cell"] @@ -143,4 +148,3 @@ path = "benches/main.rs" [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "doc_cfg"] - 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..4697b3e9 100644 --- a/plotters/src/style/font/naive.rs +++ b/plotters/src/style/font/naive.rs @@ -25,8 +25,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(( diff --git a/plotters/src/style/font/system_source.rs b/plotters/src/style/font/system_source.rs new file mode 100644 index 00000000..a136943b --- /dev/null +++ b/plotters/src/style/font/system_source.rs @@ -0,0 +1,214 @@ +//! 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::{ + Attributes, Collection, CollectionOptions, FontStyle as FontiqueStyle, FontWeight, FontWidth, + 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! { + 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(); + } + + // Resolve outside the write lock so concurrent loads of *different* + // fonts do not serialize on font I/O. + let loaded = load_uncached(family, style); + + // Take the write lock and re-check: if another thread inserted while we + // were loading, prefer its entry so the cache stays canonical and + // `Arc::ptr_eq` holds across repeated loads of the same key. + let mut cache = BYTE_CACHE.write().map_err(|_| FontError::LockError)?; + cache.entry(key).or_insert(loaded).clone() +} + +#[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 { + // 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| { + // peniko::Blob is internally reference-counted; clone is cheap. + hit = Some((font.blob.clone(), font.index)); + QueryStatus::Stop + }); + } + + 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 { + 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), + QueryFamily::Generic(GenericFamily::SansSerif), + ], + } +} + +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 + ); + assert!(font.id > 0, "case {} got an unallocated font id", name); + } + + Ok(()) + } + + #[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 1f7b5037..3f3bf922 100644 --- a/plotters/src/style/font/ttf.rs +++ b/plotters/src/style/font/ttf.rs @@ -1,25 +1,18 @@ -use std::borrow::{Borrow, Cow}; +#[path = "system_source.rs"] +mod system_source; + 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 std::sync::Arc; -use ttf_parser::{Face, GlyphId}; +use swash::{ + scale::{Render, ScaleContext, Source}, + zeno::Format, + Charmap, FontRef, 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}; @@ -29,173 +22,114 @@ 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), } } } impl std::error::Error for FontError {} -lazy_static! { - 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 = '�'; +/// 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 { - inner: Font, - face: Option>, -} - -impl Drop for FontExt { - fn drop(&mut self) { - // We should make sure the face object dead first - self.face.take(); - } + bytes: Arc>, + index: usize, + id: u64, } 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 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; - } + fn from_data(data: SystemFontData) -> Self { + Self { + bytes: data.bytes, + index: data.index, + id: data.id, } - 0.0 } -} -impl std::ops::Deref for FontExt { - type Target = Font; - fn deref(&self) -> &Font { - &self.inner + fn font_ref(&self) -> FontRef<'_> { + FontRef::from_index(self.bytes.as_slice(), self.index) + .expect("font validated at system_source::load") } -} -/// 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())), - }; - - // 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); + fn face(&self) -> Face<'_> { + Face::parse(self.bytes.as_slice(), self.index as u32) + .expect("face validated at system_source::load") } - // 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)??; + fn cache_id(&self) -> [u64; 2] { + [self.id, self.index as u64] } - 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()), +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) +} - let make_not_found_error = - || FontError::NoSuchFont(face.as_str().to_owned(), style.as_str().to_owned()); +/// 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); - if let Ok(handle) = FONT_SOURCE - .with(|source| source.select_best_match(&[family, FamilyName::SansSerif], &properties)) + if let Some(font_object) = + FONT_OBJECT_CACHE.with(|cache| cache.borrow().get(key.as_str()).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())), - }; - - if should_cache { - DATA_CACHE - .write() - .map_err(|_| FontError::LockError)? - .insert(key.clone().into_owned(), data); - } + return Ok(font_object); + } - 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 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) +} - 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 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)] @@ -209,31 +143,32 @@ impl FontData for FontDataInternal { } fn estimate_layout(&self, size: f64, text: &str) -> Result { + let pixel_per_em = (size / 1.24) as f32; let font = &self.0; - let pixel_per_em = size / 1.24; - let metrics = font.metrics(); - - let font = &self.0; - - let mut x_in_unit = 0f32; + 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); + let charmap = font_ref.charmap(); + 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) { if let Some(pc) = prev { - x_in_unit += font.query_kerning_table(pc, glyph_id); + x_pixels += scale_design_units( + 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); } } - 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))) } @@ -248,56 +183,70 @@ 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; - - base_y -= (0.24 * em) as i32; + 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(); + + // 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 = 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 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( + kerning_units(&face, pc, glyph_id) as f32, + 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; + draw( + base_x + image.placement.left + dx as i32, + base_y - image.placement.top + dy as i32, + alpha, + )? + } } } - } - 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) } } @@ -308,16 +257,123 @@ 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 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(()) + } - let _b = load_font_data(FontFamily::Serif, FontStyle::Normal)?; - assert!(DATA_CACHE.read().unwrap().contains_key("serif")); + #[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)?; + } - // TODO: Check they are the same + Ok(()) + } + + fn assert_draw_sanity(family: FontFamily<'_>, style: FontStyle) -> FontResult<()> { + 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, pos_y), 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(); + + // 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 >= 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 <= baseline + (0.6 * em) as i32, + "glyphs drifted too far below baseline {}: max_y={}", + baseline, + 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 + ); + + // 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..=1.5 * em).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(()) }