paper_sizes/
lib.rs

1//! # paper-sizes
2//!
3//! A library to detect the user's preferred paper size as well as
4//! system-wide and per-user known sizes.  This is a Rust equivalent of
5//! the library features in [libpaper].
6//!
7//! This crate does not provide the `paper` or `paperconf` programs.  Use
8//! [libpaper] for those.
9//!
10//! [libpaper]: https://github.com/rrthomas/libpaper
11//!
12//! # License
13//!
14//! This crate is distributed under your choice of the following licenses:
15//!
16//! * The [MIT License].
17//!
18//! * The [GNU LGPL, version 2.1], or any later version.
19//!
20//! * The [Apache License, version 2.0].
21//!
22//! The `paperspecs` file in this crate is from [libpaper], which documents it
23//! to be in the public domain.
24//!
25//! [MIT License]: https://opensource.org/license/mit
26//! [GNU LGPL, version 2.1]: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html
27//! [Apache License, version 2.0]: https://www.apache.org/licenses/LICENSE-2.0
28#![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
45#[cfg(feature = "serde")]
46mod serde;
47
48include!(concat!(env!("OUT_DIR"), "/paperspecs.rs"));
49
50static PAPERSIZE_FILENAME: &str = "papersize";
51static PAPERSPECS_FILENAME: &str = "paperspecs";
52
53enum DefaultPaper {
54    Name(String),
55    Size(PaperSize),
56}
57
58/// A unit of measurement used for [PaperSize]s.
59#[derive(Copy, Clone, Debug, PartialEq, Eq)]
60pub enum Unit {
61    /// PostScript points (1/72 of an inch).
62    Point,
63
64    /// Inches.
65    Inch,
66
67    /// Millimeters.
68    Millimeter,
69}
70
71/// [Unit] name cannot be parsed.
72#[derive(Copy, Clone, Debug, PartialEq, Eq)]
73pub struct ParseUnitError;
74
75impl Error for ParseUnitError {}
76
77impl Display for ParseUnitError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        write!(f, "unknown unit")
80    }
81}
82
83impl FromStr for Unit {
84    type Err = ParseUnitError;
85
86    /// Parses the name of a unit in the form used in paperspecs files, one of
87    /// `pt`, `in`, or `mm`.
88    fn from_str(s: &str) -> Result<Self, Self::Err> {
89        match s {
90            "pt" => Ok(Self::Point),
91            "in" => Ok(Self::Inch),
92            "mm" => Ok(Self::Millimeter),
93            _ => Err(ParseUnitError),
94        }
95    }
96}
97
98impl Unit {
99    /// Returns the name of the unit in the form used in paperspecs files.
100    pub fn name(&self) -> &'static str {
101        match self {
102            Unit::Point => "pt",
103            Unit::Inch => "in",
104            Unit::Millimeter => "mm",
105        }
106    }
107
108    /// Returns the number of `other` in one unit of `self`.
109    ///
110    /// To convert a quantity of unit `a` into unit `b`, multiply by
111    /// `a.as_unit(b)`.
112    pub fn as_unit(&self, other: Unit) -> f64 {
113        match (*self, other) {
114            (Unit::Point, Unit::Point) => 1.0,
115            (Unit::Point, Unit::Inch) => 1.0 / 72.0,
116            (Unit::Point, Unit::Millimeter) => 25.4 / 72.0,
117            (Unit::Inch, Unit::Point) => 72.0,
118            (Unit::Inch, Unit::Inch) => 1.0,
119            (Unit::Inch, Unit::Millimeter) => 25.4,
120            (Unit::Millimeter, Unit::Point) => 72.0 / 25.4,
121            (Unit::Millimeter, Unit::Inch) => 1.0 / 25.4,
122            (Unit::Millimeter, Unit::Millimeter) => 1.0,
123        }
124    }
125}
126
127impl Display for Unit {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        write!(f, "{}", self.name())
130    }
131}
132
133/// A physical length with a [Unit].
134#[derive(Copy, Clone, Debug, PartialEq)]
135pub struct Length {
136    /// The length.
137    pub value: f64,
138
139    /// The length's unit.
140    pub unit: Unit,
141}
142
143impl Length {
144    /// Constructs a new `Length` from `value` and `unit`.
145    pub fn new(value: f64, unit: Unit) -> Self {
146        Self { value, unit }
147    }
148
149    /// Returns this length converted to `unit`.
150    pub fn as_unit(&self, unit: Unit) -> Self {
151        Self {
152            value: self.value * self.unit.as_unit(unit),
153            unit,
154        }
155    }
156
157    /// Returns the value of this length in `unit`.
158    pub fn into_unit(&self, unit: Unit) -> f64 {
159        self.as_unit(unit).value
160    }
161}
162
163/// An error parsing a [Length].
164#[derive(Copy, Clone, Debug)]
165pub enum ParseLengthError {
166    /// Missing unit.
167    MissingUnit,
168    /// Invalid unit.
169    InvalidUnit,
170    /// Invalid value
171    InvalidValue,
172}
173
174impl Error for ParseLengthError {}
175
176impl Display for ParseLengthError {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        match self {
179            ParseLengthError::MissingUnit => write!(f, "Missing unit"),
180            ParseLengthError::InvalidUnit => write!(f, "Invalid unit of measurement"),
181            ParseLengthError::InvalidValue => write!(f, "Invalid length"),
182        }
183    }
184}
185
186impl FromStr for Length {
187    type Err = ParseLengthError;
188
189    fn from_str(s: &str) -> Result<Self, Self::Err> {
190        if let Some(index) = s.find(|c: char| c.is_alphabetic()) {
191            let (value, unit) = s.split_at(index);
192            let value = value.parse().map_err(|_| ParseLengthError::InvalidValue)?;
193            let unit = unit.parse().map_err(|_| ParseLengthError::InvalidUnit)?;
194            Ok(Self { value, unit })
195        } else {
196            Err(ParseLengthError::MissingUnit)
197        }
198    }
199}
200
201impl Display for Length {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        write!(f, "{}{}", self.value, self.unit)
204    }
205}
206
207/// The size of a piece of paper.
208#[derive(Copy, Clone, Debug, PartialEq)]
209pub struct PaperSize {
210    /// The paper's width, in [unit](Self::unit).
211    pub width: f64,
212
213    /// The paper's height (or length), in [unit](Self::unit).
214    pub height: f64,
215
216    /// The unit of [width](Self::width) and [height](Self::height).
217    pub unit: Unit,
218}
219
220impl Default for PaperSize {
221    /// A4, the internationally standard paper size.
222    fn default() -> Self {
223        Self::new(210.0, 297.0, Unit::Millimeter)
224    }
225}
226
227impl PaperSize {
228    /// Constructs a new `PaperSize`.
229    pub fn new(width: f64, height: f64, unit: Unit) -> Self {
230        Self {
231            width,
232            height,
233            unit,
234        }
235    }
236
237    /// Returns this paper size converted to `unit`.
238    pub fn as_unit(&self, unit: Unit) -> PaperSize {
239        Self {
240            width: self.width * self.unit.as_unit(unit),
241            height: self.height * self.unit.as_unit(unit),
242            unit,
243        }
244    }
245
246    /// Returns this paper size's `width` and `height`, discarding the unit.
247    pub fn into_width_height(self) -> (f64, f64) {
248        (self.width, self.height)
249    }
250
251    /// Returns true if `self` and `other` are equal to the nearest `unit`,
252    /// false otherwise.
253    pub fn eq_rounded(&self, other: &Self, unit: Unit) -> bool {
254        let (aw, ah) = self.as_unit(unit).into_width_height();
255        let (bw, bh) = other.as_unit(unit).into_width_height();
256        aw.round() == bw.round() && ah.round() == bh.round()
257    }
258
259    /// Returns the paper's width as a [Length].
260    pub fn width(&self) -> Length {
261        Length::new(self.width, self.unit)
262    }
263
264    /// Returns the paper's height as a [Length].
265    pub fn height(&self) -> Length {
266        Length::new(self.height, self.unit)
267    }
268}
269
270/// An error parsing a [PaperSize].
271#[derive(Copy, Clone, Debug, PartialEq, Eq)]
272pub enum ParsePaperSizeError {
273    /// Invalid paper height.
274    InvalidHeight,
275
276    /// Invalid paper width.
277    InvalidWidth,
278
279    /// Invalid unit of measurement.
280    InvalidUnit,
281
282    /// Missing unit of measurement.
283    MissingUnit,
284
285    /// Missing delimiter.
286    MissingDelimiter,
287}
288
289impl Error for ParsePaperSizeError {}
290
291impl Display for ParsePaperSizeError {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        match self {
294            ParsePaperSizeError::InvalidHeight => write!(f, "Invalid paper height"),
295            ParsePaperSizeError::InvalidWidth => write!(f, "Invalid paper width"),
296            ParsePaperSizeError::InvalidUnit => write!(f, "Invalid unit of measurement"),
297            ParsePaperSizeError::MissingUnit => write!(f, "Missing unit in paper size"),
298            ParsePaperSizeError::MissingDelimiter => write!(f, "Missing delimiter in paper size"),
299        }
300    }
301}
302
303impl FromStr for PaperSize {
304    type Err = ParsePaperSizeError;
305
306    /// Parses a paper size that takes one of the forms `8.5x11in` or `8.5,11in`
307    /// or `8.5,11,in`, with optional white space.
308    fn from_str(s: &str) -> Result<Self, Self::Err> {
309        let Some((width, rest)) = s.split_once([',', 'x']) else {
310            return Err(ParsePaperSizeError::MissingDelimiter);
311        };
312        let (height, unit) = if let Some(result) = rest.split_once(',') {
313            result
314        } else if let Some(alpha) = rest.find(|c: char| c.is_alphabetic()) {
315            rest.split_at(alpha)
316        } else {
317            return Err(ParsePaperSizeError::MissingUnit);
318        };
319
320        let width = f64::from_str(width.trim()).map_err(|_| ParsePaperSizeError::InvalidWidth)?;
321        let height =
322            f64::from_str(height.trim()).map_err(|_| ParsePaperSizeError::InvalidHeight)?;
323        let unit = Unit::from_str(unit.trim()).map_err(|_| ParsePaperSizeError::InvalidUnit)?;
324        Ok(Self {
325            width,
326            height,
327            unit,
328        })
329    }
330}
331
332impl Display for PaperSize {
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        write!(f, "{}x{}{}", self.width, self.height, self.unit)
335    }
336}
337
338/// An error parsing a [PaperSpec].
339#[derive(Copy, Clone, Debug, PartialEq, Eq)]
340pub enum ParsePaperSpecError {
341    /// Invalid paper height.
342    InvalidHeight,
343
344    /// Invalid paper width.
345    InvalidWidth,
346
347    /// Invalid unit of measurement.
348    InvalidUnit,
349
350    /// Missing field in paper specification.
351    MissingField,
352}
353
354impl From<ParsePaperSizeError> for ParsePaperSpecError {
355    fn from(value: ParsePaperSizeError) -> Self {
356        match value {
357            ParsePaperSizeError::InvalidHeight => Self::InvalidHeight,
358            ParsePaperSizeError::InvalidWidth => Self::InvalidWidth,
359            ParsePaperSizeError::InvalidUnit => Self::InvalidUnit,
360            ParsePaperSizeError::MissingUnit => Self::MissingField,
361            ParsePaperSizeError::MissingDelimiter => Self::MissingField,
362        }
363    }
364}
365
366impl Error for ParsePaperSpecError {}
367
368impl Display for ParsePaperSpecError {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        match self {
371            ParsePaperSpecError::InvalidHeight => write!(f, "Invalid paper height."),
372            ParsePaperSpecError::InvalidWidth => write!(f, "Invalid paper width."),
373            ParsePaperSpecError::InvalidUnit => write!(f, "Invalid unit of measurement."),
374            ParsePaperSpecError::MissingField => write!(f, "Missing field in paper specification."),
375        }
376    }
377}
378
379/// A named [PaperSize].
380#[derive(Clone, Debug, PartialEq)]
381pub struct PaperSpec {
382    /// The paper's name, such as `A4` or `Letter`.
383    pub name: Cow<'static, str>,
384
385    /// The paper's size.
386    pub size: PaperSize,
387}
388
389impl PaperSpec {
390    /// Construct a new `PaperSpec`.
391    pub fn new(name: impl Into<Cow<'static, str>>, size: PaperSize) -> Self {
392        Self {
393            name: name.into(),
394            size,
395        }
396    }
397}
398
399impl FromStr for PaperSpec {
400    type Err = ParsePaperSpecError;
401
402    /// Parses a paper specification as `name,<size>`, where `<size>` is one of
403    /// the formats supported by [PaperSize::from_str].
404    ///
405    /// The canonical form of a paper specification is `name,width,height,unit`.
406    fn from_str(s: &str) -> Result<Self, Self::Err> {
407        let (name, size) = s.split_once(',').ok_or(ParsePaperSpecError::MissingField)?;
408        Ok(Self {
409            name: String::from(name).into(),
410            size: size.parse()?,
411        })
412    }
413}
414
415/// An error encountered building a [Catalog].
416#[derive(Debug)]
417pub enum CatalogBuildError {
418    /// Line {line_number}: {error}
419    ParseError {
420        /// The file where the parse error occurred.
421        path: PathBuf,
422
423        /// The 1-based line number on which the parse error occurred.
424        line_number: usize,
425
426        /// The parse error.
427        error: ParsePaperSpecError,
428    },
429
430    /// I/O error.
431    IoError {
432        /// The file where the I/O error occurred.
433        path: PathBuf,
434
435        /// Error details.
436        error: std::io::Error,
437    },
438}
439
440impl Error for CatalogBuildError {}
441
442impl Display for CatalogBuildError {
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        match self {
445            CatalogBuildError::ParseError {
446                path,
447                line_number,
448                error,
449            } => write!(f, "{}:{line_number}: {error}", path.display()),
450            CatalogBuildError::IoError { path, error } => {
451                write!(f, "{}: {error}", path.display())
452            }
453        }
454    }
455}
456
457/// A builder for constructing a [Catalog].
458///
459/// `CatalogBuilder` allows control over the process of constructing a
460/// [Catalog].  If the default options are acceptable, [Catalog::new] bypasses
461/// the need for `CatalogBuilder`.
462pub struct CatalogBuilder<'a> {
463    papersize: Option<Option<&'a str>>,
464    use_locale: bool,
465    user_config_dir: Option<Option<&'a Path>>,
466    system_config_dir: Option<&'a Path>,
467    error_cb: Box<dyn FnMut(CatalogBuildError) + 'a>,
468}
469
470impl<'a> Default for CatalogBuilder<'a> {
471    fn default() -> Self {
472        Self {
473            use_locale: true,
474            papersize: None,
475            user_config_dir: None,
476            system_config_dir: Some(Path::new("/etc")),
477            error_cb: Box::new(drop),
478        }
479    }
480}
481
482fn fallback_specs() -> (Vec<PaperSpec>, PaperSpec) {
483    let specs = STANDARD_PAPERSPECS.into_iter().cloned().collect::<Vec<_>>();
484    let default = specs.first().unwrap().clone();
485    (specs, default)
486}
487
488fn read_specs<E>(
489    user_config_dir: Option<&Path>,
490    system_config_dir: Option<&Path>,
491    mut error_cb: E,
492) -> Option<(Vec<PaperSpec>, PaperSpec)>
493where
494    E: FnMut(CatalogBuildError),
495{
496    fn read_paperspecs_file(
497        directory: Option<&Path>,
498        error_cb: &mut dyn FnMut(CatalogBuildError),
499    ) -> Vec<PaperSpec> {
500        let mut specs = Vec::new();
501        if let Some(directory) = directory {
502            let path = directory.join(PAPERSPECS_FILENAME);
503            match File::open(&path) {
504                Ok(file) => {
505                    let reader = BufReader::new(file);
506                    for (line, line_number) in reader.lines().zip(1..) {
507                        match line
508                            .map_err(|error| CatalogBuildError::IoError {
509                                path: path.clone(),
510                                error,
511                            })
512                            .and_then(|line| {
513                                PaperSpec::from_str(&line).map_err(|error| {
514                                    CatalogBuildError::ParseError {
515                                        path: path.clone(),
516                                        line_number,
517                                        error,
518                                    }
519                                })
520                            }) {
521                            Ok(spec) => specs.push(spec),
522                            Err(error) => error_cb(error),
523                        }
524                    }
525                }
526                Err(error) if error.kind() == ErrorKind::NotFound => (),
527                Err(error) => error_cb(CatalogBuildError::IoError { path, error }),
528            }
529        }
530        specs
531    }
532
533    let user_specs = read_paperspecs_file(user_config_dir, &mut error_cb);
534    let system_specs = read_paperspecs_file(system_config_dir, &mut error_cb);
535    let default_spec = system_specs.first().or(user_specs.first())?.clone();
536    Some((
537        user_specs.into_iter().chain(system_specs).collect(),
538        default_spec,
539    ))
540}
541
542fn default_paper<E>(
543    papersize: Option<Option<&str>>,
544    user_config_dir: Option<&Path>,
545    _use_locale: bool,
546    system_config_dir: Option<&Path>,
547    default: &PaperSpec,
548    mut error_cb: E,
549) -> DefaultPaper
550where
551    E: FnMut(CatalogBuildError),
552{
553    fn read_papersize_file<P, E>(path: P, mut error_cb: E) -> Option<String>
554    where
555        P: AsRef<Path>,
556        E: FnMut(CatalogBuildError),
557    {
558        fn inner(path: &Path) -> std::io::Result<Option<String>> {
559            let file = BufReader::new(File::open(path)?);
560            let line = file.lines().next().unwrap_or(Ok(String::new()))?;
561            let name = line.split(',').next().unwrap_or("");
562            Ok(name.is_empty().not().then(|| name.into()))
563        }
564        let path = path.as_ref();
565        match inner(path) {
566            Ok(result) => result,
567            Err(error) => {
568                if error.kind() != ErrorKind::NotFound {
569                    error_cb(CatalogBuildError::IoError {
570                        path: path.to_path_buf(),
571                        error,
572                    });
573                }
574                None
575            }
576        }
577    }
578
579    // Use `PAPERSIZE` from the environment (or from the override).
580    let env_var;
581    let paper_name = match papersize {
582        Some(paper_name) => paper_name,
583        None => {
584            env_var = std::env::var("PAPERSIZE").ok();
585            env_var.as_deref()
586        }
587    };
588    if let Some(paper_name) = paper_name
589        && !paper_name.is_empty()
590    {
591        return DefaultPaper::Name(paper_name.into());
592    }
593
594    // Then try the user configuration directory.
595    if let Some(dir) = user_config_dir
596        && let path = dir.join(PAPERSIZE_FILENAME)
597        && let Some(paper_name) = read_papersize_file(path, &mut error_cb)
598    {
599        return DefaultPaper::Name(paper_name);
600    }
601
602    // Then try the locale.
603    #[cfg(target_os = "linux")]
604    if _use_locale && let Some(paper_size) = locale::locale_paper_size() {
605        return DefaultPaper::Size(paper_size);
606    }
607
608    if let Some(system_config_dir) = system_config_dir
609        && let Some(paper_name) =
610            read_papersize_file(system_config_dir.join(PAPERSIZE_FILENAME), &mut error_cb)
611    {
612        return DefaultPaper::Name(paper_name);
613    }
614
615    // Otherwise take it from the default papers.
616    DefaultPaper::Name(default.name.as_ref().into())
617}
618
619impl<'a> CatalogBuilder<'a> {
620    /// Constructs a new `CatalogBuilder` with default settings.
621    pub fn new() -> Self {
622        Self::default()
623    }
624
625    /// Builds a [Catalog] and chooses a default paper size by reading the by
626    /// reading `paperspecs` and `papersize` files and examining the
627    /// environment and (on GNU/Linux) locale.
628    ///
629    /// If no system or user `paperspecs` files exist, or if they exist but they
630    /// contain no valid paper specifications, then this method uses the
631    /// standard paper sizes in [`STANDARD_PAPERSPECS`].  This is usually a
632    /// reasonable fallback.
633    pub fn build(self) -> Catalog {
634        self.build_inner(|user_config_dir, system_config_dir, error_cb| {
635            Some(
636                read_specs(user_config_dir, system_config_dir, error_cb)
637                    .unwrap_or_else(fallback_specs),
638            )
639        })
640        .unwrap()
641    }
642
643    /// Builds a [Catalog] from [`STANDARD_PAPERSPECS`] and chooses a default
644    /// paper size by reading the by reading `papersize` files and examining the
645    /// environment and (on GNU/Linux) locale.
646    ///
647    /// This is a reasonable choice if it is unlikely for `paperspecs` to be
648    /// installed but it is still desirable to detect a default paper size.
649    pub fn build_from_fallback(self) -> Catalog {
650        self.build_inner(|_, _, _| Some(fallback_specs())).unwrap()
651    }
652
653    /// Tries to build a [Catalog] and chooses a default paper size by reading
654    /// the by reading `paperspecs` and `papersize` files and examining the
655    /// environment and (on GNU/Linux) locale.
656    ///
657    /// If no system or user `paperspecs` files exist, or if they exist but they
658    /// contain no valid paper specifications, this method fails and returns
659    /// `None`.
660    pub fn build_without_fallback(self) -> Option<Catalog> {
661        self.build_inner(|user_config_dir, system_config_dir, error_cb| {
662            read_specs(user_config_dir, system_config_dir, error_cb)
663        })
664    }
665
666    /// Sets `papersize` to be used for the value of the `PAPERSIZE` environment
667    /// variable, instead of obtaining it from the process environment.  `None`
668    /// means that the environment variable is assumed to be empty or absent.
669    pub fn with_papersize_value(self, papersize: Option<&'a str>) -> Self {
670        Self {
671            papersize: Some(papersize),
672            ..self
673        }
674    }
675
676    /// On GNU/Linux, by default, `CatalogBuilder` will consider the paper size
677    /// setting in the glibc locale `LC_PAPER`.  This method disables this
678    /// feature.
679    ///
680    /// This setting has no effect on other operating systems, which do not
681    /// support paper size as part of their locales.
682    pub fn without_locale(self) -> Self {
683        Self {
684            use_locale: false,
685            ..self
686        }
687    }
688
689    /// Overrides the name of the user-specific configuration directory.
690    ///
691    /// This directory is searched for the user-specified `paperspecs` and
692    /// `papersize` files.  It defaults to `$XDG_CONFIG_HOME`, which is usually
693    /// `$HOME/.config`.
694    ///
695    /// Passing `None` will disable reading `paperspec` or `papersize` from the
696    /// user configuration directory.
697    pub fn with_user_config_dir(self, user_config_dir: Option<&'a Path>) -> Self {
698        Self {
699            user_config_dir: Some(user_config_dir),
700            ..self
701        }
702    }
703
704    /// Overrides the name of the system configuration directory.
705    ///
706    /// This directory is searched for the system `paperspecs` and `papersize`
707    /// files.  It defaults to `/etc`.
708    ///
709    /// Passing `None` will disable reading `paperspec` or `papersize` from the
710    /// system configuration directory.
711    pub fn with_system_config_dir(self, system_config_dir: Option<&'a Path>) -> Self {
712        Self {
713            system_config_dir,
714            ..self
715        }
716    }
717
718    /// Sets an error reporting callback.
719    ///
720    /// By default, [CatalogBuilder] ignores errors while building the catalog.
721    /// The `error_cb` callback allows the caller to receive information about
722    /// these errors.
723    ///
724    /// It is not considered an error if `paperspecs` or `papersize` files do
725    /// not exist.
726    pub fn with_error_callback(self, error_cb: Box<dyn FnMut(CatalogBuildError) + 'a>) -> Self {
727        Self { error_cb, ..self }
728    }
729
730    fn build_inner<F>(mut self, f: F) -> Option<Catalog>
731    where
732        F: Fn(
733            Option<&Path>,
734            Option<&Path>,
735            &mut Box<dyn FnMut(CatalogBuildError) + 'a>,
736        ) -> Option<(Vec<PaperSpec>, PaperSpec)>,
737    {
738        let base_directories;
739        let user_config_dir = match self.user_config_dir {
740            Some(user_config_dir) => user_config_dir,
741            None => {
742                base_directories = BaseDirectories::new();
743                base_directories.config_home.as_deref()
744            }
745        };
746        let (specs, default) = f(user_config_dir, self.system_config_dir, &mut self.error_cb)?;
747        let default = match default_paper(
748            self.papersize,
749            user_config_dir,
750            self.use_locale,
751            self.system_config_dir,
752            &default,
753            &mut self.error_cb,
754        ) {
755            DefaultPaper::Name(name) => specs
756                .iter()
757                .find(|spec| spec.name.eq_ignore_ascii_case(&name))
758                .cloned()
759                .unwrap_or(default),
760            DefaultPaper::Size(size) => specs
761                .iter()
762                .find(|spec| spec.size.eq_rounded(&size, Unit::Point))
763                .cloned()
764                .unwrap_or_else(|| PaperSpec::new(Cow::from("Locale"), size)),
765        };
766
767        Some(Catalog { specs, default })
768    }
769}
770
771/// A collection of [PaperSpec]s and a default paper size.
772pub struct Catalog {
773    specs: Vec<PaperSpec>,
774    default: PaperSpec,
775}
776
777impl Default for Catalog {
778    fn default() -> Self {
779        Self::builder().build()
780    }
781}
782
783impl Catalog {
784    /// Constructs a new [CatalogBuilder].
785    pub fn builder<'a>() -> CatalogBuilder<'a> {
786        CatalogBuilder::new()
787    }
788
789    /// Constructs a new catalog by reading `paperspecs` and `papersize` files
790    /// and examining the environment.
791    ///
792    /// This is equivalent to `Catalog::builder().build()`.
793    pub fn new() -> Self {
794        Self::default()
795    }
796
797    /// Returns the contents of the catalog, as a nonempty list of user-specific
798    /// paper sizes, followed by system paper sizes.
799    pub fn specs(&self) -> &[PaperSpec] {
800        &self.specs
801    }
802
803    /// Returns the default paper size.
804    ///
805    /// This paper size might not be in the catalog's list of [PaperSpec]s
806    /// because the default can be specified in terms of measurements rather
807    /// than as a name.
808    pub fn default_paper(&self) -> &PaperSpec {
809        &self.default
810    }
811
812    /// Returns the first [PaperSpec] in the catalog with the given `size` (to
813    /// the nearest PostScript point).
814    pub fn get_by_size(&self, size: &PaperSize) -> Option<&PaperSpec> {
815        self.specs
816            .iter()
817            .find(|spec| spec.size.eq_rounded(size, Unit::Point))
818    }
819
820    /// Returns the first [PaperSpec] in the catalog whose name equals `name`,
821    /// disregarding ASCII case.
822    pub fn get_by_name(&self, name: &str) -> Option<&PaperSpec> {
823        self.specs
824            .iter()
825            .find(|spec| spec.name.eq_ignore_ascii_case(name))
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use std::{borrow::Cow, path::Path, str::FromStr};
832
833    use crate::{
834        A4, CatalogBuildError, CatalogBuilder, Length, PaperSize, PaperSpec, ParsePaperSizeError,
835        ParsePaperSpecError, Unit, locale,
836    };
837
838    #[test]
839    fn unit() {
840        assert_eq!(Unit::Point.to_string(), "pt");
841        assert_eq!(Unit::Millimeter.to_string(), "mm");
842        assert_eq!(Unit::Inch.to_string(), "in");
843
844        assert_eq!("pt".parse(), Ok(Unit::Point));
845        assert_eq!("mm".parse(), Ok(Unit::Millimeter));
846        assert_eq!("in".parse(), Ok(Unit::Inch));
847
848        assert_eq!(
849            format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Millimeter)),
850            "25.400"
851        );
852        assert_eq!(
853            format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Inch)),
854            "1.000"
855        );
856        assert_eq!(
857            format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Point)),
858            "72.000"
859        );
860        assert_eq!(
861            format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Millimeter)),
862            "12.700"
863        );
864        assert_eq!(
865            format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Inch)),
866            "0.500"
867        );
868        assert_eq!(
869            format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Point)),
870            "36.000"
871        );
872        assert_eq!(
873            format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Millimeter)),
874            "12.700"
875        );
876        assert_eq!(
877            format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Inch)),
878            "0.500"
879        );
880        assert_eq!(
881            format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Point)),
882            "36.000"
883        );
884    }
885
886    #[test]
887    fn length() {
888        assert_eq!(
889            format!(
890                "{:.3}",
891                Length::new(1.0, Unit::Inch).into_unit(Unit::Millimeter)
892            ),
893            "25.400"
894        );
895        assert_eq!(
896            format!("{:.3}", Length::new(1.0, Unit::Inch).into_unit(Unit::Inch)),
897            "1.000"
898        );
899        assert_eq!(
900            format!("{:.3}", Length::new(1.0, Unit::Inch).into_unit(Unit::Point)),
901            "72.000"
902        );
903        assert_eq!(
904            format!(
905                "{:.3}",
906                Length::new(36.0, Unit::Point).into_unit(Unit::Millimeter)
907            ),
908            "12.700"
909        );
910        assert_eq!(
911            format!(
912                "{:.3}",
913                Length::new(36.0, Unit::Point).into_unit(Unit::Inch)
914            ),
915            "0.500"
916        );
917        assert_eq!(
918            format!(
919                "{:.3}",
920                Length::new(36.0, Unit::Point).into_unit(Unit::Point)
921            ),
922            "36.000"
923        );
924        assert_eq!(
925            format!(
926                "{:.3}",
927                Length::new(12.7, Unit::Millimeter).into_unit(Unit::Millimeter)
928            ),
929            "12.700"
930        );
931        assert_eq!(
932            format!(
933                "{:.3}",
934                Length::new(12.7, Unit::Millimeter).into_unit(Unit::Inch)
935            ),
936            "0.500"
937        );
938        assert_eq!(
939            format!(
940                "{:.3}",
941                Length::new(12.7, Unit::Millimeter).into_unit(Unit::Point)
942            ),
943            "36.000"
944        );
945    }
946
947    #[test]
948    fn papersize() {
949        assert_eq!(
950            "8.5x11in".parse(),
951            Ok(PaperSize::new(8.5, 11.0, Unit::Inch))
952        );
953        assert_eq!(
954            "8.5,11in".parse(),
955            Ok(PaperSize::new(8.5, 11.0, Unit::Inch))
956        );
957        assert_eq!(
958            " 8.5 x 11 in ".parse(),
959            Ok(PaperSize::new(8.5, 11.0, Unit::Inch))
960        );
961        assert_eq!(
962            PaperSize::from_str("8.5x.in"),
963            Err(ParsePaperSizeError::InvalidHeight)
964        );
965        assert_eq!(
966            PaperSize::from_str(".x11in"),
967            Err(ParsePaperSizeError::InvalidWidth)
968        );
969        assert_eq!(
970            PaperSize::from_str("8.5x11xyzzy"),
971            Err(ParsePaperSizeError::InvalidUnit)
972        );
973        assert_eq!(
974            PaperSize::from_str("8.5x11"),
975            Err(ParsePaperSizeError::MissingUnit)
976        );
977        assert_eq!(
978            PaperSize::from_str(" 8.5  11 in "),
979            Err(ParsePaperSizeError::MissingDelimiter)
980        );
981        assert_eq!(
982            PaperSize::new(8.5, 11.0, Unit::Inch).to_string(),
983            "8.5x11in"
984        );
985        assert_eq!(A4.size.to_string(), "210x297mm");
986    }
987
988    #[test]
989    fn paperspec() {
990        assert_eq!(
991            "Letter,8.5,11,in".parse(),
992            Ok(PaperSpec::new(
993                Cow::from("Letter"),
994                PaperSize::new(8.5, 11.0, Unit::Inch)
995            ))
996        );
997        assert_eq!(
998            "Letter,8.5x11in".parse(),
999            Ok(PaperSpec::new(
1000                Cow::from("Letter"),
1001                PaperSize::new(8.5, 11.0, Unit::Inch)
1002            ))
1003        );
1004    }
1005
1006    #[test]
1007    fn default() {
1008        // Default from $PAPERSIZE.
1009        assert_eq!(
1010            CatalogBuilder::new()
1011                .with_papersize_value(Some("legal"))
1012                .with_user_config_dir(Some(Path::new("testdata/td1")))
1013                .without_locale()
1014                .build_from_fallback()
1015                .default_paper(),
1016            &PaperSpec::new(Cow::from("Legal"), PaperSize::new(8.5, 14.0, Unit::Inch))
1017        );
1018
1019        // Default from user_config_dir.
1020        assert_eq!(
1021            CatalogBuilder::new()
1022                .with_papersize_value(None)
1023                .with_user_config_dir(Some(Path::new("testdata/td1")))
1024                .without_locale()
1025                .build_from_fallback()
1026                .default_paper(),
1027            &PaperSpec::new(Cow::from("Ledger"), PaperSize::new(17.0, 11.0, Unit::Inch))
1028        );
1029
1030        // Default from system_config_dir.
1031        assert_eq!(
1032            CatalogBuilder::new()
1033                .with_papersize_value(None)
1034                .with_user_config_dir(None)
1035                .with_system_config_dir(Some(Path::new("testdata/td2")))
1036                .without_locale()
1037                .build_from_fallback()
1038                .default_paper(),
1039            &PaperSpec::new(
1040                Cow::from("Executive"),
1041                PaperSize::new(7.25, 10.5, Unit::Inch)
1042            )
1043        );
1044
1045        // Default from the first system paper size.
1046        assert_eq!(
1047            CatalogBuilder::new()
1048                .with_papersize_value(None)
1049                .with_user_config_dir(None)
1050                .with_system_config_dir(Some(Path::new("testdata/td2")))
1051                .without_locale()
1052                .build()
1053                .default_paper(),
1054            &PaperSpec::new(
1055                Cow::from("A0"),
1056                PaperSize::new(841.0, 1189.0, Unit::Millimeter)
1057            )
1058        );
1059
1060        // Default from the first user paper size.
1061        assert_eq!(
1062            CatalogBuilder::new()
1063                .with_papersize_value(None)
1064                .with_user_config_dir(Some(Path::new("testdata/td3")))
1065                .with_system_config_dir(None)
1066                .without_locale()
1067                .build()
1068                .default_paper(),
1069            &PaperSpec::new(
1070                Cow::from("B0"),
1071                PaperSize::new(1000.0, 1414.0, Unit::Millimeter)
1072            )
1073        );
1074
1075        // Default when nothing can be read and fallback triggers.
1076        assert_eq!(
1077            CatalogBuilder::new()
1078                .with_papersize_value(None)
1079                .with_user_config_dir(None)
1080                .with_system_config_dir(None)
1081                .without_locale()
1082                .build()
1083                .default_paper(),
1084            &PaperSpec::new(
1085                Cow::from("A4"),
1086                PaperSize::new(210.0, 297.0, Unit::Millimeter)
1087            )
1088        );
1089
1090        // Verify that nothing can be read in the previous case.
1091        assert!(
1092            CatalogBuilder::new()
1093                .with_papersize_value(None)
1094                .with_user_config_dir(None)
1095                .with_system_config_dir(None)
1096                .without_locale()
1097                .build_without_fallback()
1098                .is_none()
1099        );
1100    }
1101
1102    #[test]
1103    fn errors() {
1104        // Missing files are not errors.
1105        let mut errors = Vec::new();
1106        let _ = CatalogBuilder::new()
1107            .with_papersize_value(None)
1108            .with_user_config_dir(Some(Path::new("nonexistent/user")))
1109            .with_system_config_dir(Some(Path::new("nonexistent/system")))
1110            .without_locale()
1111            .with_error_callback(Box::new(|error| errors.push(error)))
1112            .build()
1113            .default_paper();
1114        assert_eq!(errors.len(), 0);
1115
1116        // Test parse errors.
1117        let mut errors = Vec::new();
1118        let _ = CatalogBuilder::new()
1119            .with_papersize_value(None)
1120            .with_user_config_dir(None)
1121            .with_system_config_dir(Some(Path::new("testdata/td4")))
1122            .without_locale()
1123            .with_error_callback(Box::new(|error| errors.push(error)))
1124            .build()
1125            .default_paper();
1126
1127        assert_eq!(errors.len(), 4);
1128        for ((error, expect_line_number), expect_error) in errors.iter().zip(1..).zip([
1129            ParsePaperSpecError::MissingField,
1130            ParsePaperSpecError::InvalidWidth,
1131            ParsePaperSpecError::InvalidHeight,
1132            ParsePaperSpecError::InvalidUnit,
1133        ]) {
1134            let CatalogBuildError::ParseError {
1135                path,
1136                line_number,
1137                error,
1138            } = error
1139            else {
1140                unreachable!()
1141            };
1142            assert_eq!(path.as_path(), Path::new("testdata/td4/paperspecs"));
1143            assert_eq!(*line_number, expect_line_number);
1144            assert_eq!(*error, expect_error);
1145        }
1146    }
1147
1148    #[cfg(target_os = "linux")]
1149    #[test]
1150    fn lc_paper() {
1151        // Haven't figured out a good way to test this.
1152        //
1153        // I expect that all locales default to either A4 or letter-sized paper,
1154        // so just check for that.
1155        if let Some(size) = locale::locale_paper_size() {
1156            assert_eq!(size.unit, Unit::Millimeter);
1157            let (w, h) = size.into_width_height();
1158            assert!(
1159                (w, h) == (210.0, 297.0) || (w, h) == (216.0, 279.0),
1160                "Expected A4 (210x297) or letter (216x279) paper, got {w}x{h} mm"
1161            );
1162        }
1163    }
1164}