1#![warn(missing_docs)]
29use std::{
30 borrow::Cow,
31 error::Error,
32 fmt::Display,
33 fs::File,
34 io::{BufRead, BufReader, ErrorKind},
35 ops::Not,
36 path::{Path, PathBuf},
37 str::FromStr,
38};
39
40use xdg::BaseDirectories;
41
42#[cfg(target_os = "linux")]
43mod locale;
44
45include!(concat!(env!("OUT_DIR"), "/paperspecs.rs"));
46
47static PAPERSIZE_FILENAME: &str = "papersize";
48static PAPERSPECS_FILENAME: &str = "paperspecs";
49
50enum DefaultPaper {
51 Name(String),
52 Size(PaperSize),
53}
54
55#[derive(Copy, Clone, Debug, PartialEq, Eq)]
57pub enum Unit {
58 Point,
60
61 Inch,
63
64 Millimeter,
66}
67
68#[derive(Copy, Clone, Debug, PartialEq, Eq)]
70pub struct ParseUnitError;
71
72impl FromStr for Unit {
73 type Err = ParseUnitError;
74
75 fn from_str(s: &str) -> Result<Self, Self::Err> {
78 match s {
79 "pt" => Ok(Self::Point),
80 "in" => Ok(Self::Inch),
81 "mm" => Ok(Self::Millimeter),
82 _ => Err(ParseUnitError),
83 }
84 }
85}
86
87impl Unit {
88 pub fn name(&self) -> &'static str {
90 match self {
91 Unit::Point => "pt",
92 Unit::Inch => "in",
93 Unit::Millimeter => "mm",
94 }
95 }
96
97 fn as_unit(&self, other: Unit) -> f64 {
102 match (*self, other) {
103 (Unit::Point, Unit::Point) => 1.0,
104 (Unit::Point, Unit::Inch) => 1.0 / 72.0,
105 (Unit::Point, Unit::Millimeter) => 25.4 / 72.0,
106 (Unit::Inch, Unit::Point) => 72.0,
107 (Unit::Inch, Unit::Inch) => 1.0,
108 (Unit::Inch, Unit::Millimeter) => 25.4,
109 (Unit::Millimeter, Unit::Point) => 72.0 / 25.4,
110 (Unit::Millimeter, Unit::Inch) => 1.0 / 25.4,
111 (Unit::Millimeter, Unit::Millimeter) => 1.0,
112 }
113 }
114}
115
116impl Display for Unit {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 write!(f, "{}", self.name())
119 }
120}
121
122#[derive(Copy, Clone, Debug, PartialEq)]
124pub struct PaperSize {
125 pub width: f64,
127
128 pub height: f64,
130
131 pub unit: Unit,
133}
134
135impl Default for PaperSize {
136 fn default() -> Self {
138 Self::new(210.0, 297.0, Unit::Millimeter)
139 }
140}
141
142impl PaperSize {
143 pub fn new(width: f64, height: f64, unit: Unit) -> Self {
145 Self {
146 width,
147 height,
148 unit,
149 }
150 }
151
152 pub fn as_unit(&self, unit: Unit) -> PaperSize {
154 Self {
155 width: self.width * self.unit.as_unit(unit),
156 height: self.height * self.unit.as_unit(unit),
157 unit,
158 }
159 }
160
161 pub fn into_width_height(self) -> (f64, f64) {
163 (self.width, self.height)
164 }
165
166 pub fn eq_rounded(&self, other: &Self, unit: Unit) -> bool {
169 let (aw, ah) = self.as_unit(unit).into_width_height();
170 let (bw, bh) = other.as_unit(unit).into_width_height();
171 aw.round() == bw.round() && ah.round() == bh.round()
172 }
173}
174
175#[derive(Copy, Clone, Debug, PartialEq, Eq)]
177pub enum ParsePaperSpecError {
178 InvalidHeight,
180
181 InvalidWidth,
183
184 InvalidUnit,
186
187 MissingField,
189}
190
191impl Error for ParsePaperSpecError {}
192
193impl Display for ParsePaperSpecError {
194 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
195 match self {
196 ParsePaperSpecError::InvalidHeight => write!(f, "Invalid paper height."),
197 ParsePaperSpecError::InvalidWidth => write!(f, "Invalid paper width."),
198 ParsePaperSpecError::InvalidUnit => write!(f, "Invalid unit of measurement."),
199 ParsePaperSpecError::MissingField => write!(f, "Missing field in paper specification."),
200 }
201 }
202}
203
204#[derive(Clone, Debug, PartialEq)]
206pub struct PaperSpec {
207 pub name: Cow<'static, str>,
209
210 pub size: PaperSize,
212}
213
214impl PaperSpec {
215 pub fn new(name: impl Into<Cow<'static, str>>, size: PaperSize) -> Self {
217 Self {
218 name: name.into(),
219 size,
220 }
221 }
222}
223
224impl FromStr for PaperSpec {
225 type Err = ParsePaperSpecError;
226
227 fn from_str(s: &str) -> Result<Self, Self::Err> {
228 let mut tokens = s.split(',').map(|s| s.trim());
229 if let Some(name) = tokens.next()
230 && let Some(width) = tokens.next()
231 && let Some(height) = tokens.next()
232 && let Some(unit) = tokens.next()
233 {
234 let width = f64::from_str(width).map_err(|_| ParsePaperSpecError::InvalidWidth)?;
235 let height = f64::from_str(height).map_err(|_| ParsePaperSpecError::InvalidHeight)?;
236 let unit = Unit::from_str(unit).map_err(|_| ParsePaperSpecError::InvalidUnit)?;
237 Ok(Self {
238 name: String::from(name).into(),
239 size: PaperSize::new(width, height, unit),
240 })
241 } else {
242 Err(ParsePaperSpecError::MissingField)
243 }
244 }
245}
246
247#[derive(Debug)]
249pub enum CatalogBuildError {
250 ParseError {
252 path: PathBuf,
254
255 line_number: usize,
257
258 error: ParsePaperSpecError,
260 },
261
262 IoError {
264 path: PathBuf,
266
267 error: std::io::Error,
269 },
270}
271
272impl Error for CatalogBuildError {}
273
274impl Display for CatalogBuildError {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 match self {
277 CatalogBuildError::ParseError {
278 path,
279 line_number,
280 error,
281 } => write!(f, "{}:{line_number}: {error}", path.display()),
282 CatalogBuildError::IoError { path, error } => {
283 write!(f, "{}: {error}", path.display())
284 }
285 }
286 }
287}
288
289pub struct CatalogBuilder<'a> {
295 papersize: Option<Option<&'a str>>,
296 use_locale: bool,
297 user_config_dir: Option<Option<&'a Path>>,
298 system_config_dir: Option<&'a Path>,
299 error_cb: Box<dyn FnMut(CatalogBuildError) + 'a>,
300}
301
302impl<'a> Default for CatalogBuilder<'a> {
303 fn default() -> Self {
304 Self {
305 use_locale: true,
306 papersize: None,
307 user_config_dir: None,
308 system_config_dir: Some(Path::new("/etc")),
309 error_cb: Box::new(drop),
310 }
311 }
312}
313
314fn fallback_specs() -> (Vec<PaperSpec>, PaperSpec) {
315 let specs = STANDARD_PAPERSPECS.into_iter().cloned().collect::<Vec<_>>();
316 let default = specs.first().unwrap().clone();
317 (specs, default)
318}
319
320fn read_specs<E>(
321 user_config_dir: Option<&Path>,
322 system_config_dir: Option<&Path>,
323 mut error_cb: E,
324) -> Option<(Vec<PaperSpec>, PaperSpec)>
325where
326 E: FnMut(CatalogBuildError),
327{
328 fn read_paperspecs_file(
329 directory: Option<&Path>,
330 error_cb: &mut dyn FnMut(CatalogBuildError),
331 ) -> Vec<PaperSpec> {
332 let mut specs = Vec::new();
333 if let Some(directory) = directory {
334 let path = directory.join(PAPERSPECS_FILENAME);
335 match File::open(&path) {
336 Ok(file) => {
337 let reader = BufReader::new(file);
338 for (line, line_number) in reader.lines().zip(1..) {
339 match line
340 .map_err(|error| CatalogBuildError::IoError {
341 path: path.clone(),
342 error,
343 })
344 .and_then(|line| {
345 PaperSpec::from_str(&line).map_err(|error| {
346 CatalogBuildError::ParseError {
347 path: path.clone(),
348 line_number,
349 error,
350 }
351 })
352 }) {
353 Ok(spec) => specs.push(spec),
354 Err(error) => error_cb(error),
355 }
356 }
357 }
358 Err(error) if error.kind() == ErrorKind::NotFound => (),
359 Err(error) => error_cb(CatalogBuildError::IoError { path, error }),
360 }
361 }
362 specs
363 }
364
365 let user_specs = read_paperspecs_file(user_config_dir, &mut error_cb);
366 let system_specs = read_paperspecs_file(system_config_dir, &mut error_cb);
367 let default_spec = system_specs.first().or(user_specs.first())?.clone();
368 Some((
369 user_specs.into_iter().chain(system_specs).collect(),
370 default_spec,
371 ))
372}
373
374fn default_paper<E>(
375 papersize: Option<Option<&str>>,
376 user_config_dir: Option<&Path>,
377 _use_locale: bool,
378 system_config_dir: Option<&Path>,
379 default: &PaperSpec,
380 mut error_cb: E,
381) -> DefaultPaper
382where
383 E: FnMut(CatalogBuildError),
384{
385 fn read_papersize_file<P, E>(path: P, mut error_cb: E) -> Option<String>
386 where
387 P: AsRef<Path>,
388 E: FnMut(CatalogBuildError),
389 {
390 fn inner(path: &Path) -> std::io::Result<Option<String>> {
391 let file = BufReader::new(File::open(path)?);
392 let line = file.lines().next().unwrap_or(Ok(String::new()))?;
393 let name = line.split(',').next().unwrap_or("");
394 Ok(name.is_empty().not().then(|| name.into()))
395 }
396 let path = path.as_ref();
397 match inner(path) {
398 Ok(result) => result,
399 Err(error) => {
400 if error.kind() != ErrorKind::NotFound {
401 error_cb(CatalogBuildError::IoError {
402 path: path.to_path_buf(),
403 error,
404 });
405 }
406 None
407 }
408 }
409 }
410
411 let env_var;
413 let paper_name = match papersize {
414 Some(paper_name) => paper_name,
415 None => {
416 env_var = std::env::var("PAPERSIZE").ok();
417 env_var.as_deref()
418 }
419 };
420 if let Some(paper_name) = paper_name
421 && !paper_name.is_empty()
422 {
423 return DefaultPaper::Name(paper_name.into());
424 }
425
426 if let Some(dir) = user_config_dir
428 && let path = dir.join(PAPERSIZE_FILENAME)
429 && let Some(paper_name) = read_papersize_file(path, &mut error_cb)
430 {
431 return DefaultPaper::Name(paper_name);
432 }
433
434 #[cfg(target_os = "linux")]
436 if _use_locale && let Some(paper_size) = locale::locale_paper_size() {
437 return DefaultPaper::Size(paper_size);
438 }
439
440 if let Some(system_config_dir) = system_config_dir
441 && let Some(paper_name) =
442 read_papersize_file(system_config_dir.join(PAPERSIZE_FILENAME), &mut error_cb)
443 {
444 return DefaultPaper::Name(paper_name);
445 }
446
447 DefaultPaper::Name(default.name.as_ref().into())
449}
450
451impl<'a> CatalogBuilder<'a> {
452 pub fn new() -> Self {
454 Self::default()
455 }
456
457 pub fn build(self) -> Catalog {
466 self.build_inner(|user_config_dir, system_config_dir, error_cb| {
467 Some(
468 read_specs(user_config_dir, system_config_dir, error_cb)
469 .unwrap_or_else(fallback_specs),
470 )
471 })
472 .unwrap()
473 }
474
475 pub fn build_from_fallback(self) -> Catalog {
482 self.build_inner(|_, _, _| Some(fallback_specs())).unwrap()
483 }
484
485 pub fn build_without_fallback(self) -> Option<Catalog> {
493 self.build_inner(|user_config_dir, system_config_dir, error_cb| {
494 read_specs(user_config_dir, system_config_dir, error_cb)
495 })
496 }
497
498 pub fn with_papersize_value(self, papersize: Option<&'a str>) -> Self {
502 Self {
503 papersize: Some(papersize),
504 ..self
505 }
506 }
507
508 pub fn without_locale(self) -> Self {
515 Self {
516 use_locale: false,
517 ..self
518 }
519 }
520
521 pub fn with_user_config_dir(self, user_config_dir: Option<&'a Path>) -> Self {
530 Self {
531 user_config_dir: Some(user_config_dir),
532 ..self
533 }
534 }
535
536 pub fn with_system_config_dir(self, system_config_dir: Option<&'a Path>) -> Self {
544 Self {
545 system_config_dir,
546 ..self
547 }
548 }
549
550 pub fn with_error_callback(self, error_cb: Box<dyn FnMut(CatalogBuildError) + 'a>) -> Self {
559 Self { error_cb, ..self }
560 }
561
562 fn build_inner<F>(mut self, f: F) -> Option<Catalog>
563 where
564 F: Fn(
565 Option<&Path>,
566 Option<&Path>,
567 &mut Box<dyn FnMut(CatalogBuildError) + 'a>,
568 ) -> Option<(Vec<PaperSpec>, PaperSpec)>,
569 {
570 let base_directories;
571 let user_config_dir = match self.user_config_dir {
572 Some(user_config_dir) => user_config_dir,
573 None => {
574 base_directories = BaseDirectories::new();
575 base_directories.config_home.as_deref()
576 }
577 };
578 let (specs, default) = f(user_config_dir, self.system_config_dir, &mut self.error_cb)?;
579 let default = match default_paper(
580 self.papersize,
581 user_config_dir,
582 self.use_locale,
583 self.system_config_dir,
584 &default,
585 &mut self.error_cb,
586 ) {
587 DefaultPaper::Name(name) => specs
588 .iter()
589 .find(|spec| spec.name.eq_ignore_ascii_case(&name))
590 .cloned()
591 .unwrap_or(default),
592 DefaultPaper::Size(size) => specs
593 .iter()
594 .find(|spec| spec.size.eq_rounded(&size, Unit::Point))
595 .cloned()
596 .unwrap_or_else(|| PaperSpec::new(Cow::from("Locale"), size)),
597 };
598
599 Some(Catalog { specs, default })
600 }
601}
602
603pub struct Catalog {
605 specs: Vec<PaperSpec>,
606 default: PaperSpec,
607}
608
609impl Default for Catalog {
610 fn default() -> Self {
611 Self::builder().build()
612 }
613}
614
615impl Catalog {
616 pub fn builder<'a>() -> CatalogBuilder<'a> {
618 CatalogBuilder::new()
619 }
620
621 pub fn new() -> Self {
626 Self::default()
627 }
628
629 pub fn specs(&self) -> &[PaperSpec] {
632 &self.specs
633 }
634
635 pub fn default_paper(&self) -> &PaperSpec {
641 &self.default
642 }
643
644 pub fn get_by_size(&self, size: &PaperSize) -> Option<&PaperSpec> {
647 self.specs
648 .iter()
649 .find(|spec| spec.size.eq_rounded(size, Unit::Point))
650 }
651
652 pub fn get_by_name(&self, name: &str) -> Option<&PaperSpec> {
655 self.specs
656 .iter()
657 .find(|spec| spec.name.eq_ignore_ascii_case(name))
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use std::{borrow::Cow, path::Path};
664
665 use crate::{
666 CatalogBuildError, CatalogBuilder, PaperSize, PaperSpec, ParsePaperSpecError, Unit, locale,
667 };
668
669 #[test]
670 fn unit() {
671 assert_eq!(Unit::Point.to_string(), "pt");
672 assert_eq!(Unit::Millimeter.to_string(), "mm");
673 assert_eq!(Unit::Inch.to_string(), "in");
674
675 assert_eq!("pt".parse(), Ok(Unit::Point));
676 assert_eq!("mm".parse(), Ok(Unit::Millimeter));
677 assert_eq!("in".parse(), Ok(Unit::Inch));
678
679 assert_eq!(
680 format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Millimeter)),
681 "25.400"
682 );
683 assert_eq!(
684 format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Inch)),
685 "1.000"
686 );
687 assert_eq!(
688 format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Point)),
689 "72.000"
690 );
691 assert_eq!(
692 format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Millimeter)),
693 "12.700"
694 );
695 assert_eq!(
696 format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Inch)),
697 "0.500"
698 );
699 assert_eq!(
700 format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Point)),
701 "36.000"
702 );
703 assert_eq!(
704 format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Millimeter)),
705 "12.700"
706 );
707 assert_eq!(
708 format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Inch)),
709 "0.500"
710 );
711 assert_eq!(
712 format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Point)),
713 "36.000"
714 );
715 }
716
717 #[test]
718 fn paperspec() {
719 assert_eq!(
720 "Letter,8.5,11,in".parse(),
721 Ok(PaperSpec::new(
722 Cow::from("Letter"),
723 PaperSize::new(8.5, 11.0, Unit::Inch)
724 ))
725 );
726 }
727
728 #[test]
729 fn default() {
730 assert_eq!(
732 CatalogBuilder::new()
733 .with_papersize_value(Some("legal"))
734 .with_user_config_dir(Some(Path::new("testdata/td1")))
735 .without_locale()
736 .build_from_fallback()
737 .default_paper(),
738 &PaperSpec::new(Cow::from("Legal"), PaperSize::new(8.5, 14.0, Unit::Inch))
739 );
740
741 assert_eq!(
743 CatalogBuilder::new()
744 .with_papersize_value(None)
745 .with_user_config_dir(Some(Path::new("testdata/td1")))
746 .without_locale()
747 .build_from_fallback()
748 .default_paper(),
749 &PaperSpec::new(Cow::from("Ledger"), PaperSize::new(17.0, 11.0, Unit::Inch))
750 );
751
752 assert_eq!(
754 CatalogBuilder::new()
755 .with_papersize_value(None)
756 .with_user_config_dir(None)
757 .with_system_config_dir(Some(Path::new("testdata/td2")))
758 .without_locale()
759 .build_from_fallback()
760 .default_paper(),
761 &PaperSpec::new(
762 Cow::from("Executive"),
763 PaperSize::new(7.25, 10.5, Unit::Inch)
764 )
765 );
766
767 assert_eq!(
769 CatalogBuilder::new()
770 .with_papersize_value(None)
771 .with_user_config_dir(None)
772 .with_system_config_dir(Some(Path::new("testdata/td2")))
773 .without_locale()
774 .build()
775 .default_paper(),
776 &PaperSpec::new(
777 Cow::from("A0"),
778 PaperSize::new(841.0, 1189.0, Unit::Millimeter)
779 )
780 );
781
782 assert_eq!(
784 CatalogBuilder::new()
785 .with_papersize_value(None)
786 .with_user_config_dir(Some(Path::new("testdata/td3")))
787 .with_system_config_dir(None)
788 .without_locale()
789 .build()
790 .default_paper(),
791 &PaperSpec::new(
792 Cow::from("B0"),
793 PaperSize::new(1000.0, 1414.0, Unit::Millimeter)
794 )
795 );
796
797 assert_eq!(
799 CatalogBuilder::new()
800 .with_papersize_value(None)
801 .with_user_config_dir(None)
802 .with_system_config_dir(None)
803 .without_locale()
804 .build()
805 .default_paper(),
806 &PaperSpec::new(
807 Cow::from("A4"),
808 PaperSize::new(210.0, 297.0, Unit::Millimeter)
809 )
810 );
811
812 assert!(
814 CatalogBuilder::new()
815 .with_papersize_value(None)
816 .with_user_config_dir(None)
817 .with_system_config_dir(None)
818 .without_locale()
819 .build_without_fallback()
820 .is_none()
821 );
822 }
823
824 #[test]
825 fn errors() {
826 let mut errors = Vec::new();
828 let _ = CatalogBuilder::new()
829 .with_papersize_value(None)
830 .with_user_config_dir(Some(Path::new("nonexistent/user")))
831 .with_system_config_dir(Some(Path::new("nonexistent/system")))
832 .without_locale()
833 .with_error_callback(Box::new(|error| errors.push(error)))
834 .build()
835 .default_paper();
836 assert_eq!(errors.len(), 0);
837
838 let mut errors = Vec::new();
840 let _ = CatalogBuilder::new()
841 .with_papersize_value(None)
842 .with_user_config_dir(None)
843 .with_system_config_dir(Some(Path::new("testdata/td4")))
844 .without_locale()
845 .with_error_callback(Box::new(|error| errors.push(error)))
846 .build()
847 .default_paper();
848
849 assert_eq!(errors.len(), 4);
850 for ((error, expect_line_number), expect_error) in errors.iter().zip(1..).zip([
851 ParsePaperSpecError::MissingField,
852 ParsePaperSpecError::InvalidWidth,
853 ParsePaperSpecError::InvalidHeight,
854 ParsePaperSpecError::InvalidUnit,
855 ]) {
856 let CatalogBuildError::ParseError {
857 path,
858 line_number,
859 error,
860 } = error
861 else {
862 unreachable!()
863 };
864 assert_eq!(path.as_path(), Path::new("testdata/td4/paperspecs"));
865 assert_eq!(*line_number, expect_line_number);
866 assert_eq!(*error, expect_error);
867 }
868 }
869
870 #[cfg(target_os = "linux")]
871 #[test]
872 fn lc_paper() {
873 if let Some(size) = locale::locale_paper_size() {
878 assert_eq!(size.unit, Unit::Millimeter);
879 let (w, h) = size.into_width_height();
880 assert!(
881 (w, h) == (210.0, 297.0) || (w, h) == (216.0, 279.0),
882 "Expected A4 (210x297) or letter (216x279) paper, got {w}x{h} mm"
883 );
884 }
885 }
886}