Skip to main content

oxitext_shape/
variational.rs

1//! Variable font support and vertical shaping tests.
2//!
3//! Provides [`SwashShaper::shape_with_variations`] for shaping text with
4//! explicit OpenType variation axis values, and related tests including
5//! vertical shaping with the `vert` feature.
6
7use crate::{ShapeResult, SwashShaper};
8use oxitext_core::OxiTextError;
9
10impl SwashShaper {
11    /// Shape text with specific OpenType variation axis values.
12    ///
13    /// `variations` is a list of `(axis_tag, value)` pairs, e.g.
14    /// `([b'w', b'g', b'h', b't'], 700.0)` for Bold weight.
15    ///
16    /// # Note on swash 0.2.x API
17    ///
18    /// swash's [`ShapeContext`] does not expose a public variation-axis API in
19    /// version 0.2.x — the font metrics are applied internally.  This method
20    /// provides the correct API surface for future enhancement when swash adds
21    /// explicit variation support; for now it delegates to regular shaping and
22    /// returns the result unchanged.
23    ///
24    /// # Errors
25    /// Returns [`OxiTextError::Shaping`] if the font bytes cannot be parsed.
26    ///
27    /// [`ShapeContext`]: swash::shape::ShapeContext
28    pub fn shape_with_variations(
29        &mut self,
30        font_data: &[u8],
31        text: &str,
32        px_size: f32,
33        variations: &[([u8; 4], f32)],
34    ) -> Result<ShapeResult, OxiTextError> {
35        // `variations` is accepted for future use once swash exposes a public
36        // variation-axis API; ignore the value here without a warning.
37        let _ = variations;
38        self.shape_full(font_data, text, px_size)
39    }
40}
41
42// ────────────────────────────────────────────────────────────────────────────
43// Tests
44// ────────────────────────────────────────────────────────────────────────────
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use crate::{ShapeDirection, ShapeFeature, ShapeRequest};
50    use std::path::Path;
51    use std::sync::Arc;
52
53    fn load_test_font() -> Arc<[u8]> {
54        let fixture =
55            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/test-font.ttf");
56        if fixture.exists() {
57            return Arc::from(
58                std::fs::read(&fixture)
59                    .expect("read fixture font")
60                    .as_slice(),
61            );
62        }
63        let candidates = [
64            "/Library/Fonts/Arial Unicode.ttf",
65            "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
66        ];
67        for p in &candidates {
68            if Path::new(p).exists() {
69                return Arc::from(std::fs::read(p).expect("read system font").as_slice());
70            }
71        }
72        panic!("no test font found — add tests/fixtures/test-font.ttf");
73    }
74
75    // ── Item 1: Vertical shaping with vert feature ────────────────────────────
76
77    #[test]
78    fn test_vertical_shaping_applies_vert_feature() {
79        let font_bytes = load_test_font();
80        let mut shaper = SwashShaper::new();
81        // shape_request auto-injects vert/vrt2 when direction == Ttb.
82        let req = ShapeRequest::builder()
83            .text("A")
84            .font_data(&font_bytes)
85            .px_size(16.0)
86            .direction(ShapeDirection::Ttb)
87            .build()
88            .expect("build request");
89        // Must not panic; glyphs may be .notdef if font lacks CJK coverage.
90        let result = shaper.shape_request(&req);
91        assert!(
92            result.is_ok(),
93            "shape_request with Ttb should succeed: {result:?}"
94        );
95        let glyphs = result.expect("ok");
96        assert!(
97            !glyphs.is_empty(),
98            "expected at least one glyph from vertical shaping"
99        );
100    }
101
102    #[test]
103    fn test_vertical_shaping_shape_with_features_vert() {
104        // shape_with_features called with the vert feature should not panic.
105        let font_bytes = load_test_font();
106        let mut shaper = SwashShaper::new();
107        let vert = ShapeFeature::VERT;
108        let result = shaper.shape_with_features(&font_bytes, "A", 16.0, false, &[vert]);
109        assert!(
110            result.is_ok(),
111            "shape_with_features with VERT should succeed: {result:?}"
112        );
113    }
114
115    #[test]
116    fn test_vertical_shaping_vert_and_vrt2_injected() {
117        // Verify the injected feature list contains both vert and vrt2 for Ttb.
118        let req = ShapeRequest::builder()
119            .text("A")
120            .font_data(&[0u8; 4])
121            .px_size(16.0)
122            .direction(ShapeDirection::Ttb)
123            .build()
124            .expect("build ok");
125        let mut features = req.features.clone();
126        if req.direction == ShapeDirection::Ttb {
127            if !features.iter().any(|f| f.tag == *b"vert") {
128                features.push(ShapeFeature::VERT);
129            }
130            if !features.iter().any(|f| f.tag == *b"vrt2") {
131                features.push(ShapeFeature::VRT2);
132            }
133        }
134        assert!(
135            features.iter().any(|f| f.tag == *b"vert"),
136            "vert must be present for Ttb"
137        );
138        assert!(
139            features.iter().any(|f| f.tag == *b"vrt2"),
140            "vrt2 must be present for Ttb"
141        );
142    }
143
144    // ── Item 2: Variable font shaping ─────────────────────────────────────────
145
146    #[test]
147    fn test_shape_with_variations_does_not_panic() {
148        let font_bytes = load_test_font();
149        let mut shaper = SwashShaper::new();
150        let variations = [(*b"wght", 700.0f32)];
151        let result = shaper.shape_with_variations(&font_bytes, "Hello", 16.0, &variations);
152        // The test font may not be a variable font, but the call must succeed.
153        assert!(
154            result.is_ok(),
155            "shape_with_variations should succeed: {result:?}"
156        );
157    }
158
159    #[test]
160    fn test_shape_with_variations_empty_variations() {
161        let font_bytes = load_test_font();
162        let mut shaper = SwashShaper::new();
163        let result = shaper.shape_with_variations(&font_bytes, "ABC", 16.0, &[]);
164        assert!(result.is_ok(), "empty variations list must succeed");
165        let r = result.expect("ok");
166        assert!(!r.glyphs.is_empty(), "expected shaped glyphs");
167    }
168
169    #[test]
170    fn test_shape_with_variations_multiple_axes() {
171        let font_bytes = load_test_font();
172        let mut shaper = SwashShaper::new();
173        let variations = [(*b"wght", 400.0f32), (*b"wdth", 100.0f32)];
174        let result = shaper.shape_with_variations(&font_bytes, "Hi", 16.0, &variations);
175        assert!(
176            result.is_ok(),
177            "multiple variation axes must not error: {result:?}"
178        );
179    }
180
181    // ── Item 1: Devanagari conjunct shaping ───────────────────────────────────
182
183    /// The test font (test-font.ttf) may lack Devanagari coverage.  That is
184    /// acceptable: what matters is that the shaper does not panic when asked to
185    /// shape Indic text and that `requires_indic_shaping` correctly classifies
186    /// the input.
187    #[test]
188    fn test_devanagari_requires_indic_shaping() {
189        assert!(
190            crate::requires_indic_shaping("क्ष"),
191            "ksha (ka + virama + sha) must require Indic shaping"
192        );
193        assert!(
194            crate::requires_indic_shaping("नमस्ते"),
195            "namaste must require Indic shaping"
196        );
197        assert!(
198            !crate::requires_indic_shaping("Hello"),
199            "Latin text must not require Indic shaping"
200        );
201    }
202
203    #[test]
204    fn test_devanagari_virama_text_requires_indic_shaping() {
205        // Explicit virama (U+094D) between consonants must also be detected.
206        let text = "क\u{094D}ष";
207        assert!(
208            crate::requires_indic_shaping(text),
209            "explicit virama sequence must require Indic shaping"
210        );
211    }
212
213    #[test]
214    fn test_devanagari_conjunct_swash_no_panic() {
215        // "ksha" in Devanagari: क + ् + ष → may form a conjunct when the font
216        // has GSUB Indic tables; Roboto / test-font may yield .notdef but must
217        // not panic.
218        let devanagari_ksha = "क्ष";
219        let font_bytes = load_test_font();
220        let mut shaper = SwashShaper::new();
221        let result = shaper.shape_full(&font_bytes, devanagari_ksha, 16.0);
222        // Accept either success (possibly .notdef glyphs) or a shaping error.
223        match result {
224            Ok(sr) => {
225                // If glyphs were produced, cluster_boundaries must be populated.
226                assert!(
227                    !sr.cluster_boundaries.is_empty(),
228                    "shape_full must populate cluster_boundaries"
229                );
230            }
231            Err(_) => {
232                // Font does not support Devanagari — acceptable for a Latin test font.
233            }
234        }
235    }
236
237    #[cfg(feature = "rustybuzz-backend")]
238    #[test]
239    fn test_devanagari_conjunct_rustybuzz_no_panic() {
240        use crate::backend::ShapeBackend as _;
241
242        let devanagari_ksha = "क्ष";
243        let font_bytes = load_test_font();
244        // RustybuzzShaper is a unit struct; shape() returns Vec<ShapedGlyph>
245        // (never panics — returns empty on font parse failure).
246        let backend = crate::RustybuzzShaper;
247        let glyphs = backend.shape(&font_bytes, devanagari_ksha, 16.0);
248        // The result is either empty (font lacks Devanagari) or contains
249        // some glyphs (possibly .notdef).  The key assertion is no panic.
250        let _ = glyphs;
251    }
252
253    // ── Item 2: Shape cache correctness ───────────────────────────────────────
254
255    #[test]
256    fn test_shape_cache_correctness_repeated_font() {
257        let font_bytes = load_test_font();
258        let mut shaper = SwashShaper::with_cache(64);
259
260        // Shape the same text 5 times via the Arc-keyed cache path.
261        let results: Vec<_> = (0..5)
262            .map(|_| shaper.shape("Hello", Arc::clone(&font_bytes), 16.0).ok())
263            .collect();
264
265        let first = &results[0];
266        for r in &results[1..] {
267            match (first, r) {
268                (Some(a), Some(b)) => {
269                    assert_eq!(
270                        a.glyphs.len(),
271                        b.glyphs.len(),
272                        "repeated shape call must return the same glyph count"
273                    );
274                    for (ga, gb) in a.glyphs.iter().zip(b.glyphs.iter()) {
275                        assert_eq!(ga.gid, gb.gid, "cached glyph IDs must be stable");
276                    }
277                }
278                (None, None) => {}
279                _ => panic!(
280                    "inconsistent cache behaviour: first was Some={}, subsequent was Some={}",
281                    first.is_some(),
282                    r.is_some()
283                ),
284            }
285        }
286    }
287
288    #[test]
289    fn test_shape_cache_correctness_different_texts() {
290        let font_bytes = load_test_font();
291        let mut shaper = SwashShaper::with_cache(64);
292
293        let r1 = shaper.shape("AAA", Arc::clone(&font_bytes), 16.0).ok();
294        let r2 = shaper.shape("BBB", Arc::clone(&font_bytes), 16.0).ok();
295        let r3 = shaper.shape("AAA", Arc::clone(&font_bytes), 16.0).ok();
296
297        // r1 and r3 (same text) must have the same glyph count.
298        if let (Some(a), Some(b)) = (&r1, &r3) {
299            assert_eq!(
300                a.glyphs.len(),
301                b.glyphs.len(),
302                "same text shaped twice must yield the same glyph count"
303            );
304        }
305        // r2 is exercised to ensure interleaved different-text calls do not
306        // corrupt subsequent cache lookups.
307        let _ = r2;
308    }
309
310    // ── Item 3: font_has_aat idempotency ──────────────────────────────────────
311
312    #[test]
313    fn test_font_has_aat_is_idempotent() {
314        // Repeated calls with the same bytes must return the same value.
315        // Note: the current implementation re-parses on every call; this test
316        // verifies correctness (stable output) rather than caching behaviour.
317        let font_bytes = load_test_font();
318        let r1 = SwashShaper::font_has_aat(&font_bytes);
319        let r2 = SwashShaper::font_has_aat(&font_bytes);
320        assert_eq!(
321            r1, r2,
322            "font_has_aat must return the same value on repeated calls"
323        );
324    }
325}