Skip to content

Refactor font rendering pipeline, remove font-kit and pathfinder#736

Open
pbdeuchler wants to merge 23 commits intoplotters-rs:masterfrom
pbdeuchler:pbd/migrate_to_harfrust
Open

Refactor font rendering pipeline, remove font-kit and pathfinder#736
pbdeuchler wants to merge 23 commits intoplotters-rs:masterfrom
pbdeuchler:pbd/migrate_to_harfrust

Conversation

@pbdeuchler
Copy link
Copy Markdown
Contributor

@pbdeuchler pbdeuchler commented Apr 29, 2026

Alternative PR to #735

Font rendering pipeline changes

Per discussion here, with the removal of font-kit and pathfinder we are now able to unify the default font rendering pipeline into a single code path. Users can now determine whether or not system fonts are used programmatically as part of the API instead of using build flags. The biggest change outside of the API surface is that font resolution and rasterization is deferred until the chart's draw call actually runs.

The new API creates a FontContext struct that is created and scoped per DrawingArea. This FontContext struct gates what fonts are available for draw calls. Users may manipulate the FontContext by calling the new DrawingArea::with_fonts method. This call differs only slightly from the existing register_font call in that it takes multiple fonts and relaxes the static requirement on the font bytes by allowing Arc<[u8]>. This obviously creates some allocations but I'm not sure that's a concern here. Happy to change that back or into something different if we'd prefer a different performance profile.

Deprecation of ab_glyph feature/functionality

With this change we can deprecate the ab_glyph feature and register_font functionality. The only thing the ab_glyph feature enables now is:

  1. The register_font symbol and its global registry exist.
  2. The default FontContext is built with enable_system: false.

I've done my best to integrate the existing behavior into the new pipeline however due to the way font caching works now register_font is much more stateful than before. There is a potential footgun when using both register_font and with_fonts where register_font can override the global state and unintentionally change a specific DrawingArea's FontContext, however this behavior works in an obvious imperative way so I'm hoping it's not a big issue.

