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}