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
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/// A unit of measurement used for [PaperSize]s.
56#[derive(Copy, Clone, Debug, PartialEq, Eq)]
57pub enum Unit {
58    /// PostScript points (1/72 of an inch).
59    Point,
60
61    /// Inches.
62    Inch,
63
64    /// Millimeters.
65    Millimeter,
66}
67
68/// [Unit] name cannot be parsed.
69#[derive(Copy, Clone, Debug, PartialEq, Eq)]
70pub struct ParseUnitError;
71
72impl FromStr for Unit {
73    type Err = ParseUnitError;
74
75    /// Parses the name of a unit in the form used in paperspecs files, one of
76    /// `pt`, `in`, or `mm`.
77    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    /// Returns the name of the unit in the form used in paperspecs files.
89    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    /// Returns the number of `other` in one unit of `self`.
98    ///
99    /// To convert a quantity of unit `a` into unit `b`, multiply by
100    /// `a.as_unit(b)`.
101    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/// The size of a piece of paper.
123#[derive(Copy, Clone, Debug, PartialEq)]
124pub struct PaperSize {
125    /// The paper's width, in [unit](Self::unit).
126    pub width: f64,
127
128    /// The paper's height (or length), in [unit](Self::unit).
129    pub height: f64,
130
131    /// The unit of [width](Self::width) and [height](Self::height).
132    pub unit: Unit,
133}
134
135impl Default for PaperSize {
136    /// A4, the internationally standard paper size.
137    fn default() -> Self {
138        Self::new(210.0, 297.0, Unit::Millimeter)
139    }
140}
141
142impl PaperSize {
143    /// Constructs a new `PaperSize`.
144    pub fn new(width: f64, height: f64, unit: Unit) -> Self {
145        Self {
146            width,
147            height,
148            unit,
149        }
150    }
151
152    /// Returns this paper size converted to `unit`.
153    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    /// Returns this paper size's `width` and `height`, discarding the unit.
162    pub fn into_width_height(self) -> (f64, f64) {
163        (self.width, self.height)
164    }
165
166    /// Returns true if `self` and `other` are equal to the nearest `unit`,
167    /// false otherwise.
168    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/// An error parsing a [PaperSpec].
176#[derive(Copy, Clone, Debug, PartialEq, Eq)]
177pub enum ParsePaperSpecError {
178    /// Invalid paper height.
179    InvalidHeight,
180
181    /// Invalid paper width.
182    InvalidWidth,
183
184    /// Invalid unit of measurement.
185    InvalidUnit,
186
187    /// Missing field in paper specification.
188    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/// A named [PaperSize].
205#[derive(Clone, Debug, PartialEq)]
206pub struct PaperSpec {
207    /// The paper's name, such as `A4` or `Letter`.
208    pub name: Cow<'static, str>,
209
210    /// The paper's size.
211    pub size: PaperSize,
212}
213
214impl PaperSpec {
215    /// Construct a new `PaperSpec`.
216    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/// An error encountered building a [Catalog].
248#[derive(Debug)]
249pub enum CatalogBuildError {
250    /// Line {line_number}: {error}
251    ParseError {
252        /// The file where the parse error occurred.
253        path: PathBuf,
254
255        /// The 1-based line number on which the parse error occurred.
256        line_number: usize,
257
258        /// The parse error.
259        error: ParsePaperSpecError,
260    },
261
262    /// I/O error.
263    IoError {
264        /// The file where the I/O error occurred.
265        path: PathBuf,
266
267        /// Error details.
268        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
289/// A builder for constructing a [Catalog].
290///
291/// `CatalogBuilder` allows control over the process of constructing a
292/// [Catalog].  If the default options are acceptable, [Catalog::new] bypasses
293/// the need for `CatalogBuilder`.
294pub 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    // Use `PAPERSIZE` from the environment (or from the override).
412    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    // Then try the user configuration directory.
427    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    // Then try the locale.
435    #[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    // Otherwise take it from the default papers.
448    DefaultPaper::Name(default.name.as_ref().into())
449}
450
451impl<'a> CatalogBuilder<'a> {
452    /// Constructs a new `CatalogBuilder` with default settings.
453    pub fn new() -> Self {
454        Self::default()
455    }
456
457    /// Builds a [Catalog] and chooses a default paper size by reading the by
458    /// reading `paperspecs` and `papersize` files and examining the
459    /// environment and (on GNU/Linux) locale.
460    ///
461    /// If no system or user `paperspecs` files exist, or if they exist but they
462    /// contain no valid paper specifications, then this method uses the
463    /// standard paper sizes in [`STANDARD_PAPERSPECS`].  This is usually a
464    /// reasonable fallback.
465    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    /// Builds a [Catalog] from [`STANDARD_PAPERSPECS`] and chooses a default
476    /// paper size by reading the by reading `papersize` files and examining the
477    /// environment and (on GNU/Linux) locale.
478    ///
479    /// This is a reasonable choice if it is unlikely for `paperspecs` to be
480    /// installed but it is still desirable to detect a default paper size.
481    pub fn build_from_fallback(self) -> Catalog {
482        self.build_inner(|_, _, _| Some(fallback_specs())).unwrap()
483    }
484
485    /// Tries to build a [Catalog] and chooses a default paper size by reading
486    /// the by reading `paperspecs` and `papersize` files and examining the
487    /// environment and (on GNU/Linux) locale.
488    ///
489    /// If no system or user `paperspecs` files exist, or if they exist but they
490    /// contain no valid paper specifications, this method fails and returns
491    /// `None`.
492    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    /// Sets `papersize` to be used for the value of the `PAPERSIZE` environment
499    /// variable, instead of obtaining it from the process environment.  `None`
500    /// means that the environment variable is assumed to be empty or absent.
501    pub fn with_papersize_value(self, papersize: Option<&'a str>) -> Self {
502        Self {
503            papersize: Some(papersize),
504            ..self
505        }
506    }
507
508    /// On GNU/Linux, by default, `CatalogBuilder` will consider the paper size
509    /// setting in the glibc locale `LC_PAPER`.  This method disables this
510    /// feature.
511    ///
512    /// This setting has no effect on other operating systems, which do not
513    /// support paper size as part of their locales.
514    pub fn without_locale(self) -> Self {
515        Self {
516            use_locale: false,
517            ..self
518        }
519    }
520
521    /// Overrides the name of the user-specific configuration directory.
522    ///
523    /// This directory is searched for the user-specified `paperspecs` and
524    /// `papersize` files.  It defaults to `$XDG_CONFIG_HOME`, which is usually
525    /// `$HOME/.config`.
526    ///
527    /// Passing `None` will disable reading `paperspec` or `papersize` from the
528    /// user configuration directory.
529    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    /// Overrides the name of the system configuration directory.
537    ///
538    /// This directory is searched for the system `paperspecs` and `papersize`
539    /// files.  It defaults to `/etc`.
540    ///
541    /// Passing `None` will disable reading `paperspec` or `papersize` from the
542    /// system configuration directory.
543    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    /// Sets an error reporting callback.
551    ///
552    /// By default, [CatalogBuilder] ignores errors while building the catalog.
553    /// The `error_cb` callback allows the caller to receive information about
554    /// these errors.
555    ///
556    /// It is not considered an error if `paperspecs` or `papersize` files do
557    /// not exist.
558    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
603/// A collection of [PaperSpec]s and a default paper size.
604pub 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    /// Constructs a new [CatalogBuilder].
617    pub fn builder<'a>() -> CatalogBuilder<'a> {
618        CatalogBuilder::new()
619    }
620
621    /// Constructs a new catalog by reading `paperspecs` and `papersize` files
622    /// and examining the environment.
623    ///
624    /// This is equivalent to `Catalog::builder().build()`.
625    pub fn new() -> Self {
626        Self::default()
627    }
628
629    /// Returns the contents of the catalog, as a nonempty list of user-specific
630    /// paper sizes, followed by system paper sizes.
631    pub fn specs(&self) -> &[PaperSpec] {
632        &self.specs
633    }
634
635    /// Returns the default paper size.
636    ///
637    /// This paper size might not be in the catalog's list of [PaperSpec]s
638    /// because the default can be specified in terms of measurements rather
639    /// than as a name.
640    pub fn default_paper(&self) -> &PaperSpec {
641        &self.default
642    }
643
644    /// Returns the first [PaperSpec] in the catalog with the given `size` (to
645    /// the nearest PostScript point).
646    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    /// Returns the first [PaperSpec] in the catalog whose name equals `name`,
653    /// disregarding ASCII case.
654    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        // Default from $PAPERSIZE.
731        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        // Default from user_config_dir.
742        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        // Default from system_config_dir.
753        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        // Default from the first system paper size.
768        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        // Default from the first user paper size.
783        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        // Default when nothing can be read and fallback triggers.
798        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        // Verify that nothing can be read in the previous case.
813        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        // Missing files are not errors.
827        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        // Test parse errors.
839        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        // Haven't figured out a good way to test this.
874        //
875        // I expect that all locales default to either A4 or letter-sized paper,
876        // so just check for that.
877        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}