Skip to main content

oxitext_layout/
vertical.rs

1//! Vertical text orientation utilities — basic UAX #50 subset.
2//!
3//! Determines whether a Unicode codepoint should be rendered *upright*
4//! (CJK-style, keeping its standard orientation) or *rotated 90° clockwise*
5//! when used in top-to-bottom vertical text.
6
7/// Returns the scaled vertical advance for `glyph_id` from the font's `vmtx`
8/// table.  Falls back to `em_size` if the face cannot be parsed, the `vmtx`
9/// table is absent, or the glyph has no explicit vertical advance.
10pub fn vmtx_advance_for_glyph(face_data: &[u8], glyph_id: u16, em_size: f32) -> f32 {
11    if face_data.is_empty() || em_size <= 0.0 {
12        return em_size;
13    }
14    let face = match ttf_parser::Face::parse(face_data, 0) {
15        Ok(f) => f,
16        Err(_) => return em_size,
17    };
18    let units_per_em = face.units_per_em();
19    if units_per_em == 0 {
20        return em_size;
21    }
22    match face.glyph_ver_advance(ttf_parser::GlyphId(glyph_id)) {
23        Some(adv) => adv as f32 * em_size / units_per_em as f32,
24        None => em_size,
25    }
26}
27
28/// Returns `true` if `c` should be drawn upright in vertical text.
29///
30/// Returns `false` if the character should be rotated 90° clockwise (the
31/// typical behaviour for Latin letters, digits, and punctuation).
32///
33/// The classification covers the most common CJK and related scripts as
34/// defined in Unicode UAX #50 "Orientation" property = "U" (Upright).
35pub fn is_upright_in_vertical(c: char) -> bool {
36    let cp = c as u32;
37    // CJK Unified Ideographs (BMP)
38    (0x4E00..=0x9FFF).contains(&cp)
39        // CJK Extension A
40        || (0x3400..=0x4DBF).contains(&cp)
41        // CJK Extension B (SMP)
42        || (0x2_0000..=0x2_A6DF).contains(&cp)
43        // Hiragana
44        || (0x3040..=0x309F).contains(&cp)
45        // Katakana (full-width; half-width Katakana U+FF65–FF9F are rotated)
46        || (0x30A0..=0x30FF).contains(&cp)
47        // CJK Symbols and Punctuation (mostly upright)
48        || (0x3000..=0x303F).contains(&cp)
49        // Enclosed CJK Letters and Months
50        || (0x3200..=0x32FF).contains(&cp)
51        // Fullwidth Latin / Fullwidth ASCII variants
52        || (0xFF01..=0xFF60).contains(&cp)
53        // Hangul Syllables
54        || (0xAC00..=0xD7A3).contains(&cp)
55        // Hangul Jamo
56        || (0x1100..=0x11FF).contains(&cp)
57        // Bopomofo
58        || (0x3100..=0x312F).contains(&cp)
59        // Kangxi Radicals
60        || (0x2F00..=0x2FDF).contains(&cp)
61}
62
63/// Per-call cache for parsed [`ttf_parser::Face`] instances, keyed by the
64/// raw pointer of the font byte slice.
65///
66/// `ParsedFaceCache<'a>` is a **call-scoped** object: create one at the top of
67/// a vertical-layout pass and drop it at the end.  Every unique font face is
68/// parsed exactly once; subsequent lookups for the same font reuse the cached
69/// `Face`.
70///
71/// The lifetime `'a` is the borrow lifetime of the font byte slices passed to
72/// [`ParsedFaceCache::vmtx_advance_or_default`].  Callers must ensure that all
73/// `&'a [u8]` slices remain valid for the entire life of the cache — inside
74/// `layout_vertical` this is guaranteed because the `runs` slice (and therefore
75/// every `Arc<[u8]>` it contains) is borrowed for the full call duration.
76pub(crate) struct ParsedFaceCache<'a> {
77    /// Maps raw data pointer → `None` (parse failed) or
78    /// `Some((parsed_face, units_per_em))`.
79    faces: std::collections::HashMap<usize, Option<(ttf_parser::Face<'a>, u16)>>,
80}
81
82impl<'a> ParsedFaceCache<'a> {
83    /// Creates an empty cache.
84    pub(crate) fn new() -> Self {
85        Self {
86            faces: std::collections::HashMap::new(),
87        }
88    }
89
90    /// Returns the scaled `vmtx` vertical advance for `glyph_id` in the face
91    /// described by `face_data`, caching the parsed face by pointer identity.
92    ///
93    /// Falls back to `em_size` when:
94    /// - `face_data` is empty or `em_size ≤ 0`,
95    /// - the face cannot be parsed (cached as `None` so we don't retry),
96    /// - the face has `units_per_em == 0`,
97    /// - the glyph has no explicit `vmtx` entry.
98    pub(crate) fn vmtx_advance_or_default(
99        &mut self,
100        face_data: &'a [u8],
101        glyph_id: u16,
102        em_size: f32,
103    ) -> f32 {
104        if face_data.is_empty() || em_size <= 0.0 {
105            return em_size;
106        }
107        // Use the raw data pointer as a stable identity key.
108        let key = face_data.as_ptr() as usize;
109        let entry = self.faces.entry(key).or_insert_with(|| {
110            let face = ttf_parser::Face::parse(face_data, 0).ok()?;
111            let upem = face.units_per_em();
112            if upem == 0 {
113                return None;
114            }
115            Some((face, upem))
116        });
117        match entry {
118            Some((face, upem)) => match face.glyph_ver_advance(ttf_parser::GlyphId(glyph_id)) {
119                Some(adv) => adv as f32 * em_size / (*upem as f32),
120                None => em_size,
121            },
122            None => em_size,
123        }
124    }
125}
126
127/// Vertical-layout metrics for a single glyph.
128///
129/// In vertical text, glyphs advance along the block (Y) axis rather than the
130/// inline (X) axis.  The `advance` field reflects the block-direction advance,
131/// and `upright` controls whether the glyph is drawn in its natural orientation
132/// or rotated.
133pub struct VerticalMetrics {
134    /// Block-direction advance in the same units as `em_size`.
135    /// Ideally sourced from the font's `vmtx` table; falls back to 1 em.
136    pub advance: f32,
137    /// `true` → draw upright (CJK-style); `false` → rotate 90° clockwise.
138    pub upright: bool,
139}
140
141impl VerticalMetrics {
142    /// Compute vertical metrics for character `c` at the given `em_size`.
143    ///
144    /// The advance defaults to `em_size` (1 em) when no `vmtx` data is
145    /// available.  Use [`VerticalMetrics::for_glyph`] when font bytes are
146    /// available for accurate `vmtx` advances.
147    pub fn for_char(c: char, em_size: f32) -> Self {
148        Self {
149            advance: em_size,
150            upright: is_upright_in_vertical(c),
151        }
152    }
153
154    /// Compute vertical metrics for `glyph_id` using the font's `vmtx` table.
155    /// Falls back to `em_size` if `vmtx` is unavailable or parsing fails.
156    pub fn for_glyph(face_data: &[u8], glyph_id: u16, c: char, em_size: f32) -> Self {
157        Self {
158            advance: vmtx_advance_for_glyph(face_data, glyph_id, em_size),
159            upright: is_upright_in_vertical(c),
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use std::path::Path;
168    use std::sync::Arc;
169
170    // ---- ParsedFaceCache tests ----
171
172    #[test]
173    fn parsed_face_cache_returns_em_size_for_empty_face() {
174        let mut cache = ParsedFaceCache::new();
175        let font: Arc<[u8]> = Arc::from(&[][..]);
176        assert_eq!(cache.vmtx_advance_or_default(font.as_ref(), 0, 16.0), 16.0);
177    }
178
179    #[test]
180    fn parsed_face_cache_returns_em_size_for_garbage_face() {
181        let mut cache = ParsedFaceCache::new();
182        let font: Arc<[u8]> = Arc::from(b"not a font".as_slice());
183        assert_eq!(cache.vmtx_advance_or_default(font.as_ref(), 0, 16.0), 16.0);
184    }
185
186    #[test]
187    fn parsed_face_cache_returns_em_size_for_zero_em() {
188        let mut cache = ParsedFaceCache::new();
189        let font: Arc<[u8]> = Arc::from(b"not a font".as_slice());
190        // em_size == 0.0 triggers the early-exit path; face_data.is_empty() is
191        // false here, but em_size <= 0.0 wins.
192        assert_eq!(cache.vmtx_advance_or_default(font.as_ref(), 0, 0.0), 0.0);
193    }
194
195    #[test]
196    fn parsed_face_cache_single_parse_per_face() {
197        let mut cache = ParsedFaceCache::new();
198        let font: Arc<[u8]> = Arc::from(b"garbage bytes".as_slice());
199        // Call 100 times with the same Arc data — only one entry should be
200        // created in the cache (the parse failure is cached as `None`).
201        for _ in 0..100 {
202            let _ = cache.vmtx_advance_or_default(font.as_ref(), 1, 16.0);
203        }
204        assert_eq!(
205            cache.faces.len(),
206            1,
207            "only one cache entry per unique data pointer"
208        );
209    }
210
211    #[test]
212    fn parsed_face_cache_two_fonts_two_entries() {
213        let mut cache = ParsedFaceCache::new();
214        let font_a: Arc<[u8]> = Arc::from(b"garbage_a".as_slice());
215        let font_b: Arc<[u8]> = Arc::from(b"garbage_b".as_slice());
216        // Distinct Arc allocations → distinct pointer → two entries.
217        let _ = cache.vmtx_advance_or_default(font_a.as_ref(), 0, 16.0);
218        let _ = cache.vmtx_advance_or_default(font_b.as_ref(), 0, 16.0);
219        assert_eq!(
220            cache.faces.len(),
221            2,
222            "two distinct fonts → two cache entries"
223        );
224    }
225
226    #[test]
227    fn parsed_face_cache_matches_uncached_for_invalid_font() {
228        let font: Arc<[u8]> = Arc::from(b"not a valid font".as_slice());
229        let uncached = vmtx_advance_for_glyph(font.as_ref(), 5, 20.0);
230        let mut cache = ParsedFaceCache::new();
231        let cached = cache.vmtx_advance_or_default(font.as_ref(), 5, 20.0);
232        assert_eq!(
233            cached, uncached,
234            "cached and uncached paths must agree for invalid font data"
235        );
236    }
237
238    // ---- Original vertical tests ----
239
240    #[test]
241    fn cjk_ideograph_is_upright() {
242        assert!(is_upright_in_vertical('日'));
243        assert!(is_upright_in_vertical('語'));
244    }
245
246    #[test]
247    fn latin_letter_is_rotated() {
248        assert!(!is_upright_in_vertical('A'));
249        assert!(!is_upright_in_vertical('z'));
250    }
251
252    /// Verifies the fallback path: when no font data is present, advance == em_size.
253    #[test]
254    fn vertical_metrics_advance_equals_em() {
255        let vm = VerticalMetrics::for_char('日', 16.0);
256        assert!((vm.advance - 16.0).abs() < f32::EPSILON);
257        assert!(vm.upright);
258    }
259
260    #[test]
261    fn vmtx_advance_empty_face_returns_em_size() {
262        assert_eq!(vmtx_advance_for_glyph(&[], 0, 16.0), 16.0);
263    }
264
265    #[test]
266    fn vmtx_advance_invalid_face_returns_em_size() {
267        // Garbage bytes — face parse fails, should return em_size.
268        assert_eq!(vmtx_advance_for_glyph(b"not a font", 0, 16.0), 16.0);
269    }
270
271    #[test]
272    fn vmtx_advance_zero_em_size() {
273        // em_size == 0.0 triggers the early return.
274        assert_eq!(vmtx_advance_for_glyph(&[], 0, 0.0), 0.0);
275    }
276
277    #[test]
278    fn vmtx_advance_scales_linearly_with_em() {
279        // Try common font paths; skip silently if none are available.
280        let candidates = [
281            Path::new(env!("CARGO_MANIFEST_DIR"))
282                .join("../../tests/fixtures/test-font.ttf")
283                .to_path_buf(),
284            Path::new("/Library/Fonts/Arial Unicode.ttf").to_path_buf(),
285            Path::new("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf").to_path_buf(),
286        ];
287        let font_bytes = candidates
288            .iter()
289            .filter(|p| p.exists())
290            .find_map(|p| std::fs::read(p).ok());
291        let bytes = match font_bytes {
292            Some(b) => b,
293            None => return, // No font available — skip this test.
294        };
295        // Find the first glyph id that has a vmtx advance.
296        let face = match ttf_parser::Face::parse(&bytes, 0) {
297            Ok(f) => f,
298            Err(_) => return,
299        };
300        // Try glyph IDs 1..=100 until we find one with a vmtx advance.
301        let gid = (1u16..=100).find(|&g| face.glyph_ver_advance(ttf_parser::GlyphId(g)).is_some());
302        let gid = match gid {
303            Some(g) => g,
304            None => return, // Font has no vmtx entries — skip.
305        };
306        let adv16 = vmtx_advance_for_glyph(&bytes, gid, 16.0);
307        let adv32 = vmtx_advance_for_glyph(&bytes, gid, 32.0);
308        assert!(
309            (adv32 - 2.0 * adv16).abs() < 1e-3,
310            "adv at 32px should be 2× adv at 16px: adv16={adv16}, adv32={adv32}"
311        );
312    }
313
314    #[test]
315    fn for_glyph_upright_cjk() {
316        // Empty face → advance falls back to em_size; '日' is upright.
317        let vm = VerticalMetrics::for_glyph(&[], 0, '日', 16.0);
318        assert!(vm.upright);
319        assert!((vm.advance - 16.0).abs() < f32::EPSILON);
320    }
321
322    #[test]
323    fn for_glyph_rotated_latin() {
324        // Empty face → advance falls back to em_size; 'A' is not upright.
325        let vm = VerticalMetrics::for_glyph(&[], 0, 'A', 16.0);
326        assert!(!vm.upright);
327        assert!((vm.advance - 16.0).abs() < f32::EPSILON);
328    }
329}