1use std::ops::RangeInclusive;
13use std::path::{Path, PathBuf};
14
15use anyhow::{anyhow, Result};
16use pyo3::exceptions::PyValueError;
17use pyo3::prelude::*;
18use pyo3::types::PyDict;
19use regex::Regex;
20use typg_core::query::{
21 parse_codepoint_list, parse_family_class, parse_tag_list, parse_u16_range, FamilyClassFilter,
22 Query,
23};
24use typg_core::search::{
25 filter_cached, search, SearchOptions, TypgFontFaceMatch, TypgFontFaceMeta, TypgFontSource,
26};
27use typg_core::tags::tag_to_string;
28
29#[cfg(feature = "hpindex")]
30use typg_core::index::FontIndex;
31
32#[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]
88#[pyo3(
89 signature = (
90 paths,
91 axes=None,
92 features=None,
93 scripts=None,
94 tables=None,
95 names=None,
96 codepoints=None,
97 text=None,
98 weight=None,
99 width=None,
100 family_class=None,
101 creator=None,
102 license=None,
103 variable=false,
104 follow_symlinks=false,
105 jobs=None
106 )
107)]
108#[allow(clippy::too_many_arguments)]
109fn find_py(
110 py: Python<'_>,
111 paths: Vec<PathBuf>,
112 axes: Option<Vec<String>>,
113 features: Option<Vec<String>>,
114 scripts: Option<Vec<String>>,
115 tables: Option<Vec<String>>,
116 names: Option<Vec<String>>,
117 codepoints: Option<Vec<String>>,
118 text: Option<String>,
119 weight: Option<String>,
120 width: Option<String>,
121 family_class: Option<String>,
122 creator: Option<Vec<String>>,
123 license: Option<Vec<String>>,
124 variable: bool,
125 follow_symlinks: bool,
126 jobs: Option<usize>,
127) -> PyResult<Vec<Py<PyAny>>> {
128 if paths.is_empty() {
129 return Err(PyValueError::new_err(
130 "at least one search path is required",
131 ));
132 }
133
134 if matches!(jobs, Some(0)) {
135 return Err(PyValueError::new_err(
136 "jobs must be at least 1 when provided",
137 ));
138 }
139
140 let query = build_query(
142 axes,
143 features,
144 scripts,
145 tables,
146 names,
147 codepoints,
148 text,
149 weight,
150 width,
151 family_class,
152 creator,
153 license,
154 variable,
155 )
156 .map_err(to_py_err)?;
157
158 let opts = SearchOptions {
159 follow_symlinks,
160 jobs,
161 };
162
163 let matches = search(&paths, &query, &opts).map_err(to_py_err)?;
164 to_py_matches(py, matches)
165}
166
167#[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]
256#[pyo3(
257 signature = (
258 entries,
259 axes=None,
260 features=None,
261 scripts=None,
262 tables=None,
263 names=None,
264 codepoints=None,
265 text=None,
266 weight=None,
267 width=None,
268 family_class=None,
269 creator=None,
270 license=None,
271 variable=false
272 )
273)]
274#[allow(clippy::too_many_arguments)]
275fn filter_cached_py(
276 py: Python<'_>,
277 entries: Vec<MetadataInput>,
278 axes: Option<Vec<String>>,
279 features: Option<Vec<String>>,
280 scripts: Option<Vec<String>>,
281 tables: Option<Vec<String>>,
282 names: Option<Vec<String>>,
283 codepoints: Option<Vec<String>>,
284 text: Option<String>,
285 weight: Option<String>,
286 width: Option<String>,
287 family_class: Option<String>,
288 creator: Option<Vec<String>>,
289 license: Option<Vec<String>>,
290 variable: bool,
291) -> PyResult<Vec<Py<PyAny>>> {
292 let metadata = convert_metadata(entries).map_err(to_py_err)?;
294
295 let query = build_query(
296 axes,
297 features,
298 scripts,
299 tables,
300 names,
301 codepoints,
302 text,
303 weight,
304 width,
305 family_class,
306 creator,
307 license,
308 variable,
309 )
310 .map_err(to_py_err)?;
311
312 let matches = filter_cached(&metadata, &query);
313 to_py_matches(py, matches)
314}
315
316#[cfg(feature = "hpindex")]
320#[pyfunction]
321#[pyo3(
322 signature = (
323 index_path,
324 axes=None,
325 features=None,
326 scripts=None,
327 tables=None,
328 names=None,
329 codepoints=None,
330 text=None,
331 weight=None,
332 width=None,
333 family_class=None,
334 creator=None,
335 license=None,
336 variable=false
337 )
338)]
339#[allow(clippy::too_many_arguments)]
340fn find_indexed_py(
341 py: Python<'_>,
342 index_path: PathBuf,
343 axes: Option<Vec<String>>,
344 features: Option<Vec<String>>,
345 scripts: Option<Vec<String>>,
346 tables: Option<Vec<String>>,
347 names: Option<Vec<String>>,
348 codepoints: Option<Vec<String>>,
349 text: Option<String>,
350 weight: Option<String>,
351 width: Option<String>,
352 family_class: Option<String>,
353 creator: Option<Vec<String>>,
354 license: Option<Vec<String>>,
355 variable: bool,
356) -> PyResult<Vec<Py<PyAny>>> {
357 let query = build_query(
358 axes,
359 features,
360 scripts,
361 tables,
362 names,
363 codepoints,
364 text,
365 weight,
366 width,
367 family_class,
368 creator,
369 license,
370 variable,
371 )
372 .map_err(to_py_err)?;
373
374 let index = FontIndex::open(&index_path).map_err(to_py_err)?;
376 let reader = index.reader().map_err(to_py_err)?;
377 let matches = reader.find(&query).map_err(to_py_err)?;
378 to_py_matches(py, matches)
379}
380
381#[cfg(feature = "hpindex")]
385#[pyfunction]
386fn list_indexed_py(py: Python<'_>, index_path: PathBuf) -> PyResult<Vec<Py<PyAny>>> {
387 let index = FontIndex::open(&index_path).map_err(to_py_err)?;
388 let reader = index.reader().map_err(to_py_err)?;
389 let matches = reader.list_all().map_err(to_py_err)?;
390 to_py_matches(py, matches)
391}
392
393#[cfg(feature = "hpindex")]
397#[pyfunction]
398fn count_indexed_py(index_path: PathBuf) -> PyResult<usize> {
399 let index = FontIndex::open(&index_path).map_err(to_py_err)?;
400 index.count().map_err(to_py_err)
401}
402
403fn convert_metadata(entries: Vec<MetadataInput>) -> Result<Vec<TypgFontFaceMatch>> {
404 entries
405 .into_iter()
406 .map(|entry| {
407 let mut names = entry.names;
408 if names.is_empty() {
409 names.push(default_name(&entry.path));
410 }
411
412 Ok(TypgFontFaceMatch {
413 source: TypgFontSource {
414 path: entry.path,
415 ttc_index: entry.ttc_index,
416 },
417 metadata: TypgFontFaceMeta {
418 names,
419 axis_tags: parse_tag_list(&entry.axis_tags)?,
420 feature_tags: parse_tag_list(&entry.feature_tags)?,
421 script_tags: parse_tag_list(&entry.script_tags)?,
422 table_tags: parse_tag_list(&entry.table_tags)?,
423 codepoints: parse_codepoints(&entry.codepoints)?,
424 is_variable: entry.is_variable,
425 weight_class: entry.weight_class,
426 width_class: entry.width_class,
427 family_class: entry
428 .family_class
429 .map(|raw| (((raw >> 8) & 0xFF) as u8, (raw & 0x00FF) as u8)),
430 creator_names: entry.creator_names,
431 license_names: entry.license_names,
432 },
433 })
434 })
435 .collect()
436}
437
438fn default_name(path: &Path) -> String {
439 path.file_stem()
440 .map(|s| s.to_string_lossy().to_string())
441 .unwrap_or_else(|| path.display().to_string())
442}
443
444#[allow(clippy::too_many_arguments)]
445fn build_query(
446 axes: Option<Vec<String>>,
447 features: Option<Vec<String>>,
448 scripts: Option<Vec<String>>,
449 tables: Option<Vec<String>>,
450 names: Option<Vec<String>>,
451 codepoints: Option<Vec<String>>,
452 text: Option<String>,
453 weight: Option<String>,
454 width: Option<String>,
455 family_class: Option<String>,
456 creator: Option<Vec<String>>,
457 license: Option<Vec<String>>,
458 variable: bool,
459) -> Result<Query> {
460 let axes = parse_tag_list(&axes.unwrap_or_default())?;
461 let features = parse_tag_list(&features.unwrap_or_default())?;
462 let scripts = parse_tag_list(&scripts.unwrap_or_default())?;
463 let tables = parse_tag_list(&tables.unwrap_or_default())?;
464 let name_patterns = compile_patterns(&names.unwrap_or_default())?;
465 let creator_patterns = compile_patterns(&creator.unwrap_or_default())?;
466 let license_patterns = compile_patterns(&license.unwrap_or_default())?;
467
468 let weight_range = parse_optional_range(weight)?;
469 let width_range = parse_optional_range(width)?;
470 let family_class = parse_optional_family_class(family_class)?;
471
472 let mut cps = parse_codepoints(&codepoints.unwrap_or_default())?;
473 if let Some(text) = text {
474 cps.extend(text.chars());
475 }
476 dedup_chars(&mut cps);
477
478 Ok(Query::new()
479 .with_axes(axes)
480 .with_features(features)
481 .with_scripts(scripts)
482 .with_tables(tables)
483 .with_name_patterns(name_patterns)
484 .with_creator_patterns(creator_patterns)
485 .with_license_patterns(license_patterns)
486 .with_codepoints(cps)
487 .require_variable(variable)
488 .with_weight_range(weight_range)
489 .with_width_range(width_range)
490 .with_family_class(family_class))
491}
492
493fn parse_codepoints(raw: &[String]) -> Result<Vec<char>> {
494 let mut cps = Vec::new();
495 for chunk in raw {
496 cps.extend(parse_codepoint_list(chunk)?);
497 }
498 Ok(cps)
499}
500
501fn parse_optional_range(raw: Option<String>) -> Result<Option<RangeInclusive<u16>>> {
502 match raw {
503 Some(value) => Ok(Some(parse_u16_range(&value)?)),
504 None => Ok(None),
505 }
506}
507
508fn parse_optional_family_class(raw: Option<String>) -> Result<Option<FamilyClassFilter>> {
509 match raw {
510 Some(value) => Ok(Some(parse_family_class(&value)?)),
511 None => Ok(None),
512 }
513}
514
515fn compile_patterns(patterns: &[String]) -> Result<Vec<Regex>> {
516 patterns
517 .iter()
518 .map(|p| Regex::new(p).map_err(|e| anyhow!("invalid regex {p}: {e}")))
519 .collect()
520}
521
522fn dedup_chars(cps: &mut Vec<char>) {
523 cps.sort();
524 cps.dedup();
525}
526
527fn to_py_matches(py: Python<'_>, matches: Vec<TypgFontFaceMatch>) -> PyResult<Vec<Py<PyAny>>> {
528 matches
529 .into_iter()
530 .map(|item| {
531 let meta = &item.metadata;
532
533 let meta_dict = PyDict::new(py);
534 meta_dict.set_item("names", meta.names.clone())?;
535 meta_dict.set_item(
536 "axis_tags",
537 meta.axis_tags
538 .iter()
539 .map(|t| tag_to_string(*t))
540 .collect::<Vec<_>>(),
541 )?;
542 meta_dict.set_item(
543 "feature_tags",
544 meta.feature_tags
545 .iter()
546 .map(|t| tag_to_string(*t))
547 .collect::<Vec<_>>(),
548 )?;
549 meta_dict.set_item(
550 "script_tags",
551 meta.script_tags
552 .iter()
553 .map(|t| tag_to_string(*t))
554 .collect::<Vec<_>>(),
555 )?;
556 meta_dict.set_item(
557 "table_tags",
558 meta.table_tags
559 .iter()
560 .map(|t| tag_to_string(*t))
561 .collect::<Vec<_>>(),
562 )?;
563 meta_dict.set_item(
564 "codepoints",
565 meta.codepoints
566 .iter()
567 .map(|c| c.to_string())
568 .collect::<Vec<_>>(),
569 )?;
570 meta_dict.set_item("is_variable", meta.is_variable)?;
571 meta_dict.set_item("weight_class", meta.weight_class)?;
572 meta_dict.set_item("width_class", meta.width_class)?;
573 meta_dict.set_item("family_class", meta.family_class)?;
574 meta_dict.set_item("creator_names", meta.creator_names.clone())?;
575 meta_dict.set_item("license_names", meta.license_names.clone())?;
576
577 let outer = PyDict::new(py);
578 outer.set_item("path", item.source.path.to_string_lossy().to_string())?;
579 outer.set_item("ttc_index", item.source.ttc_index)?;
580 outer.set_item("metadata", meta_dict)?;
581
582 Ok(outer.into_any().unbind())
583 })
584 .collect()
585}
586
587fn to_py_err(err: anyhow::Error) -> PyErr {
588 PyValueError::new_err(err.to_string())
589}
590
591#[pymodule]
592#[pyo3(name = "_typg_python")]
593fn typg_python(_py: Python<'_>, m: &Bound<PyModule>) -> PyResult<()> {
594 m.add_function(wrap_pyfunction!(find_py, m)?)?;
595 m.add_function(wrap_pyfunction!(find_paths_py, m)?)?;
596 m.add_function(wrap_pyfunction!(filter_cached_py, m)?)?;
597
598 #[cfg(feature = "hpindex")]
599 {
600 m.add_function(wrap_pyfunction!(find_indexed_py, m)?)?;
601 m.add_function(wrap_pyfunction!(list_indexed_py, m)?)?;
602 m.add_function(wrap_pyfunction!(count_indexed_py, m)?)?;
603 }
604
605 Ok(())
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 fn metadata(path: &str, names: &[&str], axes: &[&str], variable: bool) -> MetadataInput {
613 MetadataInput {
614 path: PathBuf::from(path),
615 names: names.iter().map(|s| s.to_string()).collect(),
616 axis_tags: axes.iter().map(|s| s.to_string()).collect(),
617 feature_tags: Vec::new(),
618 script_tags: Vec::new(),
619 table_tags: Vec::new(),
620 codepoints: vec!["A".into()],
621 is_variable: variable,
622 ttc_index: None,
623 weight_class: None,
624 width_class: None,
625 family_class: None,
626 creator_names: Vec::new(),
627 license_names: Vec::new(),
628 }
629 }
630
631 #[test]
632 fn filter_cached_filters_axes_and_names() {
633 Python::initialize();
634 Python::attach(|py| {
635 let entries = vec![
636 metadata("VariableVF.ttf", &["Pro VF"], &["wght"], true),
637 metadata("Static.ttf", &["Static Sans"], &[], false),
638 ];
639
640 let result = filter_cached_py(
641 py,
642 entries,
643 Some(vec!["wght".into()]),
644 None,
645 None,
646 None,
647 Some(vec!["Pro".into()]),
648 None,
649 None,
650 None,
651 None,
652 None,
653 None,
654 None,
655 true,
656 );
657
658 assert!(result.is_ok(), "expected Ok from filter_cached_py");
659 let objs = result.unwrap();
660 assert_eq!(objs.len(), 1, "only variable font with axis should match");
661 let py_any: Py<PyAny> = objs[0].clone_ref(py);
662 let bound_any = py_any.bind(py);
663 let dict = bound_any.downcast::<PyDict>().unwrap();
664 assert_eq!(
665 dict.get_item("path")
666 .expect("path lookup")
667 .expect("path field")
668 .extract::<String>()
669 .unwrap(),
670 "VariableVF.ttf"
671 );
672 });
673 }
674
675 #[test]
676 fn invalid_tag_returns_error() {
677 Python::initialize();
678 Python::attach(|py| {
679 let err = filter_cached_py(
680 py,
681 vec![metadata("Bad.ttf", &["Bad"], &["wght"], true)],
682 Some(vec!["abcde".into()]),
683 None,
684 None,
685 None,
686 None,
687 None,
688 None,
689 None,
690 None,
691 None,
692 None,
693 None,
694 false,
695 )
696 .unwrap_err();
697
698 let message = format!("{err}");
699 assert!(
700 message.contains("tag") || message.contains("invalid"),
701 "error message should mention invalid tag, got: {message}"
702 );
703 });
704 }
705
706 #[test]
707 fn find_requires_paths() {
708 Python::initialize();
709 Python::attach(|py| {
710 let err = find_py(
711 py,
712 Vec::new(),
713 None,
714 None,
715 None,
716 None,
717 None,
718 None,
719 None,
720 None,
721 None,
722 None,
723 None,
724 None,
725 false,
726 false,
727 None,
728 )
729 .unwrap_err();
730
731 assert!(
732 format!("{err}").contains("path"),
733 "should mention missing paths"
734 );
735 });
736 }
737
738 #[test]
739 fn find_paths_requires_paths() {
740 Python::initialize();
741 Python::attach(|_| {
742 let err = find_paths_py(
743 Vec::new(),
744 None,
745 None,
746 None,
747 None,
748 None,
749 None,
750 None,
751 None,
752 None,
753 None,
754 None,
755 None,
756 false,
757 false,
758 None,
759 )
760 .unwrap_err();
761
762 assert!(format!("{err}").contains("path"));
763 });
764 }
765
766 #[cfg(feature = "hpindex")]
767 #[test]
768 fn indexed_search_returns_results() {
769 use read_fonts::types::Tag;
770 use std::time::SystemTime;
771 use tempfile::TempDir;
772 use typg_core::index::FontIndex;
773
774 let dir = TempDir::new().unwrap();
776 let index_path = dir.path().to_path_buf();
777
778 {
780 let index = FontIndex::open(&index_path).unwrap();
781 let mut writer = index.writer().unwrap();
782 writer
783 .add_font(
784 Path::new("/test/IndexedFont.ttf"),
785 None,
786 SystemTime::UNIX_EPOCH,
787 vec!["Indexed Font".to_string()],
788 &[Tag::new(b"wght")],
789 &[Tag::new(b"smcp")],
790 &[Tag::new(b"latn")],
791 &[],
792 &['a', 'b', 'c'],
793 true,
794 Some(400),
795 Some(5),
796 None,
797 )
798 .unwrap();
799 writer.commit().unwrap();
800 }
801
802 Python::initialize();
804 Python::attach(|py| {
805 let count = count_indexed_py(index_path.clone()).unwrap();
807 assert_eq!(count, 1);
808
809 let all = list_indexed_py(py, index_path.clone()).unwrap();
811 assert_eq!(all.len(), 1);
812
813 let matches = find_indexed_py(
815 py,
816 index_path.clone(),
817 Some(vec!["wght".into()]),
818 Some(vec!["smcp".into()]),
819 None,
820 None,
821 None,
822 None,
823 None,
824 None,
825 None,
826 None,
827 None,
828 None,
829 true,
830 )
831 .unwrap();
832 assert_eq!(matches.len(), 1);
833
834 let no_matches = find_indexed_py(
836 py,
837 index_path.clone(),
838 None,
839 Some(vec!["liga".into()]), None,
841 None,
842 None,
843 None,
844 None,
845 None,
846 None,
847 None,
848 None,
849 None,
850 false,
851 )
852 .unwrap();
853 assert_eq!(no_matches.len(), 0);
854 });
855
856 }
858}