Skip to main content

skrifa/outline/autohint/metrics/
scale.rs

1//! Metrics scaling.
2//!
3//! Uses the widths and blues computations to generate unscaled metrics for a
4//! given style/script.
5//!
6//! Then applies a scaling factor to those metrics, computes a potentially
7//! modified scale, and tags active blue zones.
8
9use super::super::{
10    metrics::{
11        fixed_div, fixed_mul, fixed_mul_div, pix_round, BlueZones, Scale, ScaledAxisMetrics,
12        ScaledBlue, ScaledStyleMetrics, ScaledWidth, UnscaledAxisMetrics, UnscaledBlue,
13        UnscaledStyleMetrics, WidthMetrics,
14    },
15    shape::{Shaper, ShaperMode},
16    style::{ScriptGroup, StyleClass},
17    topo::Dimension,
18    QuirksMode,
19};
20use crate::{instance::NormalizedCoord, prelude::Size, FontRef, MetadataProvider};
21use raw::types::F2Dot14;
22
23impl UnscaledStyleMetrics {
24    /// Creates a set of metrics for the given font, normalized coordinates
25    /// and style class.
26    pub fn new(font: &FontRef, coords: &[NormalizedCoord], style: &StyleClass) -> Self {
27        let shaper_mode = if cfg!(feature = "autohint_shaping") {
28            ShaperMode::BestEffort
29        } else {
30            ShaperMode::Nominal
31        };
32        let shaper = Shaper::new(font, shaper_mode);
33        compute_unscaled_style_metrics(&shaper, coords, style, QuirksMode::Aot)
34    }
35
36    /// Applies the given scale to this set of style metrics.
37    pub fn scale(&self, scale: Scale) -> ScaledStyleMetrics {
38        scale_style_metrics(self, scale, QuirksMode::Aot)
39    }
40}
41
42/// Computes unscaled metrics for the Latin writing system.
43///
44/// See <https://gitlab.freedesktop.org/freetype/freetype/-/blob/57617782464411201ce7bbc93b086c1b4d7d84a5/src/autofit/aflatin.c#L1134>
45pub(crate) fn compute_unscaled_style_metrics(
46    shaper: &Shaper,
47    coords: &[F2Dot14],
48    style: &StyleClass,
49    quirks: QuirksMode,
50) -> UnscaledStyleMetrics {
51    let charmap = shaper.charmap();
52    // We don't attempt to produce any metrics if we don't have a Unicode
53    // cmap
54    // See <https://gitlab.freedesktop.org/freetype/freetype/-/blob/57617782464411201ce7bbc93b086c1b4d7d84a5/src/autofit/aflatin.c#L1146>
55    if charmap.is_symbol() {
56        return UnscaledStyleMetrics {
57            class_ix: style.index as u16,
58            axes: [
59                UnscaledAxisMetrics {
60                    dim: Dimension::Horizontal,
61                    ..Default::default()
62                },
63                UnscaledAxisMetrics {
64                    dim: Dimension::Vertical,
65                    ..Default::default()
66                },
67            ],
68            ..Default::default()
69        };
70    }
71    let [hwidths, vwidths] = super::widths::compute_widths(shaper, coords, style, quirks);
72    let [hblues, vblues] = super::blues::compute_unscaled_blues(shaper, coords, style);
73    let glyph_metrics = shaper.font().glyph_metrics(Size::unscaled(), coords);
74    let mut digit_advance = None;
75    let mut digits_have_same_width = true;
76    for ch in '0'..='9' {
77        if let Some(advance) = charmap
78            .map(ch)
79            .and_then(|gid| glyph_metrics.advance_width(gid))
80        {
81            if digit_advance.is_some() && digit_advance != Some(advance) {
82                digits_have_same_width = false;
83                break;
84            }
85            digit_advance = Some(advance);
86        }
87    }
88    UnscaledStyleMetrics {
89        class_ix: style.index as u16,
90        digits_have_same_width,
91        axes: [
92            UnscaledAxisMetrics {
93                dim: Dimension::Horizontal,
94                blues: hblues,
95                width_metrics: hwidths.0,
96                widths: hwidths.1,
97            },
98            UnscaledAxisMetrics {
99                dim: Dimension::Vertical,
100                blues: vblues,
101                width_metrics: vwidths.0,
102                widths: vwidths.1,
103            },
104        ],
105    }
106}
107
108/// Computes scaled metrics for the Latin writing system.
109///
110/// See <https://gitlab.freedesktop.org/freetype/freetype/-/blob/57617782464411201ce7bbc93b086c1b4d7d84a5/src/autofit/aflatin.c#L1491>
111pub(crate) fn scale_style_metrics(
112    unscaled_metrics: &UnscaledStyleMetrics,
113    mut scale: Scale,
114    quirks: QuirksMode,
115) -> ScaledStyleMetrics {
116    let scale_axis_fn = if unscaled_metrics.style_class().script.group == ScriptGroup::Default {
117        scale_default_axis_metrics
118    } else {
119        scale_cjk_axis_metrics
120    };
121    let mut scale_axis = |axis: &UnscaledAxisMetrics| {
122        scale_axis_fn(
123            axis.dim,
124            &axis.widths,
125            axis.width_metrics,
126            &axis.blues,
127            &mut scale,
128            quirks,
129        )
130    };
131    let axes = [
132        scale_axis(&unscaled_metrics.axes[0]),
133        scale_axis(&unscaled_metrics.axes[1]),
134    ];
135    ScaledStyleMetrics { scale, axes }
136}
137
138/// Computes scaled metrics for a single axis.
139///
140/// See <https://gitlab.freedesktop.org/freetype/freetype/-/blob/57617782464411201ce7bbc93b086c1b4d7d84a5/src/autofit/aflatin.c#L1168>
141fn scale_default_axis_metrics(
142    dim: Dimension,
143    widths: &[i32],
144    width_metrics: WidthMetrics,
145    blues: &[UnscaledBlue],
146    scale: &mut Scale,
147    quirks: QuirksMode,
148) -> ScaledAxisMetrics {
149    let mut axis = ScaledAxisMetrics {
150        dim,
151        ..Default::default()
152    };
153    if dim == Dimension::Horizontal {
154        axis.scale = scale.x_scale;
155        axis.delta = scale.x_delta;
156    } else {
157        axis.scale = scale.y_scale;
158        axis.delta = scale.y_delta;
159    };
160    // Correct Y scale to optimize alignment
161    if let Some(blue_ix) = blues
162        .iter()
163        .position(|blue| blue.zones.contains(BlueZones::ADJUSTMENT))
164    {
165        let unscaled_blue = &blues[blue_ix];
166        let scaled = fixed_mul(axis.scale, unscaled_blue.overshoot);
167        let fitted = (scaled + 40) & !63;
168        if scaled != fitted && dim == Dimension::Vertical {
169            let new_scale = fixed_mul_div(axis.scale, fitted, scaled);
170            // Scaling should not adjust by more than 2 pixels
171            let mut max_height = scale.units_per_em;
172            for blue in blues {
173                max_height = max_height.max(blue.ascender).max(-blue.descender);
174            }
175            let mut dist = fixed_mul(max_height, new_scale - axis.scale).abs();
176            dist &= !127;
177            if dist == 0 {
178                axis.scale = new_scale;
179                scale.y_scale = new_scale;
180            }
181        }
182    }
183    // Now scale the widths. FreeType ensures there is always at least one
184    // width entry (the standard width), even if width extraction found none.
185    axis.width_metrics = width_metrics;
186    if widths.is_empty() && quirks == QuirksMode::Aot {
187        let scaled = fixed_mul(axis.scale, axis.width_metrics.standard_width);
188        axis.widths.push(ScaledWidth {
189            scaled,
190            fitted: scaled,
191        });
192    } else {
193        for unscaled_width in widths {
194            let scaled = fixed_mul(axis.scale, *unscaled_width);
195            axis.widths.push(ScaledWidth {
196                scaled,
197                fitted: scaled,
198            });
199        }
200    }
201    // Compute extra light property: this is a standard width that is
202    // less than 5/8 pixels
203    axis.width_metrics.is_extra_light =
204        fixed_mul(axis.width_metrics.standard_width, axis.scale) < (32 + 8);
205    if dim == Dimension::Vertical {
206        // And scale the blue zones
207        for unscaled_blue in blues {
208            let scaled_position = fixed_mul(axis.scale, unscaled_blue.position) + axis.delta;
209            let scaled_overshoot = fixed_mul(axis.scale, unscaled_blue.overshoot) + axis.delta;
210            let mut blue = ScaledBlue {
211                position: ScaledWidth {
212                    scaled: scaled_position,
213                    fitted: scaled_position,
214                },
215                overshoot: ScaledWidth {
216                    scaled: scaled_overshoot,
217                    fitted: scaled_overshoot,
218                },
219                zones: unscaled_blue.zones,
220                is_active: false,
221            };
222            // Only activate blue zones less than 3/4 pixel tall
223            let dist = fixed_mul(unscaled_blue.position - unscaled_blue.overshoot, axis.scale);
224            if (-48..=48).contains(&dist) {
225                let mut delta = dist.abs();
226                if delta < 32 {
227                    delta = 0;
228                } else if delta < 48 {
229                    delta = 32;
230                } else {
231                    delta = 64;
232                }
233                if dist < 0 {
234                    delta = -delta;
235                }
236                blue.position.fitted = pix_round(blue.position.scaled);
237                blue.overshoot.fitted = blue.position.fitted - delta;
238                blue.is_active = true;
239            }
240            axis.blues.push(blue);
241        }
242        // Use sub-top blue zone if it doesn't overlap with another
243        // non-sub-top blue zone
244        for blue_ix in 0..axis.blues.len() {
245            let blue = axis.blues[blue_ix];
246            if !blue.zones.is_sub_top() || !blue.is_active {
247                continue;
248            }
249            for blue2 in &axis.blues {
250                if blue2.zones.is_sub_top() || !blue2.is_active {
251                    continue;
252                }
253                if blue2.position.fitted <= blue.overshoot.fitted
254                    && blue2.overshoot.fitted >= blue.position.fitted
255                {
256                    axis.blues[blue_ix].is_active = false;
257                    break;
258                }
259            }
260        }
261    }
262    axis
263}
264
265/// Computes scaled metrics for a single axis for the CJK script group.
266///
267/// See <https://gitlab.freedesktop.org/freetype/freetype/-/blob/57617782464411201ce7bbc93b086c1b4d7d84a5/src/autofit/afcjk.c#L661>
268fn scale_cjk_axis_metrics(
269    dim: Dimension,
270    widths: &[i32],
271    width_metrics: WidthMetrics,
272    blues: &[UnscaledBlue],
273    scale: &mut Scale,
274    _quirks: QuirksMode,
275) -> ScaledAxisMetrics {
276    let mut axis = ScaledAxisMetrics {
277        dim,
278        ..Default::default()
279    };
280    axis.dim = dim;
281    if dim == Dimension::Horizontal {
282        axis.scale = scale.x_scale;
283        axis.delta = scale.x_delta;
284    } else {
285        axis.scale = scale.y_scale;
286        axis.delta = scale.y_delta;
287    };
288    let scale = axis.scale;
289    // Scale the blue zones
290    for unscaled_blue in blues {
291        let position = fixed_mul(unscaled_blue.position, scale) + axis.delta;
292        let overshoot = fixed_mul(unscaled_blue.overshoot, scale) + axis.delta;
293        let mut blue = ScaledBlue {
294            position: ScaledWidth {
295                scaled: position,
296                fitted: position,
297            },
298            overshoot: ScaledWidth {
299                scaled: overshoot,
300                fitted: overshoot,
301            },
302            zones: unscaled_blue.zones,
303            is_active: false,
304        };
305        // A blue zone is only active if it is less than 3/4 pixels tall
306        let dist = fixed_mul(unscaled_blue.position - unscaled_blue.overshoot, scale);
307        if (-48..=48).contains(&dist) {
308            blue.position.fitted = pix_round(blue.position.scaled);
309            // For CJK, "overshoot" is actually undershoot
310            let delta1 = fixed_div(blue.position.fitted, scale) - unscaled_blue.overshoot;
311            let mut delta2 = fixed_mul(delta1.abs(), scale);
312            if delta2 < 32 {
313                delta2 = 0;
314            } else {
315                delta2 = pix_round(delta2);
316            }
317            if delta1 < 0 {
318                delta2 = -delta2;
319            }
320            blue.overshoot.fitted = blue.position.fitted - delta2;
321            blue.is_active = true;
322        }
323        axis.blues.push(blue);
324    }
325    // FreeType never seems to compute scaled width values. We'll just
326    // match this behavior for now.
327    // <https://github.com/googlefonts/fontations/issues/1129>
328    for _ in 0..widths.len() {
329        axis.widths.push(ScaledWidth::default());
330    }
331    axis.width_metrics = width_metrics;
332    axis
333}
334
335#[cfg(test)]
336mod tests {
337    use super::{
338        super::super::{shape::ShaperMode, style},
339        *,
340    };
341    use crate::attribute::Style;
342    use raw::{FontRef, TableProvider};
343
344    #[test]
345    fn scaled_metrics_default() {
346        // Note: expected values scraped from a FreeType debugging
347        // session
348        let scaled_metrics = make_scaled_metrics(
349            font_test_data::NOTOSERIFHEBREW_AUTOHINT_METRICS,
350            StyleClass::HEBR,
351        );
352        // Check scale and deltas
353        assert_eq!(scaled_metrics.scale.x_scale, 67109);
354        assert_eq!(scaled_metrics.scale.y_scale, 67109);
355        assert_eq!(scaled_metrics.scale.x_delta, 0);
356        assert_eq!(scaled_metrics.scale.y_delta, 0);
357        // Horizontal widths
358        let h_axis = &scaled_metrics.axes[0];
359        let expected_h_widths = [55];
360        // No horizontal blues
361        check_axis(h_axis, &expected_h_widths, &[]);
362        // Not extra light
363        assert!(!h_axis.width_metrics.is_extra_light);
364        // Vertical widths
365        let v_axis = &scaled_metrics.axes[1];
366        let expected_v_widths = [22, 112];
367        // Vertical blues
368        #[rustfmt::skip]
369        let expected_v_blues = [
370            // ((scaled_pos, fitted_pos), (scaled_shoot, fitted_shoot), flags, is_active)
371            ScaledBlue::from(((606, 576), (606, 576), BlueZones::TOP, true)),
372            ScaledBlue::from(((0, 0), (-9, 0), BlueZones::default(), true)),
373            ScaledBlue::from(((-246, -256), (-246, -256), BlueZones::default(), true)),
374        ];
375        check_axis(v_axis, &expected_v_widths, &expected_v_blues);
376        // This one is extra light
377        assert!(v_axis.width_metrics.is_extra_light);
378    }
379
380    #[test]
381    fn cjk_scaled_metrics() {
382        // Note: expected values scraped from a FreeType debugging
383        // session
384        let scaled_metrics = make_scaled_metrics(
385            font_test_data::NOTOSERIFTC_AUTOHINT_METRICS,
386            StyleClass::HANI,
387        );
388        // Check scale and deltas
389        assert_eq!(scaled_metrics.scale.x_scale, 67109);
390        assert_eq!(scaled_metrics.scale.y_scale, 67109);
391        assert_eq!(scaled_metrics.scale.x_delta, 0);
392        assert_eq!(scaled_metrics.scale.y_delta, 0);
393        // Horizontal widths
394        let h_axis = &scaled_metrics.axes[0];
395        let expected_h_widths = [0];
396        check_axis(h_axis, &expected_h_widths, &[]);
397        // Not extra light
398        assert!(!h_axis.width_metrics.is_extra_light);
399        // Vertical widths
400        let v_axis = &scaled_metrics.axes[1];
401        let expected_v_widths = [0];
402        // Vertical blues
403        #[rustfmt::skip]
404        let expected_v_blues = [
405            // ((scaled_pos, fitted_pos), (scaled_shoot, fitted_shoot), flags, is_active)
406            ScaledBlue::from(((857, 832), (844, 832), BlueZones::TOP, true)),
407            ScaledBlue::from(((-80, -64), (-68, -64), BlueZones::default(), true)),
408        ];
409        // No horizontal blues
410        check_axis(v_axis, &expected_v_widths, &expected_v_blues);
411        // Also not extra light
412        assert!(!v_axis.width_metrics.is_extra_light);
413    }
414
415    fn make_scaled_metrics(font_data: &[u8], style_class: usize) -> ScaledStyleMetrics {
416        let font = FontRef::new(font_data).unwrap();
417        let class = &style::STYLE_CLASSES[style_class];
418        let shaper = Shaper::new(&font, ShaperMode::Nominal);
419        let unscaled_metrics = compute_unscaled_style_metrics(
420            &shaper,
421            Default::default(),
422            class,
423            QuirksMode::default(),
424        );
425        let scale = Scale::new(
426            16.0,
427            font.head().unwrap().units_per_em() as i32,
428            Style::Normal,
429            Default::default(),
430            class.script.group,
431        );
432        scale_style_metrics(&unscaled_metrics, scale, QuirksMode::default())
433    }
434
435    fn check_axis(
436        axis: &ScaledAxisMetrics,
437        expected_widths: &[i32],
438        expected_blues: &[ScaledBlue],
439    ) {
440        let widths = axis
441            .widths
442            .iter()
443            .map(|width| width.scaled)
444            .collect::<Vec<_>>();
445        assert_eq!(widths, expected_widths);
446        assert_eq!(axis.blues.as_slice(), expected_blues);
447    }
448
449    impl From<(i32, i32)> for ScaledWidth {
450        fn from(value: (i32, i32)) -> Self {
451            Self {
452                scaled: value.0,
453                fitted: value.1,
454            }
455        }
456    }
457
458    impl From<((i32, i32), (i32, i32), BlueZones, bool)> for ScaledBlue {
459        fn from(value: ((i32, i32), (i32, i32), BlueZones, bool)) -> Self {
460            Self {
461                position: value.0.into(),
462                overshoot: value.1.into(),
463                zones: value.2,
464                is_active: value.3,
465            }
466        }
467    }
468}