glifo: Generalize scaling absorption mechanism (#1553)
This was very painful to debug. 😓 This PR is spun off from my
work-in-progress glifo migration. Since we currently don't have this
hooked up, the changes aren't actually tested in this PR. However, they
are tested in my branch, and as you can see, I added a new test that
tests different interactions between font size, run transform and glyph
transform, and they all render correctly and consistently across outline
glyphs, bitmap glyphs and COLR glyphs (on current main, many of those
actually don't work correctly!):
https://github.com/linebender/vello/commit/f53b1ec200f4e6dc3162326787aef365e4790969#diff-1bae2aabd6545e6410d0d5470e19fda8558156dada5c92ffd1c80fb9e63d2994
I also created a new `util` module and put some of the extension traits
there.
Therefore, I hope from a correctness point of view there shouldn't be
any issues here. I tried my best to add detailed comments explaining my
thinking behind this design.
---------
Co-authored-by: Laurenz Stampfl <laurenz@canva.com>
Co-authored-by: Conor Simmonds <conor@canva.com>
diff --git a/glifo/src/colr.rs b/glifo/src/colr.rs
index 3988241..1b0c9a4 100644
--- a/glifo/src/colr.rs
+++ b/glifo/src/colr.rs
@@ -8,8 +8,8 @@
use crate::color::{AlphaColor, DynamicColor};
use crate::glyph::{GlyphColr, OutlinePath};
use crate::kurbo::{Affine, BezPath, Point, Rect, Shape};
-use crate::math::FloatExt;
use crate::peniko::{self, BlendMode, ColorStops, Compose, Extend, Gradient, Mix};
+use crate::util::FloatExt;
use alloc::vec;
use alloc::vec::Vec;
use core::fmt::Debug;
diff --git a/glifo/src/glyph.rs b/glifo/src/glyph.rs
index e2952bd..3df9ae7 100644
--- a/glifo/src/glyph.rs
+++ b/glifo/src/glyph.rs
@@ -24,6 +24,7 @@
use crate::kurbo::{Line, ParamCurve as _, PathSeg};
use crate::peniko::FontData;
use crate::peniko::color::{AlphaColor, Srgb};
+use crate::util::AffineExt;
use alloc::boxed::Box;
use alloc::sync::Arc;
use alloc::vec::Vec;
@@ -266,27 +267,19 @@
VarLookupKey(self.prepared_run.normalized_coords),
);
let PreparedGlyphRun {
- total_transform: initial_transform,
- hinted_size,
+ draw_props,
+ run_size: _,
normalized_coords,
hinting_instance,
..
} = self.prepared_run;
- // COLR/bitmap glyphs are never hinted. `prepare_glyph_run` may absorb
- // the scale into the font size, so we keep the original transform for
- // their metric calculations.
- let unhinted_transform = self.prepared_run.run_transform
- * self
- .prepared_run
- .glyph_transform
- .unwrap_or(Affine::IDENTITY);
let font_id = self.prepared_run.font.data.id();
let font_index = self.prepared_run.font.index;
let hinted = hinting_instance.is_some();
let colr_bitmap_cache_enabled = self.atlas_cache_enabled
- && hinted_size <= self.glyph_atlas.config().max_cached_font_size;
+ && draw_props.font_size <= self.glyph_atlas.config().max_cached_font_size;
let outline_cache_enabled = colr_bitmap_cache_enabled
// Due to the various parameters that would need to be considered in the cache key,
// we never cache stroked outlines for now. For COLR and bitmap, this doesn't matter
@@ -309,19 +302,15 @@
// pure arithmetic, so we probe the cache before the expensive
// color_glyphs.get() / bitmaps.glyph_for_size() font-table lookups.
// On a miss we keep both for reuse in the outline branch below.
- let outline_transform = calculate_outline_transform(
- glyph,
- initial_transform,
- self.prepared_run.run_transform,
- hinting_instance,
- );
+ let outline_transform =
+ calculate_outline_transform(glyph, draw_props, hinting_instance);
let outline_cache_key = outline_cache_enabled.then(|| {
let fractional_x = outline_transform.translation().x.fract() as f32;
GlyphCacheKey::new(
font_id,
font_index,
glyph.id,
- hinted_size,
+ draw_props.font_size,
hinted,
fractional_x,
BLACK,
@@ -343,11 +332,10 @@
// ── COLR Glyphs ───────────────────────────────────────────
if let Some(color_glyph) = color_glyphs.get(glyph_id) {
let metrics = calculate_colr_metrics(
- self.prepared_run.font_size,
+ draw_props.font_size,
upem,
- unhinted_transform,
- glyph.x,
- glyph.y,
+ draw_props,
+ glyph,
&color_glyph,
);
let transform = calculate_colr_transform(&metrics);
@@ -358,7 +346,7 @@
font_id,
font_index,
glyph_id: glyph.id,
- size_bits: hinted_size.to_bits(),
+ size_bits: draw_props.font_size.to_bits(),
hinted: false,
subpixel_x: SUBPIXEL_COLR,
context_color,
@@ -403,7 +391,7 @@
// ── Bitmap Glyphs ────────────────────────────────────────────
let bitmap_data: Option<(skrifa::bitmap::BitmapGlyph<'_>, Pixmap)> = bitmaps
- .glyph_for_size(Size::new(self.prepared_run.font_size), glyph_id)
+ .glyph_for_size(Size::new(draw_props.font_size), glyph_id)
.and_then(|g| match g.data {
#[cfg(feature = "png")]
BitmapData::Png(data) => Pixmap::from_png(std::io::Cursor::new(data))
@@ -424,8 +412,8 @@
let transform = calculate_bitmap_transform(
glyph,
&pixmap,
- unhinted_transform,
- self.prepared_run.font_size,
+ draw_props,
+ draw_props.font_size,
upem,
&bitmap_glyph,
&bitmaps,
@@ -483,7 +471,7 @@
font_id,
font_index,
&mut outline_cache_session,
- hinted_size,
+ draw_props.font_size,
&outline,
hinting_instance,
normalized_coords,
@@ -537,7 +525,7 @@
let outlines = font_ref.outline_glyphs();
let PreparedGlyphRun {
- hinted_size,
+ draw_props,
hinting_instance,
..
} = self.prepared_run;
@@ -545,11 +533,11 @@
// The glyph_transform (e.g. skew for fake italics) affects where the outline points end up. We apply it along
// with the Y flip to transform from font space (Y up) to layout space (Y down).
//
- // When hinting is enabled, the scale from run.transform is absorbed into font_size, so outlines are larger. We
- // scale them back down to the nominal coordinate space. When not hinting (or hinting without scale), this is
- // 1.0 and has no effect. The glyph-drawing path handles this by simply drawing in global space, but we need to
- // invert it for drawing decorations.
- let outline_to_nominal_scale = f64::from(self.prepared_run.font_size / hinted_size);
+ // During the preparation of the glyph run, the transform of the run may be absorbed into
+ // `draw_props.font_size`, outlines are generated in that scaled coordinate space. We scale them back
+ // to the nominal coordinate space. The glyph-drawing path handles this by
+ // simply drawing in global space, but we need to invert it for drawing decorations.
+ let outline_to_nominal_scale = f64::from(self.prepared_run.run_size / draw_props.font_size);
let outline_transform = self
.prepared_run
.glyph_transform
@@ -588,7 +576,7 @@
glyph.id,
self.prepared_run.font.data.id(),
self.prepared_run.font.index,
- hinted_size,
+ draw_props.font_size,
var_key,
&outline,
hinting_instance,
@@ -894,33 +882,16 @@
///
/// This computes the final positioning transform for an outline glyph, taking into account:
/// - Glyph position within the run
-/// - Run and per-glyph transforms
+/// - Run-space glyph positioning
/// - Y-axis flip (fonts use upside-down coordinate system)
/// - Hinting adjustments (snap y-offset to integer)
fn calculate_outline_transform(
glyph: Glyph,
- initial_transform: Affine,
- run_transform: Affine,
+ draw_props: DrawProps,
hinting_instance: Option<&HintingInstance>,
) -> Affine {
- // Calculate the global glyph translation based on the glyph's local position within
- // the run and the run's global transform.
- //
- // This is a partial affine matrix multiplication, calculating only the translation
- // component that we need. It is added below to calculate the total transform of this
- // glyph.
- let [a, b, c, d, _, _] = run_transform.as_coeffs();
- let translation = Vec2::new(
- a * f64::from(glyph.x) + c * f64::from(glyph.y),
- b * f64::from(glyph.x) + d * f64::from(glyph.y),
- );
-
- // When hinting, ensure the y-offset is integer. The x-offset doesn't matter, as we
- // perform vertical-only hinting.
- let mut final_transform = initial_transform
- .then_translate(translation)
- // Account for the fact that the coordinate system of fonts
- // is upside down.
+ let mut final_transform = draw_props
+ .positioned_transform(glyph)
.pre_scale_non_uniform(1.0, -1.0)
.as_coeffs();
@@ -962,7 +933,7 @@
fn calculate_bitmap_transform(
glyph: Glyph,
pixmap: &Pixmap,
- initial_transform: Affine,
+ draw_props: DrawProps,
font_size: f32,
upem: f32,
bitmap_glyph: &skrifa::bitmap::BitmapGlyph<'_>,
@@ -992,8 +963,8 @@
},
};
- initial_transform
- .pre_translate(Vec2::new(glyph.x.into(), glyph.y.into()))
+ draw_props
+ .positioned_transform(glyph)
// Apply outer bearings.
.pre_translate(Vec2 {
x: (-bitmap_glyph.bearing_x * font_units_to_size).into(),
@@ -1030,15 +1001,13 @@
fn calculate_colr_metrics(
font_size: f32,
upem: f32,
- run_transform: Affine,
- glyph_x: f32,
- glyph_y: f32,
+ draw_props: DrawProps,
+ glyph: Glyph,
color_glyph: &skrifa::color::ColorGlyph<'_>,
) -> ColrMetrics {
// The scale factor we need to apply to scale from font units to our font size.
let font_size_scale = (font_size / upem) as f64;
-
- let transform = run_transform.pre_translate(Vec2::new(glyph_x.into(), glyph_y.into()));
+ let transform = draw_props.positioned_transform(glyph);
// Estimate the size of the intermediate pixmap. Ideally, the intermediate bitmap should have
// exactly one pixel (or more) per device pixel, to ensure that no quality is lost. Therefore,
@@ -1188,109 +1157,180 @@
struct PreparedGlyphRun<'a> {
/// The underlying font data.
font: FontData,
- /// The font size, prior to any scaling.
- font_size: f32,
- /// The run transform.
- run_transform: Affine,
- /// The per-glyph transform.
+ // The fact that we store `run_size` and `glyph_transform` here, as well
+ // as having more transforms and an effective font size inside of the `draw_props` field is pretty
+ // confusing, so here is a brief explanation:
+ // Basically, the reason why we need both `run_size` and `glyph_transform` here is that
+ // we need to store some of the original metadata in scene space for certain functionality
+ // (for example handling of underlines).
+ /// The original run size supplied by the caller.
+ run_size: f32,
+ /// The original per-glyph transform supplied by the caller.
glyph_transform: Option<Affine>,
- /// The total transform (`run_transform * glyph_transform`), not accounting for glyph
- /// translation.
- total_transform: Affine,
- /// The font size to generate glyph outlines for. May not be equal to [`Self::font_size`] if there is a glyph
- /// transform.
- hinted_size: f32,
+ // Continuing the above comment, the problem is that we also need to precalculate data
+ // that is needed specifically for glyph rendering. This includes:
+ // 1) We need to concatenate run transform and glyph transform to compute the final transform
+ // for the glyph outline.
+ // 2) Whenever possible, we need to try to _absorb_ the font size into the draw transform,
+ // such that we can just use the font size to uniquely identify a glyph cache hit (for example,
+ // if we draw a glyph at font size 12 with scale 2, it's the same as drawing the glyph at font size 24).
+ // While it would make things easier to just use the cache key in the transform and accept less
+ // caching potential for easier code, we would still need scaling absorption to implement proper
+ // hinting. Hence, it makes sense to just generalize the whole absorption procedure.
+ // In any case, since we do scaling absorption, we cannot use `run_size`, `GlyphRun::transform` and
+ // `glyph_transform` for glyph drawing purposes anymore. In particular, it can easily happen
+ // that
+ // 1) `run_size` != `draw_props.font_size`
+ // 2) `run_transform` * `glyph_transform` != `draw_props.effective_transform`.
+ // Therefore, we need to track a separate set of fields for glyph-drawing operations.
+ /// Properties for turning glyph-local positions into final draw transforms.
+ draw_props: DrawProps,
normalized_coords: &'a [skrifa::instance::NormalizedCoord],
hinting_instance: Option<&'a HintingInstance>,
}
+/// Properties for easily calculating the transform of a positioned glyph.
+#[derive(Clone, Copy, Debug)]
+struct DrawProps {
+ // Why do we need two separate transforms? Fundamentally, the problem is that the order
+ // of application should be:
+ // `run_transform` * `glyph_position` * `font_size` * `glyph_transform`.
+ // As part of absorption, we are only left with a potentially new `font_size` and a merged
+ // `effective_transform`. However, the translation that results form `glyph_position` logically
+ // needs to be applied after `run_transform` but before `glyph_transform`.
+ // Therefore, we need to store two separate transforms: One that is used only to transform
+ // the original glyph position, and another one that is used to actually transform the glyph
+ // outlines.
+ /// A positioning transform for the glyph.
+ positioning_transform: Affine,
+ /// A transform to apply to the glyph after positioning.
+ effective_transform: Affine,
+ /// The actual font size that should be assumed for drawing and caching
+ /// purposes.
+ font_size: f32,
+}
+
+impl DrawProps {
+ #[inline]
+ fn positioned_transform(self, glyph: Glyph) -> Affine {
+ // First, determine the "coarse" location of the glyph by applying the scaling/skewing
+ // of the original run transform to the glyph position. Note that `positioning_transform`
+ // has a translation factor of zero (since it has been absorbed into `effective_transform`), so
+ // only the skewing and scaling factors are relevant.
+ let translation = self.positioning_transform * Point::new(glyph.x as f64, glyph.y as f64);
+
+ // Now, apply the final draw transform on top of that, which will also consider
+ // the original glyph transform.
+ Affine::translate(translation.to_vec2()) * self.effective_transform
+ }
+}
+
impl Debug for PreparedGlyphRun<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
// HintingInstance doesn't implement Debug so we have to do this manually :(
f.debug_struct("PreparedGlyphRun")
.field("font", &self.font)
- .field("font_size", &self.font_size)
- .field("run_transform", &self.run_transform)
+ .field("run_size", &self.run_size)
.field("glyph_transform", &self.glyph_transform)
- .field("total_transform", &self.total_transform)
- .field("hinted_size", &self.hinted_size)
+ .field("transforms", &self.draw_props)
.field("normalized_coords", &self.normalized_coords)
.finish()
}
}
/// Prepare a glyph run for rendering.
-///
-/// This function calculates the appropriate transform, size, and scaling parameters
-/// for proper font hinting when enabled and possible.
fn prepare_glyph_run<'a>(run: GlyphRun<'a>, hint_cache: &'a mut HintCache) -> PreparedGlyphRun<'a> {
- let total_transform = run.transform * run.glyph_transform.unwrap_or(Affine::IDENTITY);
- if !run.hint {
- return PreparedGlyphRun {
- font: run.font,
- font_size: run.font_size,
- run_transform: run.transform,
- glyph_transform: run.glyph_transform,
- total_transform,
- hinted_size: run.font_size,
- normalized_coords: run.normalized_coords,
- hinting_instance: None,
- };
+ let full_transform = run.transform * run.glyph_transform.unwrap_or(Affine::IDENTITY);
+ let [_, _, t_c, t_d, t_e, t_f] = full_transform.as_coeffs();
+
+ /// The mode that should be used to handle transforms.
+ #[derive(Clone, Copy, Debug)]
+ enum PreparedGlyphRunMode {
+ /// No absorption has happened, the font size stays the same and the effective transform
+ /// is simply the concatenation of run transform and glyph transform.
+ ///
+ /// No hinting should be applied.
+ Direct,
+ /// The scaling factor has been absorbed, and hinting should be applied.
+ AbsorbScaleUnhinted,
+ /// The scaling factor has been absorbed, but not hinting should be applied.
+ AbsorbScaleHinted,
}
- let font_ref = run.font.as_skrifa();
- let outlines = font_ref.outline_glyphs();
-
- // We perform vertical-only hinting.
- //
- // Hinting doesn't make sense if we later scale the glyphs via some transform. So we extract
- // the scale from the global transform and glyph transform and apply it to the font size for
- // hinting. We do require the scaling to be uniform: simply using the vertical scale as font
- // size and then transforming by the relative horizontal scale can cause, e.g., overlapping
- // glyphs. Note that this extracted scale should be later applied to the glyph's position.
- //
- // As the hinting is vertical-only, we can handle horizontal skew, but not vertical skew or
- // rotations.
- let [t_a, t_b, t_c, t_d, t_e, t_f] = total_transform.as_coeffs();
-
- let uniform_scale = t_a == t_d;
- let vertically_uniform = t_b == 0.;
-
- if uniform_scale && vertically_uniform {
- let vertical_font_size = run.font_size * t_d as f32;
-
- let hinting_instance = hint_cache.get(&HintKey {
- font_id: run.font.data.id(),
- font_index: run.font.index,
- outlines: &outlines,
- size: vertical_font_size,
- coords: run.normalized_coords,
- });
-
- PreparedGlyphRun {
- font: run.font,
- font_size: run.font_size,
- run_transform: run.transform,
- glyph_transform: run.glyph_transform,
- // The scale has been absorbed into the font size, so we need to remove it from the skew coefficient (t_c)
- // as well. Otherwise the skew would be applied twice: once via the larger outline, once via the transform.
- // The translation (t_e, t_f) stays as-is since it positions the run in scene coordinates.
- total_transform: Affine::new([1., 0., t_c / t_d, 1., t_e, t_f]),
- hinted_size: vertical_font_size,
- normalized_coords: run.normalized_coords,
- hinting_instance,
+ let mode = if !run.hint {
+ // TODO: We could explore generalizing this by decomposing the transform, such that
+ // we always absorb it, even if there is a skewing factor in the transform. This won't
+ // automatically make them eligible for caching because any skewing factor is currently
+ // rejected for caching, but it might make the code a bit more consistent.
+ if full_transform.is_positive_uniform_scale_without_skew() {
+ PreparedGlyphRunMode::AbsorbScaleUnhinted
+ } else {
+ PreparedGlyphRunMode::Direct
}
} else {
- PreparedGlyphRun {
- font: run.font,
- font_size: run.font_size,
- run_transform: run.transform,
- glyph_transform: run.glyph_transform,
- total_transform,
- hinted_size: run.font_size,
- normalized_coords: run.normalized_coords,
- hinting_instance: None,
+ // We perform vertical-only hinting.
+ //
+ // Hinting doesn't make sense if we later scale the glyphs via some transform. So, similarly to
+ // normal glyph runs, we try to extract the scale. As is currently done for unhinted glyph runs, we
+ // also expect the scale to be uniform: Simply using the vertical scale as font
+ // size and then transforming by the relative horizontal scale can cause, e.g., overlapping
+ // glyphs. Note that this extracted scale should be later applied to the glyph's position.
+ //
+ // As the hinting is vertical-only, we can handle horizontal skew, but not vertical skew or
+ // rotations.
+ if full_transform.is_positive_uniform_scale_without_vertical_skew() {
+ PreparedGlyphRunMode::AbsorbScaleHinted
+ } else {
+ PreparedGlyphRunMode::Direct
}
+ };
+
+ let (effective_transform, draw_font_size, hinting_instance) = match mode {
+ PreparedGlyphRunMode::Direct => (full_transform, run.font_size, None),
+ PreparedGlyphRunMode::AbsorbScaleUnhinted => (
+ Affine::new([1., 0., 0., 1., t_e, t_f]),
+ run.font_size * t_d as f32,
+ None,
+ ),
+ PreparedGlyphRunMode::AbsorbScaleHinted => {
+ let vertical_font_size = run.font_size * t_d as f32;
+ let font_ref = run.font.as_skrifa();
+ let outlines = font_ref.outline_glyphs();
+ let hinting_instance = hint_cache.get(&HintKey {
+ font_id: run.font.data.id(),
+ font_index: run.font.index,
+ outlines: &outlines,
+ size: vertical_font_size,
+ coords: run.normalized_coords,
+ });
+
+ (
+ // The scale has been absorbed into the font size, so we need to remove it from the skew
+ // coefficient (t_c) as well. Otherwise the skew would be applied twice: once via the
+ // larger outline, once via the transform. The translation (t_e, t_f) stays as-is since
+ // it positions the run in scene coordinates.
+ Affine::new([1., 0., t_c / t_d, 1., t_e, t_f]),
+ vertical_font_size,
+ hinting_instance,
+ )
+ }
+ };
+
+ PreparedGlyphRun {
+ font: run.font,
+ run_size: run.font_size,
+ glyph_transform: run.glyph_transform,
+ draw_props: DrawProps {
+ positioning_transform: run
+ .transform
+ // Translation factor is already considered in `effective_transform`, so we need to remove
+ // it here.
+ .with_translation(Vec2::ZERO),
+ effective_transform,
+ font_size: draw_font_size,
+ },
+ normalized_coords: run.normalized_coords,
+ hinting_instance,
}
}
diff --git a/glifo/src/lib.rs b/glifo/src/lib.rs
index 1c425d8..4399bec 100644
--- a/glifo/src/lib.rs
+++ b/glifo/src/lib.rs
@@ -39,7 +39,7 @@
pub mod atlas;
mod colr;
mod glyph;
-mod math;
+mod util;
pub mod renderers;
diff --git a/glifo/src/math.rs b/glifo/src/math.rs
deleted file mode 100644
index 183fd45..0000000
--- a/glifo/src/math.rs
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright 2025 the Vello Authors and the Parley Authors
-// SPDX-License-Identifier: Apache-2.0 OR MIT
-
-//! Mathematical helper functions.
-
-use core::ops::Sub;
-
-// From <https://github.com/linebender/tiny-skia/blob/68b198a7210a6bbf752b43d6bc4db62445730313/path/src/scalar.rs#L12>
-const SCALAR_NEARLY_ZERO: f32 = 1.0 / (1 << 12) as f32;
-
-/// A number of useful methods for f32 numbers.
-pub(crate) trait FloatExt: Sized + Sub<f32, Output = f32> {
- /// Whether the number is approximately 0.
- fn is_nearly_zero(&self) -> bool {
- self.is_nearly_zero_within_tolerance(SCALAR_NEARLY_ZERO)
- }
-
- /// Whether the number is approximately 0, with a given tolerance.
- fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool;
-}
-
-impl FloatExt for f32 {
- #[inline(always)]
- fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool {
- debug_assert!(tolerance >= 0.0, "tolerance must be positive");
-
- self.abs() <= tolerance
- }
-}
diff --git a/glifo/src/renderers/vello_renderer.rs b/glifo/src/renderers/vello_renderer.rs
index 9bbedcb..851712a 100644
--- a/glifo/src/renderers/vello_renderer.rs
+++ b/glifo/src/renderers/vello_renderer.rs
@@ -17,6 +17,7 @@
use crate::atlas::{AtlasSlot, GlyphCache, GlyphCacheKey, ImageCache, RasterMetrics};
use crate::colr::ColrPainter;
use crate::glyph::{GlyphBitmap, GlyphColr, GlyphRenderer, GlyphType, PreparedGlyph};
+use crate::util::AffineExt;
use crate::{kurbo, peniko};
use alloc::sync::Arc;
use alloc::vec::Vec;
@@ -36,7 +37,7 @@
CachedAndRendered,
/// Transform contains rotation or skew — cannot be cached at a single
/// raster resolution, so the caller must render directly.
- NotAxisAligned,
+ UnsupportedTransform,
/// Atlas allocator could not fit the glyph (page full, eviction didn't
/// free enough space, or the glyph exceeds the page dimensions).
AtlasFull,
@@ -256,8 +257,8 @@
image_cache: &mut ImageCache,
tint_color: AlphaColor<Srgb>,
) -> CacheResult {
- if !is_axis_aligned(&transform) {
- return CacheResult::NotAxisAligned;
+ if !supports_atlas_caching(&transform) {
+ return CacheResult::UnsupportedTransform;
}
let bounds = path.bounding_box();
@@ -493,22 +494,6 @@
}
}
-/// Returns `true` if the transform is axis-aligned (no rotation or skew).
-///
-/// Axis-aligned transforms can be cached in the atlas; rotated/skewed glyphs
-/// fall back to direct rendering.
-#[inline]
-pub(crate) fn is_axis_aligned(transform: &Affine) -> bool {
- !has_skew(transform)
-}
-
-/// Returns `true` if the transform has any rotation or skew component.
-#[inline]
-pub(crate) fn has_skew(transform: &Affine) -> bool {
- let [_, b, c, _, _, _] = transform.as_coeffs();
- b.abs() > 1e-6 || c.abs() > 1e-6
-}
-
/// Choose image sampling quality based on downscale factor.
///
/// Returns `High` when the transform scales below 50% (where aliasing is
@@ -530,7 +515,7 @@
/// rasterized at pixel boundaries.
#[inline]
pub(crate) fn quality_for_skew(transform: &Affine) -> ImageQuality {
- if has_skew(transform) {
+ if transform.has_skew() {
ImageQuality::Medium
} else {
ImageQuality::Low
@@ -583,3 +568,45 @@
}
}
}
+
+/// Returns `true` if the transform is safe for atlas-cached outline rendering.
+#[inline]
+pub(crate) fn supports_atlas_caching(transform: &Affine) -> bool {
+ // TODO: Add test cases to see how caching + flipping transforms interact with each other!
+ !transform.has_skew() && !transform.has_non_unit_scale()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::supports_atlas_caching;
+ use peniko::kurbo::Affine;
+
+ #[test]
+ fn supports_atlas_caching_for_identity_and_translation() {
+ assert!(supports_atlas_caching(&Affine::IDENTITY));
+ assert!(supports_atlas_caching(&Affine::translate((12.0, -3.5))));
+ }
+
+ #[test]
+ fn rejects_skewed_transforms() {
+ assert!(!supports_atlas_caching(&Affine::skew(0.2, 0.0)));
+ assert!(!supports_atlas_caching(&Affine::new([
+ 1.0, 0.1, 0.0, 1.0, 0.0, 0.0
+ ])));
+ }
+
+ #[test]
+ fn rejects_scaled_transforms() {
+ assert!(!supports_atlas_caching(&Affine::scale(2.0)));
+ assert!(!supports_atlas_caching(&Affine::scale_non_uniform(
+ 1.0, 0.5
+ )));
+ }
+
+ #[test]
+ fn allows_axis_flip() {
+ assert!(supports_atlas_caching(&Affine::scale_non_uniform(
+ 1.0, -1.0
+ )));
+ }
+}
diff --git a/glifo/src/util.rs b/glifo/src/util.rs
new file mode 100644
index 0000000..aa76671
--- /dev/null
+++ b/glifo/src/util.rs
@@ -0,0 +1,130 @@
+// Copyright 2025 the Vello Authors and the Parley Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Utility helper functions.
+
+use core::ops::Sub;
+use peniko::kurbo::Affine;
+
+// From <https://github.com/linebender/tiny-skia/blob/68b198a7210a6bbf752b43d6bc4db62445730313/path/src/scalar.rs#L12>
+const SCALAR_NEARLY_ZERO_F32: f32 = 1.0 / (1 << 12) as f32;
+const SCALAR_NEARLY_ZERO_F64: f64 = 1.0 / (1 << 12) as f64;
+
+/// A number of useful methods for f32 numbers.
+pub(crate) trait FloatExt: Sized + Sub<f32, Output = f32> {
+ /// Whether the number is approximately 0.
+ fn is_nearly_zero(&self) -> bool {
+ self.is_nearly_zero_within_tolerance(SCALAR_NEARLY_ZERO_F32)
+ }
+
+ /// Whether the number is approximately 0, with a given tolerance.
+ fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool;
+}
+
+impl FloatExt for f32 {
+ #[inline(always)]
+ fn is_nearly_zero_within_tolerance(&self, tolerance: f32) -> bool {
+ debug_assert!(tolerance >= 0.0, "tolerance must be positive");
+
+ self.abs() <= tolerance
+ }
+}
+
+pub(crate) trait AffineExt {
+ /// Whether the transform has any skewing coefficient.
+ fn has_skew(&self) -> bool;
+
+ /// Whether the transform has a scaling factor not equal to 1 or -1.
+ #[cfg(any(feature = "vello_cpu", feature = "vello_hybrid"))]
+ fn has_non_unit_scale(&self) -> bool;
+
+ /// Whether the transform has a vertical skew.
+ fn has_vertical_skew(&self) -> bool;
+
+ /// Whether the transform has positive, uniform scaling factors and no skew.
+ fn is_positive_uniform_scale_without_skew(&self) -> bool;
+
+ /// Whether the transform has positive, uniform scaling factors and no vertical skew.
+ fn is_positive_uniform_scale_without_vertical_skew(&self) -> bool;
+}
+
+impl AffineExt for Affine {
+ #[inline]
+ fn has_skew(&self) -> bool {
+ let [_, b, c, _, _, _] = self.as_coeffs();
+ b.abs() > SCALAR_NEARLY_ZERO_F64 || c.abs() > SCALAR_NEARLY_ZERO_F64
+ }
+
+ #[cfg(any(feature = "vello_cpu", feature = "vello_hybrid"))]
+ #[inline]
+ fn has_non_unit_scale(&self) -> bool {
+ let [a, _, _, d, _, _] = self.as_coeffs();
+ (a.abs() - 1.0).abs() > SCALAR_NEARLY_ZERO_F64
+ || (d.abs() - 1.0).abs() > SCALAR_NEARLY_ZERO_F64
+ }
+
+ #[inline]
+ fn is_positive_uniform_scale_without_skew(&self) -> bool {
+ let [a, _, _, d, _, _] = self.as_coeffs();
+ (a - d).abs() <= SCALAR_NEARLY_ZERO_F64 && a > 0.0 && d > 0.0 && !self.has_skew()
+ }
+
+ #[inline]
+ fn has_vertical_skew(&self) -> bool {
+ let [_, b, _, _, _, _] = self.as_coeffs();
+ b.abs() > SCALAR_NEARLY_ZERO_F64
+ }
+
+ #[inline]
+ fn is_positive_uniform_scale_without_vertical_skew(&self) -> bool {
+ let [a, _, _, d, _, _] = self.as_coeffs();
+ (a - d).abs() <= SCALAR_NEARLY_ZERO_F64 && a > 0.0 && d > 0.0 && !self.has_vertical_skew()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::AffineExt;
+ use peniko::kurbo::Affine;
+
+ #[test]
+ fn detects_positive_uniform_scale_without_skew() {
+ let transform = Affine::scale(2.0);
+
+ assert!(!transform.has_skew());
+ assert!(!transform.has_vertical_skew());
+ assert!(transform.is_positive_uniform_scale_without_skew());
+ assert!(transform.is_positive_uniform_scale_without_vertical_skew());
+ }
+
+ #[test]
+ fn rejects_positive_uniform_scale_without_skew_when_horizontally_skewed() {
+ let transform = Affine::new([2.0, 0.0, 0.25, 2.0, 0.0, 0.0]);
+
+ assert!(transform.has_skew());
+ assert!(!transform.has_vertical_skew());
+ assert!(!transform.is_positive_uniform_scale_without_skew());
+ assert!(transform.is_positive_uniform_scale_without_vertical_skew());
+ }
+
+ #[test]
+ fn rejects_positive_uniform_scale_without_vertical_skew_when_vertically_skewed() {
+ let transform = Affine::new([2.0, 0.25, 0.0, 2.0, 0.0, 0.0]);
+
+ assert!(transform.has_skew());
+ assert!(transform.has_vertical_skew());
+ assert!(!transform.is_positive_uniform_scale_without_skew());
+ assert!(!transform.is_positive_uniform_scale_without_vertical_skew());
+ }
+
+ #[test]
+ fn rejects_non_uniform_or_non_positive_scale() {
+ let non_uniform = Affine::new([2.0, 0.0, 0.0, 3.0, 0.0, 0.0]);
+ let flipped = Affine::new([-2.0, 0.0, 0.0, -2.0, 0.0, 0.0]);
+
+ assert!(!non_uniform.is_positive_uniform_scale_without_skew());
+ assert!(!non_uniform.is_positive_uniform_scale_without_vertical_skew());
+ assert!(!flipped.is_positive_uniform_scale_without_skew());
+ assert!(!flipped.is_positive_uniform_scale_without_vertical_skew());
+ }
+}