oxideav_scribe/style.rs
1//! Font request style — italic knob the shaper honours when emitting
2//! glyph paths.
3//!
4//! - **Italic** is synthesised from `post.italicAngle` (a horizontal
5//! shear applied at outline-flatten time when the requested style is
6//! italic but the font itself is upright). See
7//! [`synthetic_italic_shear`].
8//!
9//! In every case, callers that have a real Italic / Bold variant of
10//! the font available should prefer loading those as separate
11//! [`crate::Face`]s — synthetic styles are the fallback for fonts
12//! that ship only one cut.
13//!
14//! ## Why not just a `bool`?
15//!
16//! Because we want to remember the user's requested weight and italic
17//! flag through the shaping pipeline so future synthesis rounds can
18//! hook in without a public-API break. `Style` is `Copy` so it travels
19//! through arguments cheaply.
20//!
21//! Default is `Style { italic: false, weight: 400 }` — upright Regular.
22
23/// Font selection / synthesis request. Carried through the shaper.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct Style {
26 /// Caller wants italic. If the underlying face is already italic
27 /// (`Face::italic_angle()` is non-zero), no synthesis is applied —
28 /// the font already provides the slant. If the face is upright,
29 /// the consumer applies a horizontal shear of
30 /// `tan(-DEFAULT_SYNTHETIC_ITALIC_DEG)` (`~12°` forward slant) at
31 /// outline-flatten time.
32 pub italic: bool,
33 /// OpenType `usWeightClass` value (100..=1000). Consumed by
34 /// downstream rasterizers wanting to synthesise bold; the shaper
35 /// itself doesn't apply it (true bold should always come from a
36 /// real Bold face when one is available).
37 pub weight: u16,
38}
39
40impl Style {
41 /// Upright, regular weight (400).
42 pub const REGULAR: Style = Style {
43 italic: false,
44 weight: 400,
45 };
46
47 /// Upright, regular weight — same as `Style::REGULAR`. Convenience
48 /// for symmetry with `italic()`.
49 pub fn regular() -> Self {
50 Self::REGULAR
51 }
52
53 /// Italic, regular weight (400).
54 pub fn italic() -> Self {
55 Self {
56 italic: true,
57 weight: 400,
58 }
59 }
60
61 /// Builder: set the italic flag.
62 #[must_use]
63 pub fn with_italic(mut self, italic: bool) -> Self {
64 self.italic = italic;
65 self
66 }
67
68 /// Builder: set the weight (clamped to 1..=1000 to keep the cache
69 /// key well-defined; OpenType allows 100..=1000 in 100 increments
70 /// but variable fonts can land any integer in between).
71 #[must_use]
72 pub fn with_weight(mut self, weight: u16) -> Self {
73 self.weight = weight.clamp(1, 1000);
74 self
75 }
76}
77
78impl Default for Style {
79 fn default() -> Self {
80 Self::REGULAR
81 }
82}
83
84/// Synthetic-italic shear angle in degrees. `tan(12°) ≈ 0.213`, which
85/// matches the slant the major desktop renderers (Quartz, GDI+,
86/// FreeType) apply when the font lacks an italic variant. Mirrors
87/// historical Type-1 Oblique fonts which ship with `italicAngle = -12`.
88pub const DEFAULT_SYNTHETIC_ITALIC_DEG: f32 = 12.0;
89
90/// Threshold (in degrees) under which the font's own `italicAngle` is
91/// considered "upright". Some upright faces ship a tiny non-zero value
92/// for visual centring; if we sheared on top of that we'd look weird.
93pub const ITALIC_ANGLE_EPSILON_DEG: f32 = 0.5;
94
95/// Compute the horizontal shear (`x' = x + shear * y` in TT Y-up
96/// coordinates) that synthesises italic for the requested style on a
97/// face whose own `italic_angle` is `face_italic_deg` (TT/post
98/// convention: negative for forward slant, 0 for upright).
99///
100/// Returns `0.0` when no synthesis is needed: either the request is
101/// upright, or the face is already italic.
102pub fn synthetic_italic_shear(style: Style, face_italic_deg: f32) -> f32 {
103 if !style.italic {
104 return 0.0;
105 }
106 if face_italic_deg.abs() > ITALIC_ANGLE_EPSILON_DEG {
107 // Font is already slanted; honour it instead of double-shearing.
108 return 0.0;
109 }
110 DEFAULT_SYNTHETIC_ITALIC_DEG.to_radians().tan()
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn default_is_regular_upright() {
119 assert_eq!(Style::default(), Style::REGULAR);
120 assert!(!Style::default().italic);
121 assert_eq!(Style::default().weight, 400);
122 }
123
124 #[test]
125 fn italic_builder_sets_flag() {
126 let s = Style::italic();
127 assert!(s.italic);
128 assert_eq!(s.weight, 400);
129 }
130
131 #[test]
132 fn weight_is_clamped() {
133 assert_eq!(Style::REGULAR.with_weight(0).weight, 1);
134 assert_eq!(Style::REGULAR.with_weight(2_000).weight, 1000);
135 assert_eq!(Style::REGULAR.with_weight(700).weight, 700);
136 }
137
138 #[test]
139 fn upright_request_yields_zero_shear() {
140 // Upright request on upright face: 0.
141 assert_eq!(synthetic_italic_shear(Style::REGULAR, 0.0), 0.0);
142 // Upright request on italic face: still 0 (we never un-italicise).
143 assert_eq!(synthetic_italic_shear(Style::REGULAR, -12.0), 0.0);
144 }
145
146 #[test]
147 fn italic_request_on_upright_face_yields_default_shear() {
148 let shear = synthetic_italic_shear(Style::italic(), 0.0);
149 let expected = DEFAULT_SYNTHETIC_ITALIC_DEG.to_radians().tan();
150 assert!(
151 (shear - expected).abs() < 1e-6,
152 "shear = {shear}, expected = {expected}"
153 );
154 // Also ~0.213 (tan 12 deg).
155 assert!(shear > 0.20 && shear < 0.22, "shear = {shear}");
156 }
157
158 #[test]
159 fn italic_request_on_italic_face_yields_zero() {
160 // Face already at -12 deg → no synthesis.
161 assert_eq!(synthetic_italic_shear(Style::italic(), -12.0), 0.0);
162 // Same for positive backslant.
163 assert_eq!(synthetic_italic_shear(Style::italic(), 8.0), 0.0);
164 }
165
166 #[test]
167 fn epsilon_band_still_synthesises() {
168 // Tiny font angle (0.3 deg) is treated as upright.
169 let shear = synthetic_italic_shear(Style::italic(), 0.3);
170 assert!(shear > 0.20);
171 }
172}