1use 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#[derive(Clone, Debug, FromPyObject)]
35struct MetadataInput {
36 path: PathBuf,
38 #[pyo3(default)]
40 names: Vec<String>,
41 #[pyo3(default)]
43 axis_tags: Vec<String>,
44 #[pyo3(default)]
46 feature_tags: Vec<String>,
47 #[pyo3(default)]
49 script_tags: Vec<String>,
50 #[pyo3(default)]
52 table_tags: Vec<String>,
53 #[pyo3(default)]
55 codepoints: Vec<String>,
56 #[pyo3(default)]
58 is_variable: bool,
59 #[pyo3(default)]
61 ttc_index: Option<u32>,
62 #[pyo3(default)]
64 weight_class: Option<u16>,
65 #[pyo3(default)]
67 width_class: Option<u16>,
68 #[pyo3(default)]
70 family_class: Option<u16>,
71 #[pyo3(default)]
73 creator_names: Vec<String>,
74 #[pyo3(default)]
76 license_names: Vec<String>,
77}
78
79#[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 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#[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#[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 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#[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 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#[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#[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
418fn 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
457fn 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#[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 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
523fn 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
536fn 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
548fn 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
560fn 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
572fn dedup_chars(cps: &mut Vec<char>) {
578 cps.sort();
579 cps.dedup();
580}
581
582fn 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
647fn to_py_err(err: anyhow::Error) -> PyErr {
653 PyValueError::new_err(err.to_string())
654}
655
656#[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 #[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 let dir = TempDir::new().unwrap();
847 let index_path = dir.path().to_path_buf();
848
849 {
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 Python::initialize();
875 Python::attach(|py| {
876 let count = count_indexed_py(index_path.clone()).unwrap();
878 assert_eq!(count, 1);
879
880 let all = list_indexed_py(py, index_path.clone()).unwrap();
882 assert_eq!(all.len(), 1);
883
884 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 let no_matches = find_indexed_py(
907 py,
908 index_path.clone(),
909 None,
910 Some(vec!["liga".into()]), 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 }
929}