1use 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#[derive(Clone, Debug, FromPyObject)]
38struct MetadataInput {
39 path: PathBuf,
41 #[pyo3(default)]
43 names: Vec<String>,
44 #[pyo3(default)]
46 axis_tags: Vec<String>,
47 #[pyo3(default)]
49 feature_tags: Vec<String>,
50 #[pyo3(default)]
52 script_tags: Vec<String>,
53 #[pyo3(default)]
55 table_tags: Vec<String>,
56 #[pyo3(default)]
58 codepoints: Vec<String>,
59 #[pyo3(default)]
61 is_variable: bool,
62 #[pyo3(default)]
64 ttc_index: Option<u32>,
65 #[pyo3(default)]
67 weight_class: Option<u16>,
68 #[pyo3(default)]
70 width_class: Option<u16>,
71 #[pyo3(default)]
73 family_class: Option<u16>,
74 #[pyo3(default)]
76 creator_names: Vec<String>,
77 #[pyo3(default)]
79 license_names: Vec<String>,
80}
81
82#[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 if paths.is_empty() {
131 return Err(PyValueError::new_err(
132 "at least one search path is required",
133 ));
134 }
135
136 if matches!(jobs, Some(0)) {
138 return Err(PyValueError::new_err(
139 "jobs must be at least 1 when provided",
140 ));
141 }
142
143 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 let opts = SearchOptions {
163 follow_symlinks,
164 jobs,
165 };
166
167 let matches = search(&paths, &query, &opts).map_err(to_py_err)?;
169 to_py_matches(py, matches)
170}
171
172#[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 if paths.is_empty() {
220 return Err(PyValueError::new_err(
221 "at least one search path is required",
222 ));
223 }
224
225 if matches!(jobs, Some(0)) {
227 return Err(PyValueError::new_err(
228 "jobs must be at least 1 when provided",
229 ));
230 }
231
232 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 let opts = SearchOptions {
252 follow_symlinks,
253 jobs,
254 };
255 let matches = search(&paths, &query, &opts).map_err(to_py_err)?;
256
257 Ok(matches
259 .into_iter()
260 .map(|m| m.source.path_with_index())
261 .collect())
262}
263
264#[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 let metadata = convert_metadata(entries).map_err(to_py_err)?;
308
309 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 matches = filter_cached(&metadata, &query);
329 to_py_matches(py, matches)
330}
331
332#[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 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 let index = FontIndex::open(&index_path).map_err(to_py_err)?;
398 let reader = index.reader().map_err(to_py_err)?;
399
400 let matches = reader.find(&query).map_err(to_py_err)?;
402 to_py_matches(py, matches)
403}
404
405#[cfg(feature = "hpindex")]
413#[pyfunction]
414fn list_indexed_py(py: Python<'_>, index_path: PathBuf) -> PyResult<Vec<Py<PyAny>>> {
415 let index = FontIndex::open(&index_path).map_err(to_py_err)?;
417 let reader = index.reader().map_err(to_py_err)?;
418
419 let matches = reader.list_all().map_err(to_py_err)?;
421 to_py_matches(py, matches)
422}
423
424#[cfg(feature = "hpindex")]
432#[pyfunction]
433fn count_indexed_py(index_path: PathBuf) -> PyResult<usize> {
434 let index = FontIndex::open(&index_path).map_err(to_py_err)?;
436 index.count().map_err(to_py_err)
437}
438
439fn convert_metadata(entries: Vec<MetadataInput>) -> Result<Vec<TypgFontFaceMatch>> {
445 entries
446 .into_iter()
447 .map(|entry| {
448 let mut names = entry.names;
450 if names.is_empty() {
451 names.push(default_name(&entry.path));
452 }
453
454 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
481fn 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#[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 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 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 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 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
550fn 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
563fn 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
575fn 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
587fn 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
599fn dedup_chars(cps: &mut Vec<char>) {
605 cps.sort();
606 cps.dedup();
607}
608
609fn 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
674fn to_py_err(err: anyhow::Error) -> PyErr {
680 PyValueError::new_err(err.to_string())
681}
682
683#[pymodule]
689#[pyo3(name = "_typg_python")]
690fn typg_python(_py: Python<'_>, m: &Bound<PyModule>) -> PyResult<()> {
691 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 #[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 let dir = TempDir::new().unwrap();
875 let index_path = dir.path().to_path_buf();
876
877 {
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 Python::initialize();
903 Python::attach(|py| {
904 let count = count_indexed_py(index_path.clone()).unwrap();
906 assert_eq!(count, 1);
907
908 let all = list_indexed_py(py, index_path.clone()).unwrap();
910 assert_eq!(all.len(), 1);
911
912 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 let no_matches = find_indexed_py(
935 py,
936 index_path.clone(),
937 None,
938 Some(vec!["liga".into()]), 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 }
957}