Skip to main content

pdfrum_type1/
blend.rs

1//! Multiple Master: turning design coordinates into a weight vector.
2//!
3//! A Multiple-Master Type 1 font is *n* complete outline sets ("masters")
4//! stored interleaved, plus a rule for mixing them. The rule has three parts,
5//! all declared in the cleartext preamble:
6//!
7//! - `/BlendAxisTypes [/Weight /Width]` names the *k* design axes.
8//! - `/BlendDesignPositions [[0 0][1 0][0 1][1 1]]` places each master in the
9//!   *k*-dimensional unit cube — one row per master, and for a well-formed
10//!   font the rows are exactly the 2ᵏ cube corners.
11//! - `/BlendDesignMap [[[50 0][1450 1]] …]` maps each axis's *design* units
12//!   (a weight of 50–1450, a width of 100–900) onto that unit interval, as a
13//!   piecewise-linear curve given by its knots.
14//!
15//! From a design coordinate the blend runs: map through the design map to
16//! normalized [0,1] per axis, then compute each master's weight as the product
17//! over axes of `n` or `1-n` according to that master's corner. Those products
18//! sum to 1 by construction, which is what makes them a partition of the
19//! outline. The charstring interpreter then consumes the vector through
20//! `callothersubr` 14–18.
21//!
22//! This is the only part of Type 1 that `read_fonts::ps::type1` does not cover
23//! — it honours the `/WeightVector` a font ships with but exposes no way to
24//! compute a different one, and parses none of the three declarations above.
25//! Everything here exists to answer PDFium's `AdjustVariationParams`
26//! (`core/fxge/cfx_face.cpp`), which drives the two Foxit fallback faces
27//! entirely through design coordinates.
28
29use pdfrum_common::{DiagKind, Diagnostics, Severity};
30
31/// What a design axis controls. Type 1 names these with PostScript literals;
32/// only the two the Foxit faces use have dedicated variants.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum AxisKind {
35    /// `/Weight` — stroke thickness. PDFium's axis 0.
36    Weight,
37    /// `/Width` — horizontal proportion. PDFium's axis 1.
38    Width,
39    /// `/OpticalSize`, `/Style`, or anything else a font names.
40    Other(Box<str>),
41}
42
43/// One design axis, in the design units a caller thinks in.
44///
45/// `min`/`max` come from the first and last knot of this axis's design map,
46/// which is where FreeType's `FT_Get_MM_Var` gets them too — so the values a
47/// caller compares against are the same ones PDFium reads out of
48/// `FT_MM_Var::axis`.
49#[derive(Debug, Clone, PartialEq)]
50pub struct MmAxis {
51    /// Smallest design coordinate the font interpolates.
52    pub min: f32,
53    /// The coordinate the font's own `/WeightVector` corresponds to.
54    pub default: f32,
55    /// Largest design coordinate the font interpolates.
56    pub max: f32,
57    /// What the axis controls.
58    pub kind: AxisKind,
59}
60
61/// One axis's design-unit → normalized-position curve, as its knots.
62#[derive(Debug, Clone, PartialEq)]
63pub(crate) struct DesignMap {
64    /// `(design, normalized)` pairs, in ascending design order.
65    pub knots: Vec<(f32, f32)>,
66}
67
68impl DesignMap {
69    /// Piecewise-linear evaluation, clamped at both ends.
70    fn map(&self, design: f32) -> f32 {
71        let Some(&(first_d, first_n)) = self.knots.first() else {
72            return 0.0;
73        };
74        if design <= first_d {
75            return first_n;
76        }
77        for pair in self.knots.windows(2) {
78            let (Some(&(d0, n0)), Some(&(d1, n1))) = (pair.first(), pair.get(1)) else {
79                break;
80            };
81            if design <= d1 {
82                let span = d1 - d0;
83                if span.abs() < f32::EPSILON {
84                    return n1;
85                }
86                return n0 + (n1 - n0) * (design - d0) / span;
87            }
88        }
89        self.knots.last().map_or(0.0, |&(_, n)| n)
90    }
91}
92
93/// Everything a font declared about its Multiple-Master structure.
94#[derive(Debug, Clone, PartialEq)]
95pub(crate) struct Blend {
96    /// One per axis.
97    pub axes: Vec<MmAxis>,
98    /// One row per master, each row one normalized coordinate per axis.
99    pub design_positions: Vec<Vec<f32>>,
100    /// One per axis.
101    pub design_maps: Vec<DesignMap>,
102    /// The weight vector the font shipped with — the blend at the default
103    /// design coordinates, and what `read_fonts` would use.
104    pub default_weights: Vec<f32>,
105}
106
107impl Blend {
108    /// Assemble from the four parsed declarations, rejecting the combination
109    /// if they disagree.
110    ///
111    /// A Type 1 font that says it has two axes, four masters and three design
112    /// positions is not a font we can interpolate; treating it as
113    /// non-variable and drawing its default master is strictly better than
114    /// blending garbage, and is what FreeType does too.
115    pub fn new(
116        axis_kinds: Vec<AxisKind>,
117        design_positions: Vec<Vec<f32>>,
118        design_maps: Vec<DesignMap>,
119        default_weights: Vec<f32>,
120        diags: &mut Diagnostics,
121    ) -> Option<Self> {
122        let num_axes = axis_kinds.len();
123        let num_masters = default_weights.len();
124        let consistent = num_axes > 0
125            && num_masters > 1
126            && design_maps.len() == num_axes
127            && design_positions.len() == num_masters
128            && design_positions.iter().all(|row| row.len() == num_axes)
129            && design_maps.iter().all(|m| m.knots.len() >= 2);
130        if !consistent {
131            diags.record(Severity::Suspicious, DiagKind::Type1BlendInconsistent, None);
132            return None;
133        }
134
135        // Design-space min/max come from the outer knots. The default is the
136        // design coordinate whose blend reproduces the shipped weight vector;
137        // for the axis-aligned cube layout every real MM font uses, that is
138        // recoverable by projecting the weight vector onto the axis.
139        let axes = axis_kinds
140            .into_iter()
141            .enumerate()
142            .map(|(i, kind)| {
143                let map = design_maps.get(i);
144                let knots = map.map_or(&[][..], |m| m.knots.as_slice());
145                let (min, max) = (
146                    knots.first().map_or(0.0, |k| k.0),
147                    knots.last().map_or(1.0, |k| k.0),
148                );
149                let normalized_default = default_normalized(&design_positions, &default_weights, i);
150                MmAxis {
151                    min,
152                    max,
153                    default: map.map_or(normalized_default, |m| unmap(m, normalized_default)),
154                    kind,
155                }
156            })
157            .collect();
158
159        Some(Self {
160            axes,
161            design_positions,
162            design_maps,
163            default_weights,
164        })
165    }
166
167    /// The weight vector for a set of design coordinates.
168    ///
169    /// Coordinates are clamped to each axis's range, and a short `coords`
170    /// leaves the remaining axes at their defaults — both because a caller
171    /// asking for one axis of a two-axis font is the common case
172    /// (`AdjustVariationParams` varies weight alone whenever `dest_width` is
173    /// zero) and because refusing would mean no glyph at all.
174    pub fn weights_for(&self, coords: &[f32]) -> Vec<f32> {
175        let normalized: Vec<f32> = self
176            .axes
177            .iter()
178            .enumerate()
179            .map(|(i, axis)| {
180                let design = coords
181                    .get(i)
182                    .copied()
183                    .unwrap_or(axis.default)
184                    .clamp(axis.min.min(axis.max), axis.max.max(axis.min));
185                self.design_maps.get(i).map_or(0.0, |m| m.map(design))
186            })
187            .collect();
188
189        self.design_positions
190            .iter()
191            .map(|corner| {
192                corner
193                    .iter()
194                    .zip(&normalized)
195                    .map(|(&c, &n)| if c > 0.5 { n } else { 1.0 - n })
196                    .product()
197            })
198            .collect()
199    }
200}
201
202/// Invert a design map: find the design coordinate whose normalized value is
203/// `target`. Used only to report an axis default in design units.
204fn unmap(map: &DesignMap, target: f32) -> f32 {
205    let Some(&(first_d, first_n)) = map.knots.first() else {
206        return 0.0;
207    };
208    if target <= first_n {
209        return first_d;
210    }
211    for pair in map.knots.windows(2) {
212        let (Some(&(d0, n0)), Some(&(d1, n1))) = (pair.first(), pair.get(1)) else {
213            break;
214        };
215        if target <= n1 {
216            let span = n1 - n0;
217            if span.abs() < f32::EPSILON {
218                return d1;
219            }
220            return d0 + (d1 - d0) * (target - n0) / span;
221        }
222    }
223    map.knots.last().map_or(0.0, |&(d, _)| d)
224}
225
226/// Recover axis `i`'s normalized default from the shipped weight vector: sum
227/// the weights of every master sitting at the axis's high end.
228///
229/// For the cube layout (`[[0 0][1 0][0 1][1 1]]`) this is exact — the weight
230/// of a corner is the product over axes, so summing the high-side corners of
231/// one axis factors out to that axis's normalized coordinate.
232fn default_normalized(positions: &[Vec<f32>], weights: &[f32], axis: usize) -> f32 {
233    positions
234        .iter()
235        .zip(weights)
236        .filter(|(corner, _)| corner.get(axis).is_some_and(|&c| c > 0.5))
237        .map(|(_, &w)| w)
238        .sum::<f32>()
239        .clamp(0.0, 1.0)
240}
241
242#[cfg(test)]
243#[allow(
244    clippy::indexing_slicing,
245    clippy::float_cmp,
246    clippy::cast_possible_truncation,
247    clippy::cast_sign_loss,
248    clippy::similar_names
249)]
250mod tests {
251    use super::{AxisKind, Blend, DesignMap};
252    use pdfrum_common::{DiagKind, Diagnostics};
253
254    /// The Foxit Sans MM declaration, verbatim.
255    fn foxit_sans() -> Blend {
256        let mut d = Diagnostics::default();
257        Blend::new(
258            vec![AxisKind::Weight, AxisKind::Width],
259            vec![
260                vec![0.0, 0.0],
261                vec![1.0, 0.0],
262                vec![0.0, 1.0],
263                vec![1.0, 1.0],
264            ],
265            vec![
266                DesignMap {
267                    knots: vec![(50.0, 0.0), (1450.0, 1.0)],
268                },
269                DesignMap {
270                    knots: vec![(50.0, 0.0), (1450.0, 1.0)],
271                },
272            ],
273            vec![0.3158, 0.1349, 0.3849, 0.1644],
274            &mut d,
275        )
276        .expect("consistent declaration")
277    }
278
279    #[test]
280    fn corners_select_a_single_master() {
281        let b = foxit_sans();
282        assert_eq!(b.weights_for(&[50.0, 50.0]), vec![1.0, 0.0, 0.0, 0.0]);
283        assert_eq!(b.weights_for(&[1450.0, 50.0]), vec![0.0, 1.0, 0.0, 0.0]);
284        assert_eq!(b.weights_for(&[50.0, 1450.0]), vec![0.0, 0.0, 1.0, 0.0]);
285        assert_eq!(b.weights_for(&[1450.0, 1450.0]), vec![0.0, 0.0, 0.0, 1.0]);
286    }
287
288    #[test]
289    fn weights_always_partition_unity() {
290        let b = foxit_sans();
291        for w in [50.0f32, 300.0, 750.0, 1200.0, 1450.0] {
292            for x in [50.0f32, 400.0, 900.0, 1450.0] {
293                let sum: f32 = b.weights_for(&[w, x]).iter().sum();
294                assert!((sum - 1.0).abs() < 1e-5, "w={w} x={x} sum={sum}");
295            }
296        }
297    }
298
299    #[test]
300    fn the_default_coordinates_reproduce_the_shipped_vector() {
301        // The round trip that makes `MmAxis::default` meaningful: blending at
302        // the reported defaults must return the font's own `/WeightVector`.
303        let b = foxit_sans();
304        let defaults: Vec<f32> = b.axes.iter().map(|a| a.default).collect();
305        for (got, want) in b.weights_for(&defaults).iter().zip(&b.default_weights) {
306            assert!((got - want).abs() < 1e-4, "{got} vs {want}");
307        }
308    }
309
310    #[test]
311    fn axis_ranges_come_from_the_design_map() {
312        let b = foxit_sans();
313        assert_eq!(b.axes.len(), 2);
314        assert_eq!((b.axes[0].min, b.axes[0].max), (50.0, 1450.0));
315        assert_eq!(b.axes[0].kind, AxisKind::Weight);
316        assert_eq!(b.axes[1].kind, AxisKind::Width);
317    }
318
319    #[test]
320    fn out_of_range_coordinates_clamp() {
321        let b = foxit_sans();
322        assert_eq!(
323            b.weights_for(&[-999.0, -999.0]),
324            b.weights_for(&[50.0, 50.0])
325        );
326        assert_eq!(b.weights_for(&[9e9, 9e9]), b.weights_for(&[1450.0, 1450.0]));
327    }
328
329    #[test]
330    fn a_short_coordinate_list_leaves_the_rest_at_default() {
331        let b = foxit_sans();
332        let full = b.weights_for(&[1450.0, b.axes[1].default]);
333        assert_eq!(b.weights_for(&[1450.0]), full);
334    }
335
336    #[test]
337    fn inconsistent_declarations_are_refused_with_a_diagnostic() {
338        let mut d = Diagnostics::default();
339        // Two axes claimed, but only three of the four masters positioned.
340        assert!(
341            Blend::new(
342                vec![AxisKind::Weight, AxisKind::Width],
343                vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![0.0, 1.0]],
344                vec![
345                    DesignMap {
346                        knots: vec![(0.0, 0.0), (1.0, 1.0)]
347                    },
348                    DesignMap {
349                        knots: vec![(0.0, 0.0), (1.0, 1.0)]
350                    },
351                ],
352                vec![0.25, 0.25, 0.25, 0.25],
353                &mut d,
354            )
355            .is_none()
356        );
357        assert!(d.contains(&DiagKind::Type1BlendInconsistent));
358    }
359
360    #[test]
361    fn a_three_knot_design_map_is_piecewise() {
362        let mut d = Diagnostics::default();
363        // A weight axis whose middle is deliberately off-centre.
364        let b = Blend::new(
365            vec![AxisKind::Weight],
366            vec![vec![0.0], vec![1.0]],
367            vec![DesignMap {
368                knots: vec![(100.0, 0.0), (400.0, 0.75), (900.0, 1.0)],
369            }],
370            vec![0.5, 0.5],
371            &mut d,
372        )
373        .expect("consistent");
374        // At the middle knot the normalized value is 0.75, not 0.5.
375        let w = b.weights_for(&[400.0]);
376        assert!((w[1] - 0.75).abs() < 1e-6, "{w:?}");
377        // Halfway along the first segment.
378        let w = b.weights_for(&[250.0]);
379        assert!((w[1] - 0.375).abs() < 1e-6, "{w:?}");
380    }
381}