Coverage Report

Created: 2026-07-25 07:50

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/fontations/skrifa/src/outline/autohint/metrics/scale.rs
Line
Count
Source
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
9
use 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
};
20
use crate::{instance::NormalizedCoord, prelude::Size, FontRef, MetadataProvider};
21
use raw::types::F2Dot14;
22
23
impl UnscaledStyleMetrics {
24
    /// Creates a set of metrics for the given font, normalized coordinates
25
    /// and style class.
26
0
    pub fn new(font: &FontRef, coords: &[NormalizedCoord], style: &StyleClass) -> Self {
27
0
        let shaper_mode = if cfg!(feature = "autohint_shaping") {
28
0
            ShaperMode::BestEffort
29
        } else {
30
0
            ShaperMode::Nominal
31
        };
32
0
        let shaper = Shaper::new(font, shaper_mode);
33
0
        compute_unscaled_style_metrics(&shaper, coords, style, QuirksMode::Aot)
34
0
    }
35
36
    /// Applies the given scale to this set of style metrics.
37
0
    pub fn scale(&self, scale: Scale) -> ScaledStyleMetrics {
38
0
        scale_style_metrics(self, scale, QuirksMode::Aot)
39
0
    }
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>
45
0
pub(crate) fn compute_unscaled_style_metrics(
46
0
    shaper: &Shaper,
47
0
    coords: &[F2Dot14],
48
0
    style: &StyleClass,
49
0
    quirks: QuirksMode,
50
0
) -> UnscaledStyleMetrics {
51
0
    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
0
    if charmap.is_symbol() {
56
0
        return UnscaledStyleMetrics {
57
0
            class_ix: style.index as u16,
58
0
            axes: [
59
0
                UnscaledAxisMetrics {
60
0
                    dim: Dimension::Horizontal,
61
0
                    ..Default::default()
62
0
                },
63
0
                UnscaledAxisMetrics {
64
0
                    dim: Dimension::Vertical,
65
0
                    ..Default::default()
66
0
                },
67
0
            ],
68
0
            ..Default::default()
69
0
        };
70
0
    }
71
0
    let [hwidths, vwidths] = super::widths::compute_widths(shaper, coords, style, quirks);
72
0
    let [hblues, vblues] = super::blues::compute_unscaled_blues(shaper, coords, style);
73
0
    let glyph_metrics = shaper.font().glyph_metrics(Size::unscaled(), coords);
74
0
    let mut digit_advance = None;
75
0
    let mut digits_have_same_width = true;
76
0
    for ch in '0'..='9' {
77
0
        if let Some(advance) = charmap
78
0
            .map(ch)
79
0
            .and_then(|gid| glyph_metrics.advance_width(gid))
80
        {
81
0
            if digit_advance.is_some() && digit_advance != Some(advance) {
82
0
                digits_have_same_width = false;
83
0
                break;
84
0
            }
85
0
            digit_advance = Some(advance);
86
0
        }
87
    }
88
0
    UnscaledStyleMetrics {
89
0
        class_ix: style.index as u16,
90
0
        digits_have_same_width,
91
0
        axes: [
92
0
            UnscaledAxisMetrics {
93
0
                dim: Dimension::Horizontal,
94
0
                blues: hblues,
95
0
                width_metrics: hwidths.0,
96
0
                widths: hwidths.1,
97
0
            },
98
0
            UnscaledAxisMetrics {
99
0
                dim: Dimension::Vertical,
100
0
                blues: vblues,
101
0
                width_metrics: vwidths.0,
102
0
                widths: vwidths.1,
103
0
            },
104
0
        ],
105
0
    }
106
0
}
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>
111
0
pub(crate) fn scale_style_metrics(
112
0
    unscaled_metrics: &UnscaledStyleMetrics,
113
0
    mut scale: Scale,
114
0
    quirks: QuirksMode,
115
0
) -> ScaledStyleMetrics {
116
0
    let scale_axis_fn = if unscaled_metrics.style_class().script.group == ScriptGroup::Default {
117
0
        scale_default_axis_metrics
118
0
    } else {
119
0
        scale_cjk_axis_metrics
120
0
    };
121
0
    let mut scale_axis = |axis: &UnscaledAxisMetrics| {
122
0
        scale_axis_fn(
123
0
            axis.dim,
124
0
            &axis.widths,
125
0
            axis.width_metrics,
126
0
            &axis.blues,
127
0
            &mut scale,
128
0
            quirks,
129
0
        )
130
0
    };
131
0
    let axes = [
132
0
        scale_axis(&unscaled_metrics.axes[0]),
133
0
        scale_axis(&unscaled_metrics.axes[1]),
134
0
    ];
135
0
    ScaledStyleMetrics { scale, axes }
136
0
}
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>
141
0
fn scale_default_axis_metrics(
142
0
    dim: Dimension,
143
0
    widths: &[i32],
144
0
    width_metrics: WidthMetrics,
145
0
    blues: &[UnscaledBlue],
146
0
    scale: &mut Scale,
147
0
    quirks: QuirksMode,
148
0
) -> ScaledAxisMetrics {
149
0
    let mut axis = ScaledAxisMetrics {
150
0
        dim,
151
0
        ..Default::default()
152
0
    };
153
0
    if dim == Dimension::Horizontal {
154
0
        axis.scale = scale.x_scale;
155
0
        axis.delta = scale.x_delta;
156
0
    } else {
157
0
        axis.scale = scale.y_scale;
158
0
        axis.delta = scale.y_delta;
159
0
    };
160
    // Correct Y scale to optimize alignment
161
0
    if let Some(blue_ix) = blues
162
0
        .iter()
163
0
        .position(|blue| blue.zones.contains(BlueZones::ADJUSTMENT))
164
    {
165
0
        let unscaled_blue = &blues[blue_ix];
166
0
        let scaled = fixed_mul(axis.scale, unscaled_blue.overshoot);
167
0
        let fitted = (scaled + 40) & !63;
168
0
        if scaled != fitted && dim == Dimension::Vertical {
169
0
            let new_scale = fixed_mul_div(axis.scale, fitted, scaled);
170
            // Scaling should not adjust by more than 2 pixels
171
0
            let mut max_height = scale.units_per_em;
172
0
            for blue in blues {
173
0
                max_height = max_height.max(blue.ascender).max(-blue.descender);
174
0
            }
175
0
            let mut dist = fixed_mul(max_height, new_scale - axis.scale).abs();
176
0
            dist &= !127;
177
0
            if dist == 0 {
178
0
                axis.scale = new_scale;
179
0
                scale.y_scale = new_scale;
180
0
            }
181
0
        }
182
0
    }
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
0
    axis.width_metrics = width_metrics;
186
0
    if widths.is_empty() && quirks == QuirksMode::Aot {
187
0
        let scaled = fixed_mul(axis.scale, axis.width_metrics.standard_width);
188
0
        axis.widths.push(ScaledWidth {
189
0
            scaled,
190
0
            fitted: scaled,
191
0
        });
192
0
    } else {
193
0
        for unscaled_width in widths {
194
0
            let scaled = fixed_mul(axis.scale, *unscaled_width);
195
0
            axis.widths.push(ScaledWidth {
196
0
                scaled,
197
0
                fitted: scaled,
198
0
            });
199
0
        }
200
    }
201
    // Compute extra light property: this is a standard width that is
202
    // less than 5/8 pixels
203
0
    axis.width_metrics.is_extra_light =
204
0
        fixed_mul(axis.width_metrics.standard_width, axis.scale) < (32 + 8);
205
0
    if dim == Dimension::Vertical {
206
        // And scale the blue zones
207
0
        for unscaled_blue in blues {
208
0
            let scaled_position = fixed_mul(axis.scale, unscaled_blue.position) + axis.delta;
209
0
            let scaled_overshoot = fixed_mul(axis.scale, unscaled_blue.overshoot) + axis.delta;
210
0
            let mut blue = ScaledBlue {
211
0
                position: ScaledWidth {
212
0
                    scaled: scaled_position,
213
0
                    fitted: scaled_position,
214
0
                },
215
0
                overshoot: ScaledWidth {
216
0
                    scaled: scaled_overshoot,
217
0
                    fitted: scaled_overshoot,
218
0
                },
219
0
                zones: unscaled_blue.zones,
220
0
                is_active: false,
221
0
            };
222
            // Only activate blue zones less than 3/4 pixel tall
223
0
            let dist = fixed_mul(unscaled_blue.position - unscaled_blue.overshoot, axis.scale);
224
0
            if (-48..=48).contains(&dist) {
225
0
                let mut delta = dist.abs();
226
0
                if delta < 32 {
227
0
                    delta = 0;
228
0
                } else if delta < 48 {
229
0
                    delta = 32;
230
0
                } else {
231
0
                    delta = 64;
232
0
                }
233
0
                if dist < 0 {
234
0
                    delta = -delta;
235
0
                }
236
0
                blue.position.fitted = pix_round(blue.position.scaled);
237
0
                blue.overshoot.fitted = blue.position.fitted - delta;
238
0
                blue.is_active = true;
239
0
            }
240
0
            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
0
        for blue_ix in 0..axis.blues.len() {
245
0
            let blue = axis.blues[blue_ix];
246
0
            if !blue.zones.is_sub_top() || !blue.is_active {
247
0
                continue;
248
0
            }
249
0
            for blue2 in &axis.blues {
250
0
                if blue2.zones.is_sub_top() || !blue2.is_active {
251
0
                    continue;
252
0
                }
253
0
                if blue2.position.fitted <= blue.overshoot.fitted
254
0
                    && blue2.overshoot.fitted >= blue.position.fitted
255
                {
256
0
                    axis.blues[blue_ix].is_active = false;
257
0
                    break;
258
0
                }
259
            }
260
        }
261
0
    }
262
0
    axis
263
0
}
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>
268
0
fn scale_cjk_axis_metrics(
269
0
    dim: Dimension,
270
0
    widths: &[i32],
271
0
    width_metrics: WidthMetrics,
272
0
    blues: &[UnscaledBlue],
273
0
    scale: &mut Scale,
274
0
    _quirks: QuirksMode,
275
0
) -> ScaledAxisMetrics {
276
0
    let mut axis = ScaledAxisMetrics {
277
0
        dim,
278
0
        ..Default::default()
279
0
    };
280
0
    axis.dim = dim;
281
0
    if dim == Dimension::Horizontal {
282
0
        axis.scale = scale.x_scale;
283
0
        axis.delta = scale.x_delta;
284
0
    } else {
285
0
        axis.scale = scale.y_scale;
286
0
        axis.delta = scale.y_delta;
287
0
    };
288
0
    let scale = axis.scale;
289
    // Scale the blue zones
290
0
    for unscaled_blue in blues {
291
0
        let position = fixed_mul(unscaled_blue.position, scale) + axis.delta;
292
0
        let overshoot = fixed_mul(unscaled_blue.overshoot, scale) + axis.delta;
293
0
        let mut blue = ScaledBlue {
294
0
            position: ScaledWidth {
295
0
                scaled: position,
296
0
                fitted: position,
297
0
            },
298
0
            overshoot: ScaledWidth {
299
0
                scaled: overshoot,
300
0
                fitted: overshoot,
301
0
            },
302
0
            zones: unscaled_blue.zones,
303
0
            is_active: false,
304
0
        };
305
        // A blue zone is only active if it is less than 3/4 pixels tall
306
0
        let dist = fixed_mul(unscaled_blue.position - unscaled_blue.overshoot, scale);
307
0
        if (-48..=48).contains(&dist) {
308
0
            blue.position.fitted = pix_round(blue.position.scaled);
309
            // For CJK, "overshoot" is actually undershoot
310
0
            let delta1 = fixed_div(blue.position.fitted, scale) - unscaled_blue.overshoot;
311
0
            let mut delta2 = fixed_mul(delta1.abs(), scale);
312
0
            if delta2 < 32 {
313
0
                delta2 = 0;
314
0
            } else {
315
0
                delta2 = pix_round(delta2);
316
0
            }
317
0
            if delta1 < 0 {
318
0
                delta2 = -delta2;
319
0
            }
320
0
            blue.overshoot.fitted = blue.position.fitted - delta2;
321
0
            blue.is_active = true;
322
0
        }
323
0
        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
0
    for _ in 0..widths.len() {
329
0
        axis.widths.push(ScaledWidth::default());
330
0
    }
331
0
    axis.width_metrics = width_metrics;
332
0
    axis
333
0
}
334
335
#[cfg(test)]
336
mod 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
}