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