Skip to main content

typg_python/
lib.rs

1//! Python bindings for typg-core - where font discovery becomes genuinely pleasant.
2//!
3//! Finding the right font shouldn't feel like solving a mystery. These PyO3 bindings
4//! create a smooth conversation between Python and Rust, letting you ask the typg-core
5//! engine for exactly the fonts you need. Think of it as having a remarkably organized
6//! friend who knows where every font lives - no frantic searching required.
7//!
8//! Built by FontLab (https://www.fontlab.com/) - people who understand fonts are just
9//! characters with remarkable personalities.
10
11use std::ops::RangeInclusive;
12use std::path::{Path, PathBuf};
13
14use anyhow::{anyhow, Result};
15use pyo3::exceptions::PyValueError;
16use pyo3::prelude::*;
17use pyo3::types::PyDict;
18use regex::Regex;
19use typg_core::query::{
20    parse_codepoint_list, parse_family_class, parse_tag_list, parse_u16_range, FamilyClassFilter,
21    Query,
22};
23use typg_core::search::{
24    filter_cached, search, SearchOptions, TypgFontFaceMatch, TypgFontFaceMeta, TypgFontSource,
25};
26use typg_core::tags::tag_to_string;
27
28#[cfg(feature = "hpindex")]
29use typg_core::index::FontIndex;
30
31/// A font's comprehensive profile for thoughtful matchmaking.
32///
33/// Contains every essential detail needed for font filtering operations. Like a
34/// well-crafted dating profile that answers all the important questions upfront -
35/// location, capabilities, special talents, and whether this font enjoys changing
36/// its appearance on demand.
37#[derive(Clone, Debug, FromPyObject)]
38struct MetadataInput {
39    /// Absolute path where this font resides on disk
40    path: PathBuf,
41    /// All known names for this font family
42    #[pyo3(default)]
43    names: Vec<String>,
44    /// Variable font variation axes supported
45    #[pyo3(default)]
46    axis_tags: Vec<String>,
47    /// OpenType feature tags available for advanced typography
48    #[pyo3(default)]
49    feature_tags: Vec<String>,
50    /// Script tags indicating supported writing systems
51    #[pyo3(default)]
52    script_tags: Vec<String>,
53    /// Font table tags included in this font file
54    #[pyo3(default)]
55    table_tags: Vec<String>,
56    /// Unicode characters this font can render
57    #[pyo3(default)]
58    codepoints: Vec<String>,
59    /// Indicates whether this is a variable font
60    #[pyo3(default)]
61    is_variable: bool,
62    /// TTC collection index if font is part of a collection
63    #[pyo3(default)]
64    ttc_index: Option<u32>,
65    /// Font weight class (100-900, where 400 is regular)
66    #[pyo3(default)]
67    weight_class: Option<u16>,
68    /// Font width class (1-9, where 5 is normal)
69    #[pyo3(default)]
70    width_class: Option<u16>,
71    /// Font family class classification bits
72    #[pyo3(default)]
73    family_class: Option<u16>,
74    /// Creator-related name strings (copyright, trademark, manufacturer, etc.)
75    #[pyo3(default)]
76    creator_names: Vec<String>,
77    /// License-related name strings (copyright, license, license URL)
78    #[pyo3(default)]
79    license_names: Vec<String>,
80}
81
82/// Search directories for fonts matching your specifications.
83///
84/// Scans the provided filesystem paths to locate fonts that meet your criteria.
85/// This function handles the heavy lifting of directory traversal and font parsing,
86/// returning comprehensive metadata for each match. Performance scales with the
87/// number of worker threads specified.
88#[pyfunction]
89#[pyo3(
90    signature = (
91        paths,
92        axes=None,
93        features=None,
94        scripts=None,
95        tables=None,
96        names=None,
97        codepoints=None,
98        text=None,
99        weight=None,
100        width=None,
101        family_class=None,
102        creator=None,
103        license=None,
104        variable=false,
105        follow_symlinks=false,
106        jobs=None
107    )
108)]
109#[allow(clippy::too_many_arguments)]
110fn find_py(
111    py: Python<'_>,
112    paths: Vec<PathBuf>,
113    axes: Option<Vec<String>>,
114    features: Option<Vec<String>>,
115    scripts: Option<Vec<String>>,
116    tables: Option<Vec<String>>,
117    names: Option<Vec<String>>,
118    codepoints: Option<Vec<String>>,
119    text: Option<String>,
120    weight: Option<String>,
121    width: Option<String>,
122    family_class: Option<String>,
123    creator: Option<Vec<String>>,
124    license: Option<Vec<String>>,
125    variable: bool,
126    follow_symlinks: bool,
127    jobs: Option<usize>,
128) -> PyResult<Vec<Py<PyAny>>> {
129    // Can't search the void - need at least one place to look
130    if paths.is_empty() {
131        return Err(PyValueError::new_err(
132            "at least one search path is required",
133        ));
134    }
135
136    // Zero workers means nobody gets the job done
137    if matches!(jobs, Some(0)) {
138        return Err(PyValueError::new_err(
139            "jobs must be at least 1 when provided",
140        ));
141    }
142
143    // Build our font detective's search warrant
144    let query = build_query(
145        axes,
146        features,
147        scripts,
148        tables,
149        names,
150        codepoints,
151        text,
152        weight,
153        width,
154        family_class,
155        creator,
156        license,
157        variable,
158    )
159    .map_err(to_py_err)?;
160
161    // Configure the search team
162    let opts = SearchOptions {
163        follow_symlinks,
164        jobs,
165    };
166
167    // Send out the search party and bring back the suspects
168    let matches = search(&paths, &query, &opts).map_err(to_py_err)?;
169    to_py_matches(py, matches)
170}
171
172/// Search directories and return only font file paths.
173///
174/// Optimized version of find_py that returns just the file paths instead of
175/// full metadata. This reduces memory usage and processing overhead when you
176/// only need locations, such as when building font catalogs or performing
177/// batch operations.
178#[pyfunction]
179#[pyo3(
180    signature = (
181        paths,
182        axes=None,
183        features=None,
184        scripts=None,
185        tables=None,
186        names=None,
187        codepoints=None,
188        text=None,
189        weight=None,
190        width=None,
191        family_class=None,
192        creator=None,
193        license=None,
194        variable=false,
195        follow_symlinks=false,
196        jobs=None
197    )
198)]
199#[allow(clippy::too_many_arguments)]
200fn find_paths_py(
201    paths: Vec<PathBuf>,
202    axes: Option<Vec<String>>,
203    features: Option<Vec<String>>,
204    scripts: Option<Vec<String>>,
205    tables: Option<Vec<String>>,
206    names: Option<Vec<String>>,
207    codepoints: Option<Vec<String>>,
208    text: Option<String>,
209    weight: Option<String>,
210    width: Option<String>,
211    family_class: Option<String>,
212    creator: Option<Vec<String>>,
213    license: Option<Vec<String>>,
214    variable: bool,
215    follow_symlinks: bool,
216    jobs: Option<usize>,
217) -> PyResult<Vec<String>> {
218    // Same validation as its fancier cousin - no empty searches
219    if paths.is_empty() {
220        return Err(PyValueError::new_err(
221            "at least one search path is required",
222        ));
223    }
224
225    // We need at least one worker bee in the hive
226    if matches!(jobs, Some(0)) {
227        return Err(PyValueError::new_err(
228            "jobs must be at least 1 when provided",
229        ));
230    }
231
232    // Build the same fancy query, but we'll only use the addresses
233    let query = build_query(
234        axes,
235        features,
236        scripts,
237        tables,
238        names,
239        codepoints,
240        text,
241        weight,
242        width,
243        family_class,
244        creator,
245        license,
246        variable,
247    )
248    .map_err(to_py_err)?;
249
250    // Search with the usual suspects
251    let opts = SearchOptions {
252        follow_symlinks,
253        jobs,
254    };
255    let matches = search(&paths, &query, &opts).map_err(to_py_err)?;
256
257    // Strip down to just the raw file paths - no frills attached
258    Ok(matches
259        .into_iter()
260        .map(|m| m.source.path_with_index())
261        .collect())
262}
263
264/// Filter pre-collected font metadata without filesystem access.
265///
266/// Operates entirely in memory on cached font metadata, avoiding expensive
267/// filesystem operations. Ideal for font managers or applications that maintain
268/// their own font databases and need fast filtering capabilities without disk I/O.
269#[pyfunction]
270#[pyo3(
271    signature = (
272        entries,
273        axes=None,
274        features=None,
275        scripts=None,
276        tables=None,
277        names=None,
278        codepoints=None,
279        text=None,
280        weight=None,
281        width=None,
282        family_class=None,
283        creator=None,
284        license=None,
285        variable=false
286    )
287)]
288#[allow(clippy::too_many_arguments)]
289fn filter_cached_py(
290    py: Python<'_>,
291    entries: Vec<MetadataInput>,
292    axes: Option<Vec<String>>,
293    features: Option<Vec<String>>,
294    scripts: Option<Vec<String>>,
295    tables: Option<Vec<String>>,
296    names: Option<Vec<String>>,
297    codepoints: Option<Vec<String>>,
298    text: Option<String>,
299    weight: Option<String>,
300    width: Option<String>,
301    family_class: Option<String>,
302    creator: Option<Vec<String>>,
303    license: Option<Vec<String>>,
304    variable: bool,
305) -> PyResult<Vec<Py<PyAny>>> {
306    // Convert the Python-friendly format to our internal type system
307    let metadata = convert_metadata(entries).map_err(to_py_err)?;
308
309    // Build the filter criteria - like a VIP list for fonts
310    let query = build_query(
311        axes,
312        features,
313        scripts,
314        tables,
315        names,
316        codepoints,
317        text,
318        weight,
319        width,
320        family_class,
321        creator,
322        license,
323        variable,
324    )
325    .map_err(to_py_err)?;
326
327    // Let the bouncer do the filtering - no filesystem required
328    let matches = filter_cached(&metadata, &query);
329    to_py_matches(py, matches)
330}
331
332/// Search fonts using a high-performance indexed database.
333///
334/// Leverages LMDB-based indexing for millisecond query performance across
335/// thousands of fonts. The indexed database enables complex searches with
336/// minimal overhead, making it ideal for applications requiring frequent
337/// font queries.
338///
339/// Requires compilation with the hpindex feature flag enabled.
340#[cfg(feature = "hpindex")]
341#[pyfunction]
342#[pyo3(
343    signature = (
344        index_path,
345        axes=None,
346        features=None,
347        scripts=None,
348        tables=None,
349        names=None,
350        codepoints=None,
351        text=None,
352        weight=None,
353        width=None,
354        family_class=None,
355        creator=None,
356        license=None,
357        variable=false
358    )
359)]
360#[allow(clippy::too_many_arguments)]
361fn find_indexed_py(
362    py: Python<'_>,
363    index_path: PathBuf,
364    axes: Option<Vec<String>>,
365    features: Option<Vec<String>>,
366    scripts: Option<Vec<String>>,
367    tables: Option<Vec<String>>,
368    names: Option<Vec<String>>,
369    codepoints: Option<Vec<String>>,
370    text: Option<String>,
371    weight: Option<String>,
372    width: Option<String>,
373    family_class: Option<String>,
374    creator: Option<Vec<String>>,
375    license: Option<Vec<String>>,
376    variable: bool,
377) -> PyResult<Vec<Py<PyAny>>> {
378    // Build our search criteria for the indexed database
379    let query = build_query(
380        axes,
381        features,
382        scripts,
383        tables,
384        names,
385        codepoints,
386        text,
387        weight,
388        width,
389        family_class,
390        creator,
391        license,
392        variable,
393    )
394    .map_err(to_py_err)?;
395
396    // Fire up the high-performance index and grab a read-only ticket
397    let index = FontIndex::open(&index_path).map_err(to_py_err)?;
398    let reader = index.reader().map_err(to_py_err)?;
399
400    // Let the turbocharged database do its magic
401    let matches = reader.find(&query).map_err(to_py_err)?;
402    to_py_matches(py, matches)
403}
404
405/// List all fonts currently indexed in the database.
406///
407/// Returns metadata for every font stored in the indexed database without
408/// applying any filters. Useful for inventory management, catalog generation,
409/// or when you need a complete overview of available fonts.
410///
411/// Requires compilation with the hpindex feature flag enabled.
412#[cfg(feature = "hpindex")]
413#[pyfunction]
414fn list_indexed_py(py: Python<'_>, index_path: PathBuf) -> PyResult<Vec<Py<PyAny>>> {
415    // Open the database and get our VIP pass
416    let index = FontIndex::open(&index_path).map_err(to_py_err)?;
417    let reader = index.reader().map_err(to_py_err)?;
418
419    // Roll out the red carpet for every font in the house
420    let matches = reader.list_all().map_err(to_py_err)?;
421    to_py_matches(py, matches)
422}
423
424/// Return the total number of fonts in the indexed database.
425///
426/// Provides a fast count of all fonts stored in the database without loading
427/// metadata. Useful for progress indicators, statistics display, or quick
428/// database size verification.
429///
430/// Requires compilation with the hpindex feature flag enabled.
431#[cfg(feature = "hpindex")]
432#[pyfunction]
433fn count_indexed_py(index_path: PathBuf) -> PyResult<usize> {
434    // Open up the database and ask for the head count
435    let index = FontIndex::open(&index_path).map_err(to_py_err)?;
436    index.count().map_err(to_py_err)
437}
438
439/// Convert Python metadata format to internal Rust structures.
440///
441/// Transforms Python-friendly input types into the strongly-typed structures
442/// used internally by the search engine. This conversion layer handles type
443/// safety and data normalization between the Python interface and Rust core.
444fn convert_metadata(entries: Vec<MetadataInput>) -> Result<Vec<TypgFontFaceMatch>> {
445    entries
446        .into_iter()
447        .map(|entry| {
448            // Make sure we always have at least one name for this font
449            let mut names = entry.names;
450            if names.is_empty() {
451                names.push(default_name(&entry.path));
452            }
453
454            // Build the complete font profile in our internal format
455            Ok(TypgFontFaceMatch {
456                source: TypgFontSource {
457                    path: entry.path,
458                    ttc_index: entry.ttc_index,
459                },
460                metadata: TypgFontFaceMeta {
461                    names,
462                    axis_tags: parse_tag_list(&entry.axis_tags)?,
463                    feature_tags: parse_tag_list(&entry.feature_tags)?,
464                    script_tags: parse_tag_list(&entry.script_tags)?,
465                    table_tags: parse_tag_list(&entry.table_tags)?,
466                    codepoints: parse_codepoints(&entry.codepoints)?,
467                    is_variable: entry.is_variable,
468                    weight_class: entry.weight_class,
469                    width_class: entry.width_class,
470                    family_class: entry
471                        .family_class
472                        .map(|raw| (((raw >> 8) & 0xFF) as u8, (raw & 0x00FF) as u8)),
473                    creator_names: entry.creator_names,
474                    license_names: entry.license_names,
475                },
476            })
477        })
478        .collect()
479}
480
481/// Generate a fallback name from the font file path.
482///
483/// Extracts a display name from the filename when font metadata doesn't
484/// include names. Uses the file stem (filename without extension) as the
485/// primary source, falling back to the full path if necessary.
486fn default_name(path: &Path) -> String {
487    path.file_stem()
488        .map(|s| s.to_string_lossy().to_string())
489        .unwrap_or_else(|| path.display().to_string())
490}
491
492/// Build a search query from optional filter parameters.
493///
494/// Assembles the various optional filter parameters into a complete Query
495/// structure for the font search engine. Handles parsing of tag lists, ranges,
496/// and other criteria into their proper internal representations.
497#[allow(clippy::too_many_arguments)]
498fn build_query(
499    axes: Option<Vec<String>>,
500    features: Option<Vec<String>>,
501    scripts: Option<Vec<String>>,
502    tables: Option<Vec<String>>,
503    names: Option<Vec<String>>,
504    codepoints: Option<Vec<String>>,
505    text: Option<String>,
506    weight: Option<String>,
507    width: Option<String>,
508    family_class: Option<String>,
509    creator: Option<Vec<String>>,
510    license: Option<Vec<String>>,
511    variable: bool,
512) -> Result<Query> {
513    // Parse all the tag lists - turning strings into proper typed tags
514    let axes = parse_tag_list(&axes.unwrap_or_default())?;
515    let features = parse_tag_list(&features.unwrap_or_default())?;
516    let scripts = parse_tag_list(&scripts.unwrap_or_default())?;
517    let tables = parse_tag_list(&tables.unwrap_or_default())?;
518    let name_patterns = compile_patterns(&names.unwrap_or_default())?;
519    let creator_patterns = compile_patterns(&creator.unwrap_or_default())?;
520    let license_patterns = compile_patterns(&license.unwrap_or_default())?;
521
522    // Handle the optional numeric filters - ranges can be tricky
523    let weight_range = parse_optional_range(weight)?;
524    let width_range = parse_optional_range(width)?;
525    let family_class = parse_optional_family_class(family_class)?;
526
527    // Mix explicit codepoints with any text characters provided
528    let mut cps = parse_codepoints(&codepoints.unwrap_or_default())?;
529    if let Some(text) = text {
530        cps.extend(text.chars());
531    }
532    dedup_chars(&mut cps);
533
534    // Assemble the final query with all our carefully parsed components
535    Ok(Query::new()
536        .with_axes(axes)
537        .with_features(features)
538        .with_scripts(scripts)
539        .with_tables(tables)
540        .with_name_patterns(name_patterns)
541        .with_creator_patterns(creator_patterns)
542        .with_license_patterns(license_patterns)
543        .with_codepoints(cps)
544        .require_variable(variable)
545        .with_weight_range(weight_range)
546        .with_width_range(width_range)
547        .with_family_class(family_class))
548}
549
550/// Parse string character specifications into Unicode characters.
551///
552/// Converts string-based character specifications (single chars, ranges, or
553/// hex codes) into actual Unicode values for the search engine. Supports
554/// multiple input formats for flexible character selection.
555fn parse_codepoints(raw: &[String]) -> Result<Vec<char>> {
556    let mut cps = Vec::new();
557    for chunk in raw {
558        cps.extend(parse_codepoint_list(chunk)?);
559    }
560    Ok(cps)
561}
562
563/// Parse optional numeric range from string input.
564///
565/// Handles conversion from string representation to inclusive numeric range.
566/// Returns None for empty input, allowing callers to distinguish between
567/// "no range specified" and "invalid range format".
568fn parse_optional_range(raw: Option<String>) -> Result<Option<RangeInclusive<u16>>> {
569    match raw {
570        Some(value) => Ok(Some(parse_u16_range(&value)?)),
571        None => Ok(None),
572    }
573}
574
575/// Parse optional family class filter from string input.
576///
577/// Handles family class filter parsing with proper error handling.
578/// Returns None for empty input, maintaining consistency with other
579/// optional parameter parsers.
580fn parse_optional_family_class(raw: Option<String>) -> Result<Option<FamilyClassFilter>> {
581    match raw {
582        Some(value) => Ok(Some(parse_family_class(&value)?)),
583        None => Ok(None),
584    }
585}
586
587/// Compile string patterns into regex objects for name matching.
588///
589/// Transforms pattern strings into compiled regular expressions for
590/// efficient font name filtering. Provides detailed error messages
591/// for invalid regex syntax to help developers debug patterns.
592fn compile_patterns(patterns: &[String]) -> Result<Vec<Regex>> {
593    patterns
594        .iter()
595        .map(|p| Regex::new(p).map_err(|e| anyhow!("invalid regex {p}: {e}")))
596        .collect()
597}
598
599/// Remove duplicate characters from the codepoint list.
600///
601/// Deduplicates the character list to optimize search performance and
602/// avoid redundant matches. Sorts characters first for efficient
603/// deduplication using the vector's built-in method.
604fn dedup_chars(cps: &mut Vec<char>) {
605    cps.sort();
606    cps.dedup();
607}
608
609/// Convert internal font match structures to Python dictionaries.
610///
611/// Transforms Rust's TypgFontFaceMatch structures into Python dictionary
612/// objects for easy consumption by Python code. Handles conversion of
613/// typed fields to appropriate Python types and maintains nested structure.
614fn to_py_matches(py: Python<'_>, matches: Vec<TypgFontFaceMatch>) -> PyResult<Vec<Py<PyAny>>> {
615    matches
616        .into_iter()
617        .map(|item| {
618            let meta = &item.metadata;
619
620            let meta_dict = PyDict::new(py);
621            meta_dict.set_item("names", meta.names.clone())?;
622            meta_dict.set_item(
623                "axis_tags",
624                meta.axis_tags
625                    .iter()
626                    .map(|t| tag_to_string(*t))
627                    .collect::<Vec<_>>(),
628            )?;
629            meta_dict.set_item(
630                "feature_tags",
631                meta.feature_tags
632                    .iter()
633                    .map(|t| tag_to_string(*t))
634                    .collect::<Vec<_>>(),
635            )?;
636            meta_dict.set_item(
637                "script_tags",
638                meta.script_tags
639                    .iter()
640                    .map(|t| tag_to_string(*t))
641                    .collect::<Vec<_>>(),
642            )?;
643            meta_dict.set_item(
644                "table_tags",
645                meta.table_tags
646                    .iter()
647                    .map(|t| tag_to_string(*t))
648                    .collect::<Vec<_>>(),
649            )?;
650            meta_dict.set_item(
651                "codepoints",
652                meta.codepoints
653                    .iter()
654                    .map(|c| c.to_string())
655                    .collect::<Vec<_>>(),
656            )?;
657            meta_dict.set_item("is_variable", meta.is_variable)?;
658            meta_dict.set_item("weight_class", meta.weight_class)?;
659            meta_dict.set_item("width_class", meta.width_class)?;
660            meta_dict.set_item("family_class", meta.family_class)?;
661            meta_dict.set_item("creator_names", meta.creator_names.clone())?;
662            meta_dict.set_item("license_names", meta.license_names.clone())?;
663
664            let outer = PyDict::new(py);
665            outer.set_item("path", item.source.path.to_string_lossy().to_string())?;
666            outer.set_item("ttc_index", item.source.ttc_index)?;
667            outer.set_item("metadata", meta_dict)?;
668
669            Ok(outer.into_any().unbind())
670        })
671        .collect()
672}
673
674/// Convert Rust error types to Python ValueError exceptions.
675///
676/// Transforms Rust's anyhow::Error into Python's ValueError with a
677/// readable message. This bridge function ensures error information
678/// flows correctly from Rust to Python while maintaining stack traces.
679fn to_py_err(err: anyhow::Error) -> PyErr {
680    PyValueError::new_err(err.to_string())
681}
682
683/// Register all Python-exposed functions with the module.
684///
685/// Creates the Python module interface by registering each function with
686/// appropriate names. Conditionally includes indexed search functions
687/// when the hpindex feature flag is enabled at compile time.
688#[pymodule]
689#[pyo3(name = "_typg_python")]
690fn typg_python(_py: Python<'_>, m: &Bound<PyModule>) -> PyResult<()> {
691    // Core search functions - always available
692    m.add_function(wrap_pyfunction!(find_py, m)?)?;
693    m.add_function(wrap_pyfunction!(find_paths_py, m)?)?;
694    m.add_function(wrap_pyfunction!(filter_cached_py, m)?)?;
695
696    // Indexed search functions - requires hpindex feature flag
697    #[cfg(feature = "hpindex")]
698    {
699        m.add_function(wrap_pyfunction!(find_indexed_py, m)?)?;
700        m.add_function(wrap_pyfunction!(list_indexed_py, m)?)?;
701        m.add_function(wrap_pyfunction!(count_indexed_py, m)?)?;
702    }
703
704    Ok(())
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710
711    fn metadata(path: &str, names: &[&str], axes: &[&str], variable: bool) -> MetadataInput {
712        MetadataInput {
713            path: PathBuf::from(path),
714            names: names.iter().map(|s| s.to_string()).collect(),
715            axis_tags: axes.iter().map(|s| s.to_string()).collect(),
716            feature_tags: Vec::new(),
717            script_tags: Vec::new(),
718            table_tags: Vec::new(),
719            codepoints: vec!["A".into()],
720            is_variable: variable,
721            ttc_index: None,
722            weight_class: None,
723            width_class: None,
724            family_class: None,
725            creator_names: Vec::new(),
726            license_names: Vec::new(),
727        }
728    }
729
730    #[test]
731    fn filter_cached_filters_axes_and_names() {
732        Python::initialize();
733        Python::attach(|py| {
734            let entries = vec![
735                metadata("VariableVF.ttf", &["Pro VF"], &["wght"], true),
736                metadata("Static.ttf", &["Static Sans"], &[], false),
737            ];
738
739            let result = filter_cached_py(
740                py,
741                entries,
742                Some(vec!["wght".into()]),
743                None,
744                None,
745                None,
746                Some(vec!["Pro".into()]),
747                None,
748                None,
749                None,
750                None,
751                None,
752                None,
753                None,
754                true,
755            );
756
757            assert!(result.is_ok(), "expected Ok from filter_cached_py");
758            let objs = result.unwrap();
759            assert_eq!(objs.len(), 1, "only variable font with axis should match");
760            let py_any: Py<PyAny> = objs[0].clone_ref(py);
761            let bound_any = py_any.bind(py);
762            let dict = bound_any.downcast::<PyDict>().unwrap();
763            assert_eq!(
764                dict.get_item("path")
765                    .expect("path lookup")
766                    .expect("path field")
767                    .extract::<String>()
768                    .unwrap(),
769                "VariableVF.ttf"
770            );
771        });
772    }
773
774    #[test]
775    fn invalid_tag_returns_error() {
776        Python::initialize();
777        Python::attach(|py| {
778            let err = filter_cached_py(
779                py,
780                vec![metadata("Bad.ttf", &["Bad"], &["wght"], true)],
781                Some(vec!["abcde".into()]),
782                None,
783                None,
784                None,
785                None,
786                None,
787                None,
788                None,
789                None,
790                None,
791                None,
792                None,
793                false,
794            )
795            .unwrap_err();
796
797            let message = format!("{err}");
798            assert!(
799                message.contains("tag") || message.contains("invalid"),
800                "error message should mention invalid tag, got: {message}"
801            );
802        });
803    }
804
805    #[test]
806    fn find_requires_paths() {
807        Python::initialize();
808        Python::attach(|py| {
809            let err = find_py(
810                py,
811                Vec::new(),
812                None,
813                None,
814                None,
815                None,
816                None,
817                None,
818                None,
819                None,
820                None,
821                None,
822                None,
823                None,
824                false,
825                false,
826                None,
827            )
828            .unwrap_err();
829
830            assert!(
831                format!("{err}").contains("path"),
832                "should mention missing paths"
833            );
834        });
835    }
836
837    #[test]
838    fn find_paths_requires_paths() {
839        Python::initialize();
840        Python::attach(|_| {
841            let err = find_paths_py(
842                Vec::new(),
843                None,
844                None,
845                None,
846                None,
847                None,
848                None,
849                None,
850                None,
851                None,
852                None,
853                None,
854                None,
855                false,
856                false,
857                None,
858            )
859            .unwrap_err();
860
861            assert!(format!("{err}").contains("path"));
862        });
863    }
864
865    #[cfg(feature = "hpindex")]
866    #[test]
867    fn indexed_search_returns_results() {
868        use read_fonts::types::Tag;
869        use std::time::SystemTime;
870        use tempfile::TempDir;
871        use typg_core::index::FontIndex;
872
873        // Keep TempDir alive for the entire test.
874        let dir = TempDir::new().unwrap();
875        let index_path = dir.path().to_path_buf();
876
877        // Create an index with a mock font entry.
878        {
879            let index = FontIndex::open(&index_path).unwrap();
880            let mut writer = index.writer().unwrap();
881            writer
882                .add_font(
883                    Path::new("/test/IndexedFont.ttf"),
884                    None,
885                    SystemTime::UNIX_EPOCH,
886                    vec!["Indexed Font".to_string()],
887                    &[Tag::new(b"wght")],
888                    &[Tag::new(b"smcp")],
889                    &[Tag::new(b"latn")],
890                    &[],
891                    &['a', 'b', 'c'],
892                    true,
893                    Some(400),
894                    Some(5),
895                    None,
896                )
897                .unwrap();
898            writer.commit().unwrap();
899        }
900
901        // Test the bindings (dir is still alive here).
902        Python::initialize();
903        Python::attach(|py| {
904            // Test count_indexed_py.
905            let count = count_indexed_py(index_path.clone()).unwrap();
906            assert_eq!(count, 1);
907
908            // Test list_indexed_py.
909            let all = list_indexed_py(py, index_path.clone()).unwrap();
910            assert_eq!(all.len(), 1);
911
912            // Test find_indexed_py with matching filter.
913            let matches = find_indexed_py(
914                py,
915                index_path.clone(),
916                Some(vec!["wght".into()]),
917                Some(vec!["smcp".into()]),
918                None,
919                None,
920                None,
921                None,
922                None,
923                None,
924                None,
925                None,
926                None,
927                None,
928                true,
929            )
930            .unwrap();
931            assert_eq!(matches.len(), 1);
932
933            // Test find_indexed_py with non-matching filter.
934            let no_matches = find_indexed_py(
935                py,
936                index_path.clone(),
937                None,
938                Some(vec!["liga".into()]), // Not in the index
939                None,
940                None,
941                None,
942                None,
943                None,
944                None,
945                None,
946                None,
947                None,
948                None,
949                false,
950            )
951            .unwrap();
952            assert_eq!(no_matches.len(), 0);
953        });
954
955        // dir is dropped here, after all tests complete.
956    }
957}