Skip to main content

typg_python/
lib.rs

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