Skip to main content

typf_core/
lib.rs

1//! Core traits and shared types for Typf.
2//!
3//! This crate defines the common contract used by the rest of the Typf
4//! workspace: pipeline stages, shaping and rendering traits, shared error
5//! types, caches, and the data structures that move from one stage to the
6//! next.
7//!
8//! Typf models text rendering as six conceptual steps:
9//!
10//! 1. read input text,
11//! 2. analyse script and direction,
12//! 3. choose a font,
13//! 4. shape characters into positioned glyphs,
14//! 5. render those glyphs into pixels or vector data,
15//! 6. export the result.
16//!
17//! The default pipeline currently exposes the shaping, rendering, and export
18//! steps directly. The earlier stages still matter because other crates may use
19//! them for bidi resolution, text segmentation, or font fallback.
20//!
21//! To extend Typf, implement one or more of these traits:
22//!
23//! - [`Stage`] for a general pipeline step,
24//! - [`Shaper`] for text shaping,
25//! - [`Renderer`] for glyph rendering,
26//! - [`Exporter`] for serialization,
27//! - [`traits::FontRef`] for access to font data.
28//!
29//! Shared values passed between those steps live in [`types`].
30
31use std::collections::HashSet;
32
33/// Default maximum bitmap dimension (width): 16,777,216 pixels (16M)
34///
35/// This prevents memory explosions from pathological fonts or extreme render sizes.
36/// The width can be very large for long text runs.
37pub const DEFAULT_MAX_BITMAP_WIDTH: u32 = 16 * 1024 * 1024;
38
39/// Default maximum bitmap height: 16k pixels
40///
41/// Height is more strictly limited than width since vertical overflow is rarer
42/// and tall bitmaps are often pathological.
43pub const DEFAULT_MAX_BITMAP_HEIGHT: u32 = 16 * 1024;
44
45/// Default maximum total bitmap pixels: 1 Gpix
46///
47/// This caps total memory regardless of aspect ratio.
48/// A 4-megapixel RGBA8 bitmap consumes 16 MB.
49pub const DEFAULT_MAX_BITMAP_PIXELS: u64 = 1024 * 1024 * 1024;
50
51pub fn get_max_bitmap_width() -> u32 {
52    std::env::var("TYPF_MAX_BITMAP_WIDTH")
53        .ok()
54        .and_then(|s| s.parse().ok())
55        .unwrap_or(DEFAULT_MAX_BITMAP_WIDTH)
56}
57
58pub fn get_max_bitmap_height() -> u32 {
59    std::env::var("TYPF_MAX_BITMAP_HEIGHT")
60        .ok()
61        .and_then(|s| s.parse().ok())
62        .unwrap_or(DEFAULT_MAX_BITMAP_HEIGHT)
63}
64
65pub fn get_max_bitmap_pixels() -> u64 {
66    std::env::var("TYPF_MAX_BITMAP_PIXELS")
67        .ok()
68        .and_then(|s| s.parse().ok())
69        .unwrap_or(DEFAULT_MAX_BITMAP_PIXELS)
70}
71
72pub mod cache;
73pub mod cache_config;
74pub mod context;
75pub mod error;
76pub mod ffi;
77pub mod glyph_cache;
78pub mod linra;
79pub mod pipeline;
80pub mod shaping_cache;
81pub mod traits;
82
83pub use context::PipelineContext;
84pub use error::{Result, TypfError};
85pub use pipeline::{Pipeline, PipelineBuilder};
86pub use traits::{Exporter, Renderer, Shaper, Stage};
87
88/// Maximum font size in pixels to prevent DoS attacks.
89///
90/// Set very high (100K px) to catch only obvious attacks while
91/// allowing legitimate large-format rendering use cases.
92pub const MAX_FONT_SIZE: f32 = 100_000.0;
93
94/// Maximum number of glyphs to render in a single operation.
95///
96/// Set very high (10M glyphs) to catch only obvious attacks while
97/// allowing legitimate bulk text processing.
98pub const MAX_GLYPH_COUNT: usize = 10_000_000;
99
100/// The data structures that move through the pipeline.
101///
102/// These types carry the output of one stage into the next, so they are shared
103/// across shapers, renderers, exporters, and bindings.
104pub mod types {
105    /// Unique identifier for a glyph within a font.
106    pub type GlyphId = u32;
107
108    /// Minimal, stable font-wide metrics in font units.
109    ///
110    /// These are intended for layout/baseline decisions by consumers that only have a `FontRef`.
111    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
112    pub struct FontMetrics {
113        /// Units per em (typically 1000 or 2048).
114        pub units_per_em: u16,
115        /// Distance from baseline to top of highest glyph.
116        pub ascent: i16,
117        /// Distance from baseline to bottom of lowest glyph (usually negative).
118        pub descent: i16,
119        /// Recommended gap between lines.
120        pub line_gap: i16,
121    }
122
123    /// A variable font axis definition.
124    ///
125    /// Describes one axis of variation in a variable font (e.g., weight, width, slant).
126    /// Values are in font design units unless otherwise specified.
127    #[derive(Debug, Clone, PartialEq)]
128    pub struct VariationAxis {
129        /// 4-character tag (e.g., "wght", "wdth", "slnt", "ital").
130        pub tag: String,
131        /// Human-readable name (if available from name table).
132        pub name: Option<String>,
133        /// Minimum axis value.
134        pub min_value: f32,
135        /// Default axis value.
136        pub default_value: f32,
137        /// Maximum axis value.
138        pub max_value: f32,
139        /// Whether this is a hidden axis (not shown to users).
140        pub hidden: bool,
141    }
142
143    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
144    pub enum Direction {
145        /// Standard Latin, Cyrillic, etc.
146        LeftToRight,
147        /// Arabic, Hebrew, etc.
148        RightToLeft,
149        /// Vertical CJK (not fully supported yet).
150        TopToBottom,
151        /// Vertical (rare).
152        BottomToTop,
153    }
154
155    /// One shaped glyph with its final position inside the run.
156    #[derive(Debug, Clone, PartialEq)]
157    pub struct PositionedGlyph {
158        /// The glyph ID in the font.
159        pub id: GlyphId,
160        /// X position of the glyph origin (relative to run start).
161        pub x: f32,
162        /// Y position of the glyph origin.
163        pub y: f32,
164        /// How much to advance the cursor after this glyph.
165        pub advance: f32,
166        /// The Unicode cluster index this glyph belongs to.
167        pub cluster: u32,
168    }
169
170    /// Output from the shaping stage, ready for rendering.
171    #[derive(Debug, Clone)]
172    pub struct ShapingResult {
173        /// The list of positioned glyphs.
174        pub glyphs: Vec<PositionedGlyph>,
175        /// Total width of the shaped run.
176        pub advance_width: f32,
177        /// Total height of the shaped run (usually 0 for horizontal text).
178        pub advance_height: f32,
179        /// Overall direction of the run.
180        pub direction: Direction,
181    }
182
183    #[derive(Debug, Clone)]
184    pub enum RenderOutput {
185        /// Rasterized bitmap (PNG, PBM, etc.).
186        Bitmap(BitmapData),
187        /// Serialized vector format (SVG, PDF).
188        Vector(VectorData),
189        /// JSON representation of glyph data.
190        Json(String),
191        /// Path geometry for GPU pipelines and tessellators.
192        Geometry(GeometryData),
193    }
194
195    impl RenderOutput {
196        /// Returns the approximate heap size in bytes of this render output.
197        ///
198        /// Used by byte-weighted caches to enforce memory limits.
199        pub fn byte_size(&self) -> usize {
200            match self {
201                RenderOutput::Bitmap(b) => b.byte_size(),
202                RenderOutput::Vector(v) => v.data.len(),
203                RenderOutput::Json(s) => s.len(),
204                RenderOutput::Geometry(g) => g.byte_size(),
205            }
206        }
207
208        /// Returns true if this is geometry output suitable for GPU consumption.
209        pub fn is_geometry(&self) -> bool {
210            matches!(self, RenderOutput::Geometry(_))
211        }
212    }
213
214    #[derive(Debug, Clone)]
215    pub struct BitmapData {
216        pub width: u32,
217        pub height: u32,
218        pub format: BitmapFormat,
219        pub data: Vec<u8>,
220    }
221
222    impl BitmapData {
223        /// Returns the heap size in bytes of the pixel data.
224        pub fn byte_size(&self) -> usize {
225            self.data.len()
226        }
227    }
228
229    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
230    pub enum BitmapFormat {
231        Rgba8,
232        Rgb8,
233        Gray8,
234        Gray1,
235    }
236
237    #[derive(Debug, Clone)]
238    pub struct VectorData {
239        pub format: VectorFormat,
240        pub data: String,
241    }
242
243    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
244    pub enum VectorFormat {
245        Svg,
246        Pdf,
247    }
248
249    // =========================================================================
250    // Stage 5 Geometry Types (for GPU pipelines and external tessellators)
251    // =========================================================================
252
253    /// A single path operation for vector glyph outlines.
254    ///
255    /// These primitives match the common subset of path operations supported by
256    /// Cairo, Skia, CoreGraphics, Direct2D, and wgpu tessellators.
257    ///
258    /// Coordinates are in font units (typically 1000 or 2048 per em).
259    /// Scale by `(font_size / units_per_em)` to convert to pixels.
260    #[derive(Debug, Clone, Copy, PartialEq)]
261    pub enum PathOp {
262        /// Move to a new position without drawing (M x y)
263        MoveTo { x: f32, y: f32 },
264        /// Draw a straight line to a point (L x y)
265        LineTo { x: f32, y: f32 },
266        /// Quadratic Bézier curve (Q cx cy x y)
267        QuadTo { cx: f32, cy: f32, x: f32, y: f32 },
268        /// Cubic Bézier curve (C c1x c1y c2x c2y x y)
269        CubicTo {
270            c1x: f32,
271            c1y: f32,
272            c2x: f32,
273            c2y: f32,
274            x: f32,
275            y: f32,
276        },
277        /// Close the current subpath (Z)
278        Close,
279    }
280
281    /// Glyph path data with positioning for a single glyph.
282    ///
283    /// Contains the vector outline of a glyph and its rendered position.
284    /// Can be consumed by external tessellators or GPU pipelines.
285    #[derive(Debug, Clone, PartialEq)]
286    pub struct GlyphPath {
287        /// Glyph ID in the font
288        pub glyph_id: GlyphId,
289        /// X position of the glyph origin (in rendered coordinates)
290        pub x: f32,
291        /// Y position of the glyph origin (in rendered coordinates)
292        pub y: f32,
293        /// Path operations defining the glyph outline (in font units)
294        pub ops: Vec<PathOp>,
295    }
296
297    /// Geometry output for GPU pipelines and vector consumers.
298    ///
299    /// This provides path operations that can be:
300    /// - Tessellated by external libraries (lyon, earcutr)
301    /// - Converted to GPU meshes for wgpu/Vulkan
302    /// - Used for hit testing and text selection
303    /// - Exported to vector formats
304    ///
305    /// # Coordinate Systems
306    ///
307    /// - Path ops are in font units (unscaled)
308    /// - Glyph positions (x, y) are in rendered coordinates (scaled to font_size)
309    /// - Consumer must apply `font_size / units_per_em` scaling to path ops
310    #[derive(Debug, Clone)]
311    pub struct GeometryData {
312        /// Glyph paths with their positions
313        pub glyphs: Vec<GlyphPath>,
314        /// Total advance width in rendered coordinates
315        pub advance_width: f32,
316        /// Total advance height in rendered coordinates
317        pub advance_height: f32,
318        /// Font units per em (for scaling path ops to rendered coordinates)
319        pub units_per_em: u16,
320        /// Font size used for positioning (pixels)
321        pub font_size: f32,
322    }
323
324    impl GeometryData {
325        /// Returns the approximate heap size in bytes.
326        pub fn byte_size(&self) -> usize {
327            self.glyphs
328                .iter()
329                .map(|g| {
330                    std::mem::size_of::<GlyphPath>() + g.ops.len() * std::mem::size_of::<PathOp>()
331                })
332                .sum()
333        }
334
335        /// Returns an iterator over glyph paths.
336        pub fn iter(&self) -> impl Iterator<Item = &GlyphPath> {
337            self.glyphs.iter()
338        }
339
340        /// Scale factor to convert font units to rendered coordinates.
341        pub fn scale(&self) -> f32 {
342            self.font_size / self.units_per_em as f32
343        }
344    }
345
346    /// The source type of glyph data in a font
347    ///
348    /// Different glyph types require different rendering approaches:
349    /// - Outlines can be scaled and exported to SVG paths
350    /// - Bitmaps are pre-rendered at specific sizes
351    /// - COLR/SVG glyphs contain color information
352    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
353    pub enum GlyphType {
354        /// Standard vector outline (glyf/CFF/CFF2 tables)
355        Outline,
356        /// Color glyph using COLR/CPAL tables (v0 or v1)
357        Colr,
358        /// Color glyph using embedded SVG documents
359        Svg,
360        /// Embedded bitmap glyph (CBDT/CBLC tables - Google format)
361        BitmapCbdt,
362        /// Embedded bitmap glyph (EBDT/EBLC tables - legacy format)
363        BitmapEbdt,
364        /// Embedded bitmap glyph (sbix table - Apple format)
365        BitmapSbix,
366        /// Glyph with no data (space, missing glyph, etc.)
367        Empty,
368    }
369
370    impl GlyphType {
371        /// Returns true if this glyph type contains vector outline data
372        pub fn has_outline(&self) -> bool {
373            matches!(self, GlyphType::Outline | GlyphType::Colr)
374        }
375
376        /// Returns true if this glyph type contains bitmap data
377        pub fn is_bitmap(&self) -> bool {
378            matches!(
379                self,
380                GlyphType::BitmapCbdt | GlyphType::BitmapEbdt | GlyphType::BitmapSbix
381            )
382        }
383
384        /// Returns true if this glyph type contains color information
385        pub fn is_color(&self) -> bool {
386            matches!(
387                self,
388                GlyphType::Colr | GlyphType::Svg | GlyphType::BitmapCbdt | GlyphType::BitmapSbix
389            )
390        }
391    }
392
393    #[derive(Debug, Clone)]
394    pub struct SegmentOptions {
395        pub language: Option<String>,
396        pub bidi_resolve: bool,
397        pub font_fallback: bool,
398        pub script_itemize: bool,
399    }
400
401    impl Default for SegmentOptions {
402        fn default() -> Self {
403            Self {
404                language: None,
405                bidi_resolve: true,
406                font_fallback: false,
407                script_itemize: true,
408            }
409        }
410    }
411
412    #[derive(Debug, Clone)]
413    pub struct TextRun {
414        pub text: String,
415        pub start: usize,
416        pub end: usize,
417        pub script: icu_properties::props::Script,
418        pub language: String,
419        pub direction: Direction,
420    }
421}
422
423#[derive(Debug, Clone)]
424pub struct ShapingParams {
425    pub size: f32,
426    pub direction: types::Direction,
427    pub language: Option<String>,
428    pub script: Option<String>,
429    pub features: Vec<(String, u32)>,
430    pub variations: Vec<(String, f32)>,
431    pub letter_spacing: f32,
432}
433
434impl Default for ShapingParams {
435    fn default() -> Self {
436        Self {
437            size: 16.0,
438            direction: types::Direction::LeftToRight,
439            language: None,
440            script: None,
441            features: Vec::new(),
442            variations: Vec::new(),
443            letter_spacing: 0.0,
444        }
445    }
446}
447
448impl ShapingParams {
449    /// Validate shaping parameters against security limits
450    ///
451    /// Returns an error if:
452    /// - Font size is not finite (`NaN`, `+/-inf`)
453    /// - Font size exceeds [`MAX_FONT_SIZE`] (currently 100,000 pixels)
454    /// - Font size is negative or zero
455    ///
456    /// # Example
457    ///
458    /// ```
459    /// use typf_core::ShapingParams;
460    ///
461    /// let params = ShapingParams { size: 48.0, ..Default::default() };
462    /// params.validate().expect("valid params");
463    ///
464    /// let bad_params = ShapingParams { size: 200_000.0, ..Default::default() };
465    /// assert!(bad_params.validate().is_err());
466    /// ```
467    pub fn validate(&self) -> Result<(), error::ShapingError> {
468        if !self.size.is_finite() {
469            return Err(error::ShapingError::BackendError(
470                "Font size must be finite".to_string(),
471            ));
472        }
473        if self.size <= 0.0 {
474            return Err(error::ShapingError::BackendError(
475                "Font size must be positive".to_string(),
476            ));
477        }
478        if self.size > MAX_FONT_SIZE {
479            return Err(error::ShapingError::FontSizeTooLarge(
480                self.size,
481                MAX_FONT_SIZE,
482            ));
483        }
484        Ok(())
485    }
486}
487
488/// Validate glyph count against security limits
489///
490/// Returns an error if glyph count exceeds [`MAX_GLYPH_COUNT`] (currently 10M).
491/// Call this before rendering to prevent resource exhaustion from malicious input.
492///
493/// # Example
494///
495/// ```
496/// use typf_core::{validate_glyph_count, types::ShapingResult};
497///
498/// // In a renderer, before processing:
499/// // validate_glyph_count(shaped.glyphs.len())?;
500/// ```
501pub fn validate_glyph_count(count: usize) -> Result<(), error::RenderError> {
502    if count > MAX_GLYPH_COUNT {
503        return Err(error::RenderError::GlyphCountTooLarge(
504            count,
505            MAX_GLYPH_COUNT,
506        ));
507    }
508    Ok(())
509}
510
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
512pub enum GlyphSource {
513    Glyf,
514    Cff,
515    Cff2,
516    Colr0,
517    Colr1,
518    Svg,
519    Sbix,
520    Cbdt,
521    Ebdt,
522}
523
524const DEFAULT_GLYPH_SOURCES: [GlyphSource; 9] = [
525    GlyphSource::Glyf,
526    GlyphSource::Cff2,
527    GlyphSource::Cff,
528    GlyphSource::Colr1,
529    GlyphSource::Colr0,
530    GlyphSource::Svg,
531    GlyphSource::Sbix,
532    GlyphSource::Cbdt,
533    GlyphSource::Ebdt,
534];
535
536/// Preference ordering and deny list for glyph sources
537#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct GlyphSourcePreference {
539    pub prefer: Vec<GlyphSource>,
540    pub deny: HashSet<GlyphSource>,
541}
542
543impl GlyphSourcePreference {
544    /// Build a preference list with an optional deny set.
545    ///
546    /// - Empty `prefer` uses the default outline-first order.
547    /// - Duplicates are removed while keeping first-seen order.
548    /// - Denied sources are removed from the preferred list.
549    pub fn from_parts(
550        prefer: Vec<GlyphSource>,
551        deny: impl IntoIterator<Item = GlyphSource>,
552    ) -> Self {
553        let deny: HashSet<GlyphSource> = deny.into_iter().collect();
554        let source_order = if prefer.is_empty() {
555            DEFAULT_GLYPH_SOURCES.to_vec()
556        } else {
557            prefer
558        };
559
560        let mut seen = HashSet::new();
561        let mut normalized = Vec::new();
562
563        for source in source_order {
564            if deny.contains(&source) {
565                continue;
566            }
567            if seen.insert(source) {
568                normalized.push(source);
569            }
570        }
571
572        Self {
573            prefer: normalized,
574            deny,
575        }
576    }
577
578    /// Effective order with current denies applied.
579    pub fn effective_order(&self) -> Vec<GlyphSource> {
580        self.prefer
581            .iter()
582            .copied()
583            .filter(|src| !self.deny.contains(src))
584            .collect()
585    }
586}
587
588impl Default for GlyphSourcePreference {
589    fn default() -> Self {
590        Self::from_parts(DEFAULT_GLYPH_SOURCES.to_vec(), [])
591    }
592}
593
594#[derive(Debug, Clone)]
595pub struct RenderParams {
596    pub foreground: Color,
597    pub background: Option<Color>,
598    pub padding: u32,
599    pub antialias: bool,
600    /// Variable font variations like [("wght", 700.0), ("wdth", 100.0)]
601    /// Variable fonts need specific coordinates to render correctly
602    pub variations: Vec<(String, f32)>,
603    /// CPAL color palette index for COLR color glyphs (0 = default palette)
604    pub color_palette: u16,
605    /// Allowed glyph sources (order + deny list)
606    pub glyph_sources: GlyphSourcePreference,
607    /// Desired render output mode (bitmap or vector)
608    pub output: RenderMode,
609}
610
611impl Default for RenderParams {
612    fn default() -> Self {
613        Self {
614            foreground: Color::black(),
615            background: None,
616            padding: 0,
617            antialias: true,
618            variations: Vec::new(),
619            color_palette: 0,
620            glyph_sources: GlyphSourcePreference::default(),
621            output: RenderMode::Bitmap,
622        }
623    }
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
627pub enum RenderMode {
628    /// Raster output (default)
629    Bitmap,
630    /// Vector output (currently SVG only)
631    Vector(types::VectorFormat),
632}
633
634#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
635pub struct Color {
636    pub r: u8,
637    pub g: u8,
638    pub b: u8,
639    pub a: u8,
640}
641
642impl Color {
643    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
644        Self { r, g, b, a }
645    }
646
647    pub const fn black() -> Self {
648        Self::rgba(0, 0, 0, 255)
649    }
650
651    pub const fn white() -> Self {
652        Self::rgba(255, 255, 255, 255)
653    }
654}
655
656#[cfg(test)]
657#[allow(clippy::expect_used, clippy::panic)]
658mod tests {
659    use super::types::*;
660    use super::{error::ShapingError, ShapingParams, MAX_FONT_SIZE};
661
662    #[test]
663    fn test_path_op_size() {
664        // PathOp should be reasonably compact for efficient storage
665        assert!(std::mem::size_of::<PathOp>() <= 32);
666    }
667
668    #[test]
669    fn test_geometry_data_byte_size() {
670        let geometry = GeometryData {
671            glyphs: vec![
672                GlyphPath {
673                    glyph_id: 1,
674                    x: 0.0,
675                    y: 0.0,
676                    ops: vec![
677                        PathOp::MoveTo { x: 0.0, y: 0.0 },
678                        PathOp::LineTo { x: 100.0, y: 0.0 },
679                        PathOp::Close,
680                    ],
681                },
682                GlyphPath {
683                    glyph_id: 2,
684                    x: 50.0,
685                    y: 0.0,
686                    ops: vec![
687                        PathOp::MoveTo { x: 0.0, y: 0.0 },
688                        PathOp::QuadTo {
689                            cx: 50.0,
690                            cy: 100.0,
691                            x: 100.0,
692                            y: 0.0,
693                        },
694                        PathOp::Close,
695                    ],
696                },
697            ],
698            advance_width: 100.0,
699            advance_height: 0.0,
700            units_per_em: 1000,
701            font_size: 16.0,
702        };
703
704        // byte_size should be non-zero and reasonable
705        let size = geometry.byte_size();
706        assert!(size > 0);
707        assert!(size < 1024); // Should be small for this test case
708    }
709
710    #[test]
711    fn test_geometry_data_scale() {
712        let geometry = GeometryData {
713            glyphs: vec![],
714            advance_width: 0.0,
715            advance_height: 0.0,
716            units_per_em: 2048,
717            font_size: 16.0,
718        };
719
720        let scale = geometry.scale();
721        assert!((scale - 16.0 / 2048.0).abs() < 0.0001);
722    }
723
724    #[test]
725    fn test_render_output_geometry_variant() {
726        let geometry = GeometryData {
727            glyphs: vec![GlyphPath {
728                glyph_id: 42,
729                x: 0.0,
730                y: 0.0,
731                ops: vec![PathOp::MoveTo { x: 0.0, y: 0.0 }, PathOp::Close],
732            }],
733            advance_width: 50.0,
734            advance_height: 0.0,
735            units_per_em: 1000,
736            font_size: 24.0,
737        };
738
739        let output = RenderOutput::Geometry(geometry);
740        assert!(output.is_geometry());
741        assert!(output.byte_size() > 0);
742    }
743
744    #[test]
745    fn test_shaping_params_validate_when_non_finite_size_then_error() {
746        for size in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
747            let params = ShapingParams {
748                size,
749                ..Default::default()
750            };
751            let error = params.validate().expect_err("non-finite size must fail");
752            match error {
753                ShapingError::BackendError(message) => {
754                    assert!(
755                        message.contains("finite"),
756                        "expected finite-size guidance, got: {}",
757                        message
758                    );
759                },
760                other => panic!("expected BackendError, got {:?}", other),
761            }
762        }
763    }
764
765    #[test]
766    fn test_shaping_params_validate_when_positive_size_then_ok() {
767        let params = ShapingParams {
768            size: 24.0,
769            ..Default::default()
770        };
771        params
772            .validate()
773            .expect("positive finite size should validate");
774    }
775
776    #[test]
777    fn test_shaping_params_validate_when_size_above_max_then_error() {
778        let params = ShapingParams {
779            size: MAX_FONT_SIZE + 1.0,
780            ..Default::default()
781        };
782        let error = params
783            .validate()
784            .expect_err("oversized font size must fail validation");
785        match error {
786            ShapingError::FontSizeTooLarge(size, max) => {
787                assert!(
788                    size > max,
789                    "expected reported size {} to exceed max {}",
790                    size,
791                    max
792                );
793            },
794            other => panic!("expected FontSizeTooLarge, got {:?}", other),
795        }
796    }
797}