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 [PaperSize].
176#[derive(Copy, Clone, Debug, PartialEq, Eq)]
177pub enum ParsePaperSizeError {
178    /// Invalid paper height.
179    InvalidHeight,
180
181    /// Invalid paper width.
182    InvalidWidth,
183
184    /// Invalid unit of measurement.
185    InvalidUnit,
186
187    /// Missing unit of measurement.
188    MissingUnit,
189
190    /// Missing delimiter.
191    MissingDelimiter,
192}
193
194impl Error for ParsePaperSizeError {}
195
196impl Display for ParsePaperSizeError {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        match self {
199            ParsePaperSizeError::InvalidHeight => write!(f, "Invalid paper height"),
200            ParsePaperSizeError::InvalidWidth => write!(f, "Invalid paper width"),
201            ParsePaperSizeError::InvalidUnit => write!(f, "Invalid unit of measurement"),
202            ParsePaperSizeError::MissingUnit => write!(f, "Missing unit in paper size"),
203            ParsePaperSizeError::MissingDelimiter => write!(f, "Missing delimiter in paper size"),
204        }
205    }
206}
207
208impl FromStr for PaperSize {
209    type Err = ParsePaperSizeError;
210
211    /// Parses a paper size that takes one of the forms `8.5x11in` or `8.5,11in`
212    /// or `8.5,11,in`, with optional white space.
213    fn from_str(s: &str) -> Result<Self, Self::Err> {
214        let Some((width, rest)) = s.split_once([',', 'x']) else {
215            return Err(ParsePaperSizeError::MissingDelimiter);
216        };
217        let (height, unit) = if let Some(result) = rest.split_once(',') {
218            result
219        } else if let Some(alpha) = rest.find(|c: char| c.is_alphabetic()) {
220            rest.split_at(alpha)
221        } else {
222            return Err(ParsePaperSizeError::MissingUnit);
223        };
224
225        let width = f64::from_str(width.trim()).map_err(|_| ParsePaperSizeError::InvalidWidth)?;
226        let height =
227            f64::from_str(height.trim()).map_err(|_| ParsePaperSizeError::InvalidHeight)?;
228        let unit = Unit::from_str(unit.trim()).map_err(|_| ParsePaperSizeError::InvalidUnit)?;
229        Ok(Self {
230            width,
231            height,
232            unit,
233        })
234    }
235}
236
237impl Display for PaperSize {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        write!(f, "{}x{}{}", self.width, self.height, self.unit)
240    }
241}
242
243#[cfg(feature = "serde")]
244impl serde::Serialize for PaperSize {
245    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
246    where
247        S: serde::Serializer,
248    {
249        self.to_string().serialize(serializer)
250    }
251}
252
253#[cfg(feature = "serde")]
254impl<'de> serde::Deserialize<'de> for PaperSize {
255    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
256    where
257        D: serde::Deserializer<'de>,
258    {
259        use serde::de::Error;
260        String::deserialize(deserializer)?
261            .parse()
262            .map_err(D::Error::custom)
263    }
264}
265
266/// An error parsing a [PaperSpec].
267#[derive(Copy, Clone, Debug, PartialEq, Eq)]
268pub enum ParsePaperSpecError {
269    /// Invalid paper height.
270    InvalidHeight,
271
272    /// Invalid paper width.
273    InvalidWidth,
274
275    /// Invalid unit of measurement.
276    InvalidUnit,
277
278    /// Missing field in paper specification.
279    MissingField,
280}
281
282impl From<ParsePaperSizeError> for ParsePaperSpecError {
283    fn from(value: ParsePaperSizeError) -> Self {
284        match value {
285            ParsePaperSizeError::InvalidHeight => Self::InvalidHeight,
286            ParsePaperSizeError::InvalidWidth => Self::InvalidWidth,
287            ParsePaperSizeError::InvalidUnit => Self::InvalidUnit,
288            ParsePaperSizeError::MissingUnit => Self::MissingField,
289            ParsePaperSizeError::MissingDelimiter => Self::MissingField,
290        }
291    }
292}
293
294impl Error for ParsePaperSpecError {}
295
296impl Display for ParsePaperSpecError {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        match self {
299            ParsePaperSpecError::InvalidHeight => write!(f, "Invalid paper height."),
300            ParsePaperSpecError::InvalidWidth => write!(f, "Invalid paper width."),
301            ParsePaperSpecError::InvalidUnit => write!(f, "Invalid unit of measurement."),
302            ParsePaperSpecError::MissingField => write!(f, "Missing field in paper specification."),
303        }
304    }
305}
306
307/// A named [PaperSize].
308#[derive(Clone, Debug, PartialEq)]
309pub struct PaperSpec {
310    /// The paper's name, such as `A4` or `Letter`.
311    pub name: Cow<'static, str>,
312
313    /// The paper's size.
314    pub size: PaperSize,
315}
316
317impl PaperSpec {
318    /// Construct a new `PaperSpec`.
319    pub fn new(name: impl Into<Cow<'static, str>>, size: PaperSize) -> Self {
320        Self {
321            name: name.into(),
322            size,
323        }
324    }
325}
326
327impl FromStr for PaperSpec {
328    type Err = ParsePaperSpecError;
329
330    /// Parses a paper specification as `name,<size>`, where `<size>` is one of
331    /// the formats supported by [PaperSize::from_str].
332    ///
333    /// The canonical form of a paper specification is `name,width,height,unit`.
334    fn from_str(s: &str) -> Result<Self, Self::Err> {
335        let (name, size) = s.split_once(',').ok_or(ParsePaperSpecError::MissingField)?;
336        Ok(Self {
337            name: String::from(name).into(),
338            size: size.parse()?,
339        })
340    }
341}
342
343/// An error encountered building a [Catalog].
344#[derive(Debug)]
345pub enum CatalogBuildError {
346    /// Line {line_number}: {error}
347    ParseError {
348        /// The file where the parse error occurred.
349        path: PathBuf,
350
351        /// The 1-based line number on which the parse error occurred.
352        line_number: usize,
353
354        /// The parse error.
355        error: ParsePaperSpecError,
356    },
357
358    /// I/O error.
359    IoError {
360        /// The file where the I/O error occurred.
361        path: PathBuf,
362
363        /// Error details.
364        error: std::io::Error,
365    },
366}
367
368impl Error for CatalogBuildError {}
369
370impl Display for CatalogBuildError {
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        match self {
373            CatalogBuildError::ParseError {
374                path,
375                line_number,
376                error,
377            } => write!(f, "{}:{line_number}: {error}", path.display()),
378            CatalogBuildError::IoError { path, error } => {
379                write!(f, "{}: {error}", path.display())
380            }
381        }
382    }
383}
384
385/// A builder for constructing a [Catalog].
386///
387/// `CatalogBuilder` allows control over the process of constructing a
388/// [Catalog].  If the default options are acceptable, [Catalog::new] bypasses
389/// the need for `CatalogBuilder`.
390pub struct CatalogBuilder<'a> {
391    papersize: Option<Option<&'a str>>,
392    use_locale: bool,
393    user_config_dir: Option<Option<&'a Path>>,
394    system_config_dir: Option<&'a Path>,
395    error_cb: Box<dyn FnMut(CatalogBuildError) + 'a>,
396}
397
398impl<'a> Default for CatalogBuilder<'a> {
399    fn default() -> Self {
400        Self {
401            use_locale: true,
402            papersize: None,
403            user_config_dir: None,
404            system_config_dir: Some(Path::new("/etc")),
405            error_cb: Box::new(drop),
406        }
407    }
408}
409
410fn fallback_specs() -> (Vec<PaperSpec>, PaperSpec) {
411    let specs = STANDARD_PAPERSPECS.into_iter().cloned().collect::<Vec<_>>();
412    let default = specs.first().unwrap().clone();
413    (specs, default)
414}
415
416fn read_specs<E>(
417    user_config_dir: Option<&Path>,
418    system_config_dir: Option<&Path>,
419    mut error_cb: E,
420) -> Option<(Vec<PaperSpec>, PaperSpec)>
421where
422    E: FnMut(CatalogBuildError),
423{
424    fn read_paperspecs_file(
425        directory: Option<&Path>,
426        error_cb: &mut dyn FnMut(CatalogBuildError),
427    ) -> Vec<PaperSpec> {
428        let mut specs = Vec::new();
429        if let Some(directory) = directory {
430            let path = directory.join(PAPERSPECS_FILENAME);
431            match File::open(&path) {
432                Ok(file) => {
433                    let reader = BufReader::new(file);
434                    for (line, line_number) in reader.lines().zip(1..) {
435                        match line
436                            .map_err(|error| CatalogBuildError::IoError {
437                                path: path.clone(),
438                                error,
439                            })
440                            .and_then(|line| {
441                                PaperSpec::from_str(&line).map_err(|error| {
442                                    CatalogBuildError::ParseError {
443                                        path: path.clone(),
444                                        line_number,
445                                        error,
446                                    }
447                                })
448                            }) {
449                            Ok(spec) => specs.push(spec),
450                            Err(error) => error_cb(error),
451                        }
452                    }
453                }
454                Err(error) if error.kind() == ErrorKind::NotFound => (),
455                Err(error) => error_cb(CatalogBuildError::IoError { path, error }),
456            }
457        }
458        specs
459    }
460
461    let user_specs = read_paperspecs_file(user_config_dir, &mut error_cb);
462    let system_specs = read_paperspecs_file(system_config_dir, &mut error_cb);
463    let default_spec = system_specs.first().or(user_specs.first())?.clone();
464    Some((
465        user_specs.into_iter().chain(system_specs).collect(),
466        default_spec,
467    ))
468}
469
470fn default_paper<E>(
471    papersize: Option<Option<&str>>,
472    user_config_dir: Option<&Path>,
473    _use_locale: bool,
474    system_config_dir: Option<&Path>,
475    default: &PaperSpec,
476    mut error_cb: E,
477) -> DefaultPaper
478where
479    E: FnMut(CatalogBuildError),
480{
481    fn read_papersize_file<P, E>(path: P, mut error_cb: E) -> Option<String>
482    where
483        P: AsRef<Path>,
484        E: FnMut(CatalogBuildError),
485    {
486        fn inner(path: &Path) -> std::io::Result<Option<String>> {
487            let file = BufReader::new(File::open(path)?);
488            let line = file.lines().next().unwrap_or(Ok(String::new()))?;
489            let name = line.split(',').next().unwrap_or("");
490            Ok(name.is_empty().not().then(|| name.into()))
491        }
492        let path = path.as_ref();
493        match inner(path) {
494            Ok(result) => result,
495            Err(error) => {
496                if error.kind() != ErrorKind::NotFound {
497                    error_cb(CatalogBuildError::IoError {
498                        path: path.to_path_buf(),
499                        error,
500                    });
501                }
502                None
503            }
504        }
505    }
506
507    // Use `PAPERSIZE` from the environment (or from the override).
508    let env_var;
509    let paper_name = match papersize {
510        Some(paper_name) => paper_name,
511        None => {
512            env_var = std::env::var("PAPERSIZE").ok();
513            env_var.as_deref()
514        }
515    };
516    if let Some(paper_name) = paper_name
517        && !paper_name.is_empty()
518    {
519        return DefaultPaper::Name(paper_name.into());
520    }
521
522    // Then try the user configuration directory.
523    if let Some(dir) = user_config_dir
524        && let path = dir.join(PAPERSIZE_FILENAME)
525        && let Some(paper_name) = read_papersize_file(path, &mut error_cb)
526    {
527        return DefaultPaper::Name(paper_name);
528    }
529
530    // Then try the locale.
531    #[cfg(target_os = "linux")]
532    if _use_locale && let Some(paper_size) = locale::locale_paper_size() {
533        return DefaultPaper::Size(paper_size);
534    }
535
536    if let Some(system_config_dir) = system_config_dir
537        && let Some(paper_name) =
538            read_papersize_file(system_config_dir.join(PAPERSIZE_FILENAME), &mut error_cb)
539    {
540        return DefaultPaper::Name(paper_name);
541    }
542
543    // Otherwise take it from the default papers.
544    DefaultPaper::Name(default.name.as_ref().into())
545}
546
547impl<'a> CatalogBuilder<'a> {
548    /// Constructs a new `CatalogBuilder` with default settings.
549    pub fn new() -> Self {
550        Self::default()
551    }
552
553    /// Builds a [Catalog] and chooses a default paper size by reading the by
554    /// reading `paperspecs` and `papersize` files and examining the
555    /// environment and (on GNU/Linux) locale.
556    ///
557    /// If no system or user `paperspecs` files exist, or if they exist but they
558    /// contain no valid paper specifications, then this method uses the
559    /// standard paper sizes in [`STANDARD_PAPERSPECS`].  This is usually a
560    /// reasonable fallback.
561    pub fn build(self) -> Catalog {
562        self.build_inner(|user_config_dir, system_config_dir, error_cb| {
563            Some(
564                read_specs(user_config_dir, system_config_dir, error_cb)
565                    .unwrap_or_else(fallback_specs),
566            )
567        })
568        .unwrap()
569    }
570
571    /// Builds a [Catalog] from [`STANDARD_PAPERSPECS`] and chooses a default
572    /// paper size by reading the by reading `papersize` files and examining the
573    /// environment and (on GNU/Linux) locale.
574    ///
575    /// This is a reasonable choice if it is unlikely for `paperspecs` to be
576    /// installed but it is still desirable to detect a default paper size.
577    pub fn build_from_fallback(self) -> Catalog {
578        self.build_inner(|_, _, _| Some(fallback_specs())).unwrap()
579    }
580
581    /// Tries to build a [Catalog] and chooses a default paper size by reading
582    /// the by reading `paperspecs` and `papersize` files and examining the
583    /// environment and (on GNU/Linux) locale.
584    ///
585    /// If no system or user `paperspecs` files exist, or if they exist but they
586    /// contain no valid paper specifications, this method fails and returns
587    /// `None`.
588    pub fn build_without_fallback(self) -> Option<Catalog> {
589        self.build_inner(|user_config_dir, system_config_dir, error_cb| {
590            read_specs(user_config_dir, system_config_dir, error_cb)
591        })
592    }
593
594    /// Sets `papersize` to be used for the value of the `PAPERSIZE` environment
595    /// variable, instead of obtaining it from the process environment.  `None`
596    /// means that the environment variable is assumed to be empty or absent.
597    pub fn with_papersize_value(self, papersize: Option<&'a str>) -> Self {
598        Self {
599            papersize: Some(papersize),
600            ..self
601        }
602    }
603
604    /// On GNU/Linux, by default, `CatalogBuilder` will consider the paper size
605    /// setting in the glibc locale `LC_PAPER`.  This method disables this
606    /// feature.
607    ///
608    /// This setting has no effect on other operating systems, which do not
609    /// support paper size as part of their locales.
610    pub fn without_locale(self) -> Self {
611        Self {
612            use_locale: false,
613            ..self
614        }
615    }
616
617    /// Overrides the name of the user-specific configuration directory.
618    ///
619    /// This directory is searched for the user-specified `paperspecs` and
620    /// `papersize` files.  It defaults to `$XDG_CONFIG_HOME`, which is usually
621    /// `$HOME/.config`.
622    ///
623    /// Passing `None` will disable reading `paperspec` or `papersize` from the
624    /// user configuration directory.
625    pub fn with_user_config_dir(self, user_config_dir: Option<&'a Path>) -> Self {
626        Self {
627            user_config_dir: Some(user_config_dir),
628            ..self
629        }
630    }
631
632    /// Overrides the name of the system configuration directory.
633    ///
634    /// This directory is searched for the system `paperspecs` and `papersize`
635    /// files.  It defaults to `/etc`.
636    ///
637    /// Passing `None` will disable reading `paperspec` or `papersize` from the
638    /// system configuration directory.
639    pub fn with_system_config_dir(self, system_config_dir: Option<&'a Path>) -> Self {
640        Self {
641            system_config_dir,
642            ..self
643        }
644    }
645
646    /// Sets an error reporting callback.
647    ///
648    /// By default, [CatalogBuilder] ignores errors while building the catalog.
649    /// The `error_cb` callback allows the caller to receive information about
650    /// these errors.
651    ///
652    /// It is not considered an error if `paperspecs` or `papersize` files do
653    /// not exist.
654    pub fn with_error_callback(self, error_cb: Box<dyn FnMut(CatalogBuildError) + 'a>) -> Self {
655        Self { error_cb, ..self }
656    }
657
658    fn build_inner<F>(mut self, f: F) -> Option<Catalog>
659    where
660        F: Fn(
661            Option<&Path>,
662            Option<&Path>,
663            &mut Box<dyn FnMut(CatalogBuildError) + 'a>,
664        ) -> Option<(Vec<PaperSpec>, PaperSpec)>,
665    {
666        let base_directories;
667        let user_config_dir = match self.user_config_dir {
668            Some(user_config_dir) => user_config_dir,
669            None => {
670                base_directories = BaseDirectories::new();
671                base_directories.config_home.as_deref()
672            }
673        };
674        let (specs, default) = f(user_config_dir, self.system_config_dir, &mut self.error_cb)?;
675        let default = match default_paper(
676            self.papersize,
677            user_config_dir,
678            self.use_locale,
679            self.system_config_dir,
680            &default,
681            &mut self.error_cb,
682        ) {
683            DefaultPaper::Name(name) => specs
684                .iter()
685                .find(|spec| spec.name.eq_ignore_ascii_case(&name))
686                .cloned()
687                .unwrap_or(default),
688            DefaultPaper::Size(size) => specs
689                .iter()
690                .find(|spec| spec.size.eq_rounded(&size, Unit::Point))
691                .cloned()
692                .unwrap_or_else(|| PaperSpec::new(Cow::from("Locale"), size)),
693        };
694
695        Some(Catalog { specs, default })
696    }
697}
698
699/// A collection of [PaperSpec]s and a default paper size.
700pub struct Catalog {
701    specs: Vec<PaperSpec>,
702    default: PaperSpec,
703}
704
705impl Default for Catalog {
706    fn default() -> Self {
707        Self::builder().build()
708    }
709}
710
711impl Catalog {
712    /// Constructs a new [CatalogBuilder].
713    pub fn builder<'a>() -> CatalogBuilder<'a> {
714        CatalogBuilder::new()
715    }
716
717    /// Constructs a new catalog by reading `paperspecs` and `papersize` files
718    /// and examining the environment.
719    ///
720    /// This is equivalent to `Catalog::builder().build()`.
721    pub fn new() -> Self {
722        Self::default()
723    }
724
725    /// Returns the contents of the catalog, as a nonempty list of user-specific
726    /// paper sizes, followed by system paper sizes.
727    pub fn specs(&self) -> &[PaperSpec] {
728        &self.specs
729    }
730
731    /// Returns the default paper size.
732    ///
733    /// This paper size might not be in the catalog's list of [PaperSpec]s
734    /// because the default can be specified in terms of measurements rather
735    /// than as a name.
736    pub fn default_paper(&self) -> &PaperSpec {
737        &self.default
738    }
739
740    /// Returns the first [PaperSpec] in the catalog with the given `size` (to
741    /// the nearest PostScript point).
742    pub fn get_by_size(&self, size: &PaperSize) -> Option<&PaperSpec> {
743        self.specs
744            .iter()
745            .find(|spec| spec.size.eq_rounded(size, Unit::Point))
746    }
747
748    /// Returns the first [PaperSpec] in the catalog whose name equals `name`,
749    /// disregarding ASCII case.
750    pub fn get_by_name(&self, name: &str) -> Option<&PaperSpec> {
751        self.specs
752            .iter()
753            .find(|spec| spec.name.eq_ignore_ascii_case(name))
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use std::{borrow::Cow, path::Path, str::FromStr};
760
761    use crate::{
762        A4, CatalogBuildError, CatalogBuilder, PaperSize, PaperSpec, ParsePaperSizeError,
763        ParsePaperSpecError, Unit, locale,
764    };
765
766    #[test]
767    fn unit() {
768        assert_eq!(Unit::Point.to_string(), "pt");
769        assert_eq!(Unit::Millimeter.to_string(), "mm");
770        assert_eq!(Unit::Inch.to_string(), "in");
771
772        assert_eq!("pt".parse(), Ok(Unit::Point));
773        assert_eq!("mm".parse(), Ok(Unit::Millimeter));
774        assert_eq!("in".parse(), Ok(Unit::Inch));
775
776        assert_eq!(
777            format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Millimeter)),
778            "25.400"
779        );
780        assert_eq!(
781            format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Inch)),
782            "1.000"
783        );
784        assert_eq!(
785            format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Point)),
786            "72.000"
787        );
788        assert_eq!(
789            format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Millimeter)),
790            "12.700"
791        );
792        assert_eq!(
793            format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Inch)),
794            "0.500"
795        );
796        assert_eq!(
797            format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Point)),
798            "36.000"
799        );
800        assert_eq!(
801            format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Millimeter)),
802            "12.700"
803        );
804        assert_eq!(
805            format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Inch)),
806            "0.500"
807        );
808        assert_eq!(
809            format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Point)),
810            "36.000"
811        );
812    }
813
814    #[test]
815    fn papersize() {
816        assert_eq!(
817            "8.5x11in".parse(),
818            Ok(PaperSize::new(8.5, 11.0, Unit::Inch))
819        );
820        assert_eq!(
821            "8.5,11in".parse(),
822            Ok(PaperSize::new(8.5, 11.0, Unit::Inch))
823        );
824        assert_eq!(
825            " 8.5 x 11 in ".parse(),
826            Ok(PaperSize::new(8.5, 11.0, Unit::Inch))
827        );
828        assert_eq!(
829            PaperSize::from_str("8.5x.in"),
830            Err(ParsePaperSizeError::InvalidHeight)
831        );
832        assert_eq!(
833            PaperSize::from_str(".x11in"),
834            Err(ParsePaperSizeError::InvalidWidth)
835        );
836        assert_eq!(
837            PaperSize::from_str("8.5x11xyzzy"),
838            Err(ParsePaperSizeError::InvalidUnit)
839        );
840        assert_eq!(
841            PaperSize::from_str("8.5x11"),
842            Err(ParsePaperSizeError::MissingUnit)
843        );
844        assert_eq!(
845            PaperSize::from_str(" 8.5  11 in "),
846            Err(ParsePaperSizeError::MissingDelimiter)
847        );
848        assert_eq!(
849            PaperSize::new(8.5, 11.0, Unit::Inch).to_string(),
850            "8.5x11in"
851        );
852        assert_eq!(A4.size.to_string(), "210x297mm");
853    }
854
855    #[test]
856    fn paperspec() {
857        assert_eq!(
858            "Letter,8.5,11,in".parse(),
859            Ok(PaperSpec::new(
860                Cow::from("Letter"),
861                PaperSize::new(8.5, 11.0, Unit::Inch)
862            ))
863        );
864        assert_eq!(
865            "Letter,8.5x11in".parse(),
866            Ok(PaperSpec::new(
867                Cow::from("Letter"),
868                PaperSize::new(8.5, 11.0, Unit::Inch)
869            ))
870        );
871    }
872
873    #[test]
874    fn default() {
875        // Default from $PAPERSIZE.
876        assert_eq!(
877            CatalogBuilder::new()
878                .with_papersize_value(Some("legal"))
879                .with_user_config_dir(Some(Path::new("testdata/td1")))
880                .without_locale()
881                .build_from_fallback()
882                .default_paper(),
883            &PaperSpec::new(Cow::from("Legal"), PaperSize::new(8.5, 14.0, Unit::Inch))
884        );
885
886        // Default from user_config_dir.
887        assert_eq!(
888            CatalogBuilder::new()
889                .with_papersize_value(None)
890                .with_user_config_dir(Some(Path::new("testdata/td1")))
891                .without_locale()
892                .build_from_fallback()
893                .default_paper(),
894            &PaperSpec::new(Cow::from("Ledger"), PaperSize::new(17.0, 11.0, Unit::Inch))
895        );
896
897        // Default from system_config_dir.
898        assert_eq!(
899            CatalogBuilder::new()
900                .with_papersize_value(None)
901                .with_user_config_dir(None)
902                .with_system_config_dir(Some(Path::new("testdata/td2")))
903                .without_locale()
904                .build_from_fallback()
905                .default_paper(),
906            &PaperSpec::new(
907                Cow::from("Executive"),
908                PaperSize::new(7.25, 10.5, Unit::Inch)
909            )
910        );
911
912        // Default from the first system paper size.
913        assert_eq!(
914            CatalogBuilder::new()
915                .with_papersize_value(None)
916                .with_user_config_dir(None)
917                .with_system_config_dir(Some(Path::new("testdata/td2")))
918                .without_locale()
919                .build()
920                .default_paper(),
921            &PaperSpec::new(
922                Cow::from("A0"),
923                PaperSize::new(841.0, 1189.0, Unit::Millimeter)
924            )
925        );
926
927        // Default from the first user paper size.
928        assert_eq!(
929            CatalogBuilder::new()
930                .with_papersize_value(None)
931                .with_user_config_dir(Some(Path::new("testdata/td3")))
932                .with_system_config_dir(None)
933                .without_locale()
934                .build()
935                .default_paper(),
936            &PaperSpec::new(
937                Cow::from("B0"),
938                PaperSize::new(1000.0, 1414.0, Unit::Millimeter)
939            )
940        );
941
942        // Default when nothing can be read and fallback triggers.
943        assert_eq!(
944            CatalogBuilder::new()
945                .with_papersize_value(None)
946                .with_user_config_dir(None)
947                .with_system_config_dir(None)
948                .without_locale()
949                .build()
950                .default_paper(),
951            &PaperSpec::new(
952                Cow::from("A4"),
953                PaperSize::new(210.0, 297.0, Unit::Millimeter)
954            )
955        );
956
957        // Verify that nothing can be read in the previous case.
958        assert!(
959            CatalogBuilder::new()
960                .with_papersize_value(None)
961                .with_user_config_dir(None)
962                .with_system_config_dir(None)
963                .without_locale()
964                .build_without_fallback()
965                .is_none()
966        );
967    }
968
969    #[test]
970    fn errors() {
971        // Missing files are not errors.
972        let mut errors = Vec::new();
973        let _ = CatalogBuilder::new()
974            .with_papersize_value(None)
975            .with_user_config_dir(Some(Path::new("nonexistent/user")))
976            .with_system_config_dir(Some(Path::new("nonexistent/system")))
977            .without_locale()
978            .with_error_callback(Box::new(|error| errors.push(error)))
979            .build()
980            .default_paper();
981        assert_eq!(errors.len(), 0);
982
983        // Test parse errors.
984        let mut errors = Vec::new();
985        let _ = CatalogBuilder::new()
986            .with_papersize_value(None)
987            .with_user_config_dir(None)
988            .with_system_config_dir(Some(Path::new("testdata/td4")))
989            .without_locale()
990            .with_error_callback(Box::new(|error| errors.push(error)))
991            .build()
992            .default_paper();
993
994        assert_eq!(errors.len(), 4);
995        for ((error, expect_line_number), expect_error) in errors.iter().zip(1..).zip([
996            ParsePaperSpecError::MissingField,
997            ParsePaperSpecError::InvalidWidth,
998            ParsePaperSpecError::InvalidHeight,
999            ParsePaperSpecError::InvalidUnit,
1000        ]) {
1001            let CatalogBuildError::ParseError {
1002                path,
1003                line_number,
1004                error,
1005            } = error
1006            else {
1007                unreachable!()
1008            };
1009            assert_eq!(path.as_path(), Path::new("testdata/td4/paperspecs"));
1010            assert_eq!(*line_number, expect_line_number);
1011            assert_eq!(*error, expect_error);
1012        }
1013    }
1014
1015    #[cfg(target_os = "linux")]
1016    #[test]
1017    fn lc_paper() {
1018        // Haven't figured out a good way to test this.
1019        //
1020        // I expect that all locales default to either A4 or letter-sized paper,
1021        // so just check for that.
1022        if let Some(size) = locale::locale_paper_size() {
1023            assert_eq!(size.unit, Unit::Millimeter);
1024            let (w, h) = size.into_width_height();
1025            assert!(
1026                (w, h) == (210.0, 297.0) || (w, h) == (216.0, 279.0),
1027                "Expected A4 (210x297) or letter (216x279) paper, got {w}x{h} mm"
1028            );
1029        }
1030    }
1031
1032    #[cfg(feature = "serde")]
1033    #[test]
1034    fn test_serde() {
1035        assert_eq!(
1036            serde_json::to_string(&PaperSize::new(8.5, 11.0, Unit::Inch)).unwrap(),
1037            "\"8.5x11in\""
1038        );
1039        assert_eq!(
1040            serde_json::from_str::<PaperSize>("\"8.5x11in\"").unwrap(),
1041            PaperSize::new(8.5, 11.0, Unit::Inch)
1042        )
1043    }
1044}