In order to make register_font work nicely with the new caching there are some performance changes where on every text render call we now:

  • Walk explicit Vec (O(N) where N is small, typically ≤ 5)
  • Maybe clone the registry Vec and walk it (using ab_glyph)
  • Maybe one fontique system query (cached internally by fontique's Collection + SourceCache)
  • Hash the bytes and look up GLOBAL_PARSED (typical case: hit, returns cached Arc)

Parsing is still amortized and even for programs where users are using hundreds of fonts with hundreds of charts there should be a negligible difference, however anything rendering dynamically changing charts at scale will definitely notice something.

Removal of font-kit and pathfinder

This PR also removes font-kit and pathfinder in favor of harfrust, skrifa, and zeno. It also creates a FontEngine abstraction so this trio of dependencies can easily be swapped out in the future as things change (or to swash if we want). This is probably the most straightforward change.

There is a potential regression since register_font takes byte blobs where existing code that is compatible with ab_glyph fails with harfrust. However given that harfrust is a Google sanctioned port of harfbuzz, the industry gold standard of font parsing, if there are regressions they are probably edge cases that shouldn't exist in the first place.

We also bump MSRV to 1.88.0.

…rifa/zeno

The legacy native text path carried two parallel backends -- font-kit + pathfinder behind the `ttf` feature, ab_glyph behind the `ab_glyph` feature -- selected by a cfg cascade in style/font/mod.rs and resolved fonts eagerly at FontDesc construction. That coupling makes it hard to scope fonts to a chart, hard to ship in-memory fonts without a process-global registry, and hard to evolve the rasterizer without churning the FontDesc surface.

Replace the native pipeline with a single linebender stack: fontique for selection, harfrust for shaping, skrifa for outlines, zeno for rasterization. The wasm text path is untouched because the browser still owns text rendering there.

Hide the new stack behind a small two-trait abstraction (FontEngine + ParsedFont) in style/font/engine.rs so a future swap to swash or another shaper is a single-file change. The harfrust+skrifa+zeno crates only appear in style/font/harfrust_engine.rs.

Move font resolution off FontDesc into a FontContext that lives per-DrawingArea, with a thread-local stack pushed and popped via an RAII guard around element draws so backends keep BackendTextStyle::draw and layout_box unchanged. Resolution becomes lazy: nothing happens until layout_box or draw runs against the active context. A process-global parsed-font intern keyed by (bytes-hash, len, index) deduplicates parse work across contexts.

Add DrawingArea::with_fonts and FontContextBuilder so callers can attach in-memory fonts to a chart and optionally disable system lookup, while the legacy register_font surface is preserved as a compatibility shim under the ab_glyph feature.

Ship a small OFL-licensed TTF fixture so the new layer can be tested without depending on host-installed fonts. Cover engine roundtrip, explicit-context resolution, the global parse intern, and TLS guard cleanup under panic.
The unit tests in style/font/* exercise the engine and context internals, but they do not prove the new pipeline survives the path the rest of the crate actually uses: DrawingArea -> backend -> BackendTextStyle::draw -> FontContext. Only an integration suite catches breakage in the TLS push/pop, sub-area context inheritance, or the legacy register_font interop.

Add tests/font_migration.rs driving BitMapBackend with the bundled TTF fixture so the suite is host-font-independent. Cover with_fonts rendering, explicit-context isolation from a default DrawingArea, sub-area context inheritance and override behavior, and concurrent text drawing across threads with separate contexts.

Under the ab_glyph compatibility feature, additionally verify that register_font called after DrawingArea construction still reaches the default context, that an explicit context can opt into the legacy registry via include_registered, and that with_fonts contexts stay isolated from the registry. These cases lock down the precedence rules called out in docs/font-pipeline-migration.md so the legacy contract cannot regress silently.
Now that style/font/mod.rs always routes native text through FontContext + the harfrust/skrifa/zeno engine, the parallel ttf, ab_glyph, and naive backend modules are unreachable. Leaving them in tree pulls font-kit, pathfinder_geometry, ttf-parser, lazy_static, and the ab_glyph crate into every native build for no runtime benefit, and slows compilation by keeping the cfg cascade in mod.rs alive.

Delete the dead modules and remove the now-unused dependencies from plotters/Cargo.toml. The `ttf`, `system_fonts`, and `fontconfig-dlopen` features go away with them; `ab_glyph` is kept as an empty feature shim that gates register_font and the include_registered builder option.

Re-export FontContext and FontContextBuilder from the prelude so the new per-area font configuration API is reachable through the same import as the rest of the crate.

Refresh README.md, the readme template, and the crate-level rustdoc to drop the old ttf/font-kit/rusttype language and describe ab_glyph as a register_font compatibility shim rather than a separate rasterizer. Update the dev-dependencies in plotters-bitmap/Cargo.toml and plotters-svg/Cargo.toml that previously selected the deleted `ttf` feature.

Tighten parse_cached so two threads racing the same uncached fingerprint converge on a single interned parsed instance instead of one of them inserting on top of the other's Weak.
When the legacy native backends were removed in the previous commit, register_font's return type InvalidFont stopped being publicly nameable -- the type is still defined in style/font/migration.rs but the path to it through style/font/mod.rs and style/mod.rs only carried `register_font`, not `InvalidFont`. Downstream callers that destructure or annotate the error (`.unwrap_or_else(|e: InvalidFont| ...)`, `Result<(), InvalidFont>`) would fail to compile against the migrated crate even though the function itself is still exported.

Re-export InvalidFont in the same cfg-gated `pub use` block as register_font so the legacy public API surface is fully usable for source-compatible upgrades. The type itself is unchanged; this is purely a path fix.
… regressed tests, code quality nits, rework caching so that register_font always takes effect at slight performance penalty
@pbdeuchler
Copy link
Copy Markdown
Contributor Author

pbdeuchler commented Apr 29, 2026

@philocalyst let me know your thoughts on this one... completely open to feedback, i'm not super attached to how I implemented things here but I think it's the least worst option. I might still refine this a bit and have some more manual testing to do but let me know your initial thoughts.

The new fontique-backed font pipeline links against fontconfig on Linux, so every CI job that compiles or runs plotters needs libfontconfig-dev available before the toolchain step. Without it, fontique fails to build the system fonts source on Ubuntu runners.

Add the apt-get install step to plotters-backend.yml, plotters-bitmap.yml, plotters-core.yml, plotters-svg.yml, rust-clippy.yml, and wasm.yml. Gate it behind 'if: runner.os == "Linux"' on the multi-OS matrix jobs so Windows and macOS runners skip it.

In plotters-core.yml, also bump the MSRV job from 1.56.0 to 1.88.0 (the new font crates require it) and normalize indentation across the file so the YAML structure is consistent.

Carried over from pbd/remove-pathfinder-geometry, where these workflow tweaks were authored alongside an alternate fontique migration.
@pbdeuchler
Copy link
Copy Markdown
Contributor Author

Hmmmm, I'll take a look at those CI failures in a bit. I think I know what's happening.

After with_font_context was removed, FontContext::builder() built an Arc<FontContext> that had no public consumer -- there was no way to attach it to a DrawingArea, and the methods on FontContext (layout_box, draw, current_or_default) were already pub(crate). The type and builder were public dead weight that suggested a richer API than actually existed.

Lower FontContext, FontContextBuilder, system_default(), builder(), with_font(), include_registered(), and build() to pub(crate). Drop the public re-exports from style/font/mod.rs, style/mod.rs, and the prelude in lib.rs. Gate disable_system_fonts behind #[cfg(test)] since it's only used by unit tests now.

Add plotters/examples/dynamic_font.rs to demonstrate the remaining public path: download Roboto from the Google Fonts css2 endpoint at runtime, parse the @font-face blocks for the Regular and Bold .ttf URLs, fetch the bytes, and attach them to a chart via DrawingArea::with_fonts. The example uses a Wget user-agent to coax Google Fonts into serving raw TrueType -- modern user-agents receive WOFF2, which harfrust and skrifa cannot read directly.

Add ureq as a non-wasm dev-dependency for the example's HTTP fetches.

Update README.md, doc-template/readme.template.md, and the crate rustdoc preamble to drop the FontContext::builder() reference and link to the new example.
zeno does honest analytical anti-aliasing, but on its own it produces noticeably chunkier text than the previous font-kit path. Two structural reasons combined:

1. We passed `DrawSettings::unhinted` to skrifa, so glyph stems landed wherever the outline put them in floating-point pixel space. font-kit on master had the same flag but its underlying rasterizers (FreeType on Linux, Core Graphics on macOS) apply auto-hinting and stem-darkening regardless. We were missing both.

2. `context::draw` rounded each glyph's advance to integer pixel coordinates before placing the rasterized mask. harfrust's sub-pixel kerning was computed and then thrown away, so spacing collapsed to whole-pixel grid steps.

Add a `HintingInstance` per (parsed font, size) inside `HarfrustFont`, cached behind a `Mutex<HashMap<u32, Arc<HintingInstance>>>`. `HintingInstance::new` traces the font's bytecode interpreter and is far too expensive to run per glyph; the cache is keyed by `size_px.to_bits()` so the typical chart hits it after the first label. Switch `glyph.draw` to `DrawSettings::hinted(&instance, false)` with the default `SmoothMode::Normal` target -- the same mode FreeType's smooth-rendering path uses, which is what the old pipeline effectively had.

Extend the `ParsedFont::rasterize` trait signature to accept a sub-pixel offset `(sx, sy)` in `[0, 1)`. The HarfrustFont implementation folds the offset into the path coordinates inside `ZenoPen` (so zeno's analytical AA accounts for the fractional position rather than rounding it away), and then `Mask::new(&path).render()` returns placement data that's already integer-aligned for the caller.

In `context::draw`, split each glyph's `f32` position into an integer pixel and a quantized sub-pixel index via a new `split_subpixel` helper. Quantize to 4 levels per axis (FreeType's default, the perceptual sweet spot) and route the quanta through `rasterize_cached`. The cache key gains `sx_quantum` and `sy_quantum`, growing the key space by ~16x; in practice the hot working set is still tiny (chart text reuses a small glyph repertoire).

Cover the new behavior with two unit tests: that a half-pixel horizontal offset measurably changes the rasterized mask, and that `split_subpixel` rounds correctly across pixel boundaries including negative coordinates.

Visually, this brings small text (10-14pt chart labels) much closer to the master branch's appearance -- stems are crisp, kerning preserves sub-pixel spacing, and the y-axis description label stops looking pixely.
…pulled in a later version of the image crate
end-to-end example that downloads Roboto from Google Fonts at runtime and
attaches it to a chart through the new pipeline.

| Name | Description | Additional Dependency | Default? |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ab_glyph doesn't add the ab_glyph dep?

Copy link
Copy Markdown
Contributor Author

@pbdeuchler pbdeuchler May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not anymore, it all goes through the same pipeline. ab_glyph just turns off system font discovery now.

Comment thread plotters/examples/dynamic_font.rs Outdated
?family=Kablammo\
&family=Roboto:ital,wght@0,100..900;1,100..900\
&display=swap";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would rather just host the fonts in-tree, to reduce this logic and avoid the issues when polling against an external service.

fn rasterize(
&self,
glyph_id: u32,
size_px: f32,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is size_px for? Feel like the name is washy

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pixel size for the font, for calculating scale and passing to the harfrust shaper

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking but I was hinting that it could be renamed for clarity -- font_size_px maybe?

sy: subpixel.1,
},
)
.map(|_| ())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can remove the map (_) call and use ? at the end of the map_err

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compiler complains about this, can't infer the types

Comment thread plotters/src/style/font/harfrust_engine.rs
Comment thread plotters/src/style/font/harfrust_engine.rs Outdated
.map_err(|_| FontError::InvalidFontIndex(self.index))
}

fn hinter_for(&self, size_px: f32) -> Result<Option<Arc<HintingInstance>>, FontError> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general find the result<option<>> pattern needless -- anyway we could compress to just a Result<>? Would appreciate a comment inline if you do feel it is needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I flattened this to an Option<> instead since we want to throw away the error anyway and draw an unhinted glyph if this fails. Happy to make it a Result with an error log or something if we want though

@philocalyst
Copy link
Copy Markdown

Do you plan to fix the remaining CI errors? The tests and error variants feel solid to me, just some readability and semantic issues core to the logic that I would prefer to be untangled.

I'd also like to mark somewhere that when Glifio stabilizes, to switch off of zero for it.

That should solidify this stack for a while yet.

@philocalyst
Copy link
Copy Markdown

Not a blocker for a merge, but if you'd like bonus points, pioneering screenshot integration testing here would be a great way to see how (if at all) this changes text outputs, and ensure future changes don't ruin this.

@pbdeuchler
Copy link
Copy Markdown
Contributor Author

@philocalyst I appreciate the review! working on fixing those CI issues and will address these comments as well

@pbdeuchler
Copy link
Copy Markdown
Contributor Author

pbdeuchler commented Apr 30, 2026

I do want to say that right now i'm not super happy with the kerning and rendering on this branch. Here's some screenshots for comparison, let me know your thoughts:

master

master - title master branch date

swash branch

swash branch - title swash branch date

skifra/zeno branch (this PR)

skifra:zeno title skifra:zeno date

@pbdeuchler
Copy link
Copy Markdown
Contributor Author

pbdeuchler commented Apr 30, 2026

master

sample

sample

skifra/zeno branch (This PR)

relative_size sample

@philocalyst
Copy link
Copy Markdown

Yeah that's a little annoying -- that's something handled by Harfrust as well :(

pbdeuchler and others added 6 commits April 30, 2026 19:40
Co-authored-by: Miles Wirht <114884788+philocalyst@users.noreply.github.com>
Co-authored-by: Miles Wirht <114884788+philocalyst@users.noreply.github.com>
… font-kit. minor compilation/test fixes. fix up features so that we don't pull in ab_glyph in tests
@pbdeuchler
Copy link
Copy Markdown
Contributor Author

I'd also like to mark somewhere that when Glifio stabilizes, to switch off of zero for it.

https://github.com/plotters-rs/plotters/pull/736/changes#diff-f071a3c388257ffa578325cc55317265fa82ec7198a05e2167f9a570520df378R10

@philocalyst I believe I addressed everything, ready for re-review

`SeriesLabelStyle::draw` calls `MultiLineText::estimate_dimension` and
`compute_line_layout` directly rather than through `backend_ops`, so
fonts registered via `DrawingArea::with_fonts` were invisible to those
layout passes and `--all-features` builds failed to resolve their own
explicit fonts. Mirror what `backend_ops` already does and push the
plotting area's font context onto the thread-local stack for the
duration of the legend draw.
Builds a dark-background line plot that exercises a wider slice of the
chart API in a single image: dashed extrapolation, dashed/dotted
vertical reference lines, per-point value labels, an in-plot summary
panel, and a styled `configure_series_labels` legend. Uses bundled
Roboto via `with_fonts(...)` so the example works without depending on
the host's installed fonts.
fn rasterize(
&self,
glyph_id: u32,
size_px: f32,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking but I was hinting that it could be renamed for clarity -- font_size_px maybe?

Comment on lines +36 to +43
HarfrustEngine
.parse(data.clone(), 0)
.map_err(|_| InvalidFont { _priv: () })?;

REGISTERED_FONTS
.lock()
.map_err(|_| InvalidFont { _priv: () })?
.push(registered_font(name, style, data));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're dropping the original errors here which feels unwise for something that interacts with foreign data, would prefer to be as helpful as possible (when) bad things happen.

Comment thread plotters/src/style/font/system.rs Outdated
Comment on lines +220 to +221
sx: f32,
sy: f32,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sx: f32,
sy: f32,
subpixel_offset: Vector2F,


fn draw_unhinted_glyph(
glyph: &OutlineGlyph<'_>,
size_px: f32,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as prior

fn draw_unhinted_glyph(
glyph: &OutlineGlyph<'_>,
size_px: f32,
subpixel: (f32, f32),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would like to mirror the x/y logic from below

Comment on lines +52 to +79
pub enum FontError {
/// The font bytes could not be parsed.
InvalidFontData(String),
/// The requested font collection index does not exist.
InvalidFontIndex(u32),
/// The requested font family and style are not available in the active context.
NotInContext {
/// The requested family name.
family: String,
/// The requested style name.
style: String,
},
/// The request could only be satisfied by system fonts, but system lookup is disabled.
SystemFontsDisabled {
/// The requested family name.
family: String,
},
/// A candidate font could not be loaded.
FontUnavailable {
/// The requested family name.
family: String,
/// The requested style name.
style: String,
},
/// A glyph outline could not be converted into a coverage mask.
RasterizeError(String),
/// Internal font state could not be locked.
LockError,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great error cases here :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lol once I started enumerating all of them I figured I might as well be exhaustive once I got 80% of the way there

Comment thread plotters/src/style/font/context.rs
text: &str,
(base_x, base_y): (i32, i32),
mut draw: DrawFunc,
) -> FontResult<Result<(), E>> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we not simplify this to just FontResult or Result? This is another anti-pattern imo.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wups sorry I think this is LLM slop that I didn't catch

&self,
glyph_id: u32,
size_px: f32,
subpixel: (f32, f32),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same Vecto2f here

@philocalyst
Copy link
Copy Markdown

A lot cleaner this time around, feeling good about this PR!

Have no complaints after these (small) issues are handled. You over-comment in some places but it's not a point to press. Just wish that some of that explaining was moved to the complex or external stuff (Like the Zeno impls with the heavily abbreviated params).

Testing looks green and good. Does make me think we need some kind of reporting, the lack of a warn in some of the fallbacks could lead to some annoying debugging for users.

Would love keep seeing contributions from you :)

Co-authored-by: Miles Wirht <114884788+philocalyst@users.noreply.github.com>
@pbdeuchler
Copy link
Copy Markdown
Contributor Author

Ha, you're not the first person to say that about my comments. TBH a big reason the more complex stuff isn't commented is because I still don't have a great grasp on what a lot of that is doing, it was mostly trial and error/copy pasting/Claude that got the rasterization and calculation bits working. Slowly figuring it out though.

I found another edge case with series labels last night, so I created a complex example chart to help test things and fixed the bug in the least hacky way I could. I'm still not happy with the kerning and rasterization, but I figure once we can get this merged I can start to experiment with other feature gated engines that might emphasize font quality.

I'm a bit busy today but will try and address your comments soon. Happy to help keep improving plotters, I use it extensively so there's a couple other things i'd like to fix if I can find the time.

@pbdeuchler pbdeuchler changed the title Refactor fond rendering pipeline, remove font-kit and pathfinder Refactor font rendering pipeline, remove font-kit and pathfinder May 1, 2026
Co-authored-by: Miles Wirht <114884788+philocalyst@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants