rust: New Rust crate `paper-sizes` for detecting paper sizes and defaults.
authorBen Pfaff <blp@cs.stanford.edu>
Fri, 3 Oct 2025 18:45:05 +0000 (11:45 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Sun, 5 Oct 2025 22:18:24 +0000 (15:18 -0700)
14 files changed:
rust/Cargo.lock
rust/Cargo.toml
rust/paper-sizes/Cargo.toml [new file with mode: 0644]
rust/paper-sizes/README.md [new file with mode: 0644]
rust/paper-sizes/build.rs [new file with mode: 0644]
rust/paper-sizes/paperspecs [new file with mode: 0644]
rust/paper-sizes/src/lib.rs [new file with mode: 0644]
rust/paper-sizes/src/locale.rs [new file with mode: 0644]
rust/paper-sizes/testdata/td1/papersize [new file with mode: 0644]
rust/paper-sizes/testdata/td2/papersize [new file with mode: 0644]
rust/paper-sizes/testdata/td2/paperspecs [new file with mode: 0644]
rust/paper-sizes/testdata/td3/paperspecs [new file with mode: 0644]
rust/paper-sizes/testdata/td4/paperspecs [new file with mode: 0644]
rust/paper-sizes/wrapper.h [new file with mode: 0644]

index f51fe616476ded1b928151d72bdcdf6e53dff76f..f67759c59619286fa5d9691546273ce7216e140f 100644 (file)
@@ -183,6 +183,26 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
+[[package]]
+name = "bindgen"
+version = "0.72.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
+dependencies = [
+ "bitflags 2.9.1",
+ "cexpr",
+ "clang-sys",
+ "itertools 0.13.0",
+ "log",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "rustc-hash",
+ "shlex",
+ "syn 2.0.101",
+]
+
 [[package]]
 name = "binrw"
 version = "0.14.1"
@@ -299,6 +319,15 @@ dependencies = [
  "shlex",
 ]
 
+[[package]]
+name = "cexpr"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
+dependencies = [
+ "nom",
+]
+
 [[package]]
 name = "cfg-expr"
 version = "0.20.0"
@@ -338,7 +367,7 @@ dependencies = [
  "num-traits",
  "serde",
  "wasm-bindgen",
- "windows-link",
+ "windows-link 0.1.1",
 ]
 
 [[package]]
@@ -351,6 +380,17 @@ dependencies = [
  "inout",
 ]
 
+[[package]]
+name = "clang-sys"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
+dependencies = [
+ "glob",
+ "libc",
+ "libloading",
+]
+
 [[package]]
 name = "clap"
 version = "4.5.39"
@@ -905,6 +945,12 @@ dependencies = [
  "system-deps",
 ]
 
+[[package]]
+name = "glob"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+
 [[package]]
 name = "gobject-sys"
 version = "0.20.10"
@@ -1131,6 +1177,15 @@ version = "1.70.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
 
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
 [[package]]
 name = "itertools"
 version = "0.14.0"
@@ -1192,9 +1247,19 @@ dependencies = [
 
 [[package]]
 name = "libc"
-version = "0.2.172"
+version = "0.2.176"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
+checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link 0.2.0",
+]
 
 [[package]]
 name = "liblzma"
@@ -1288,6 +1353,12 @@ version = "2.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
 
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
 [[package]]
 name = "miniz_oxide"
 version = "0.8.8"
@@ -1323,6 +1394,16 @@ dependencies = [
  "rawpointer",
 ]
 
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
 [[package]]
 name = "num"
 version = "0.4.3"
@@ -1488,6 +1569,15 @@ dependencies = [
  "system-deps",
 ]
 
+[[package]]
+name = "paper-sizes"
+version = "0.1.0"
+dependencies = [
+ "bindgen",
+ "libc",
+ "xdg",
+]
+
 [[package]]
 name = "parking_lot"
 version = "0.12.3"
@@ -1604,6 +1694,16 @@ dependencies = [
  "zerocopy",
 ]
 
+[[package]]
+name = "prettyplease"
+version = "0.2.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.101",
+]
+
 [[package]]
 name = "proc-macro-crate"
 version = "3.3.0"
@@ -1650,7 +1750,7 @@ dependencies = [
  "hashbrown 0.15.5",
  "hexplay",
  "indexmap",
- "itertools",
+ "itertools 0.14.0",
  "libc",
  "libm",
  "ndarray",
@@ -1814,6 +1914,12 @@ version = "0.1.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
 
+[[package]]
+name = "rustc-hash"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
 [[package]]
 name = "rustix"
 version = "1.0.7"
@@ -2512,7 +2618,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
 dependencies = [
  "windows-implement",
  "windows-interface",
- "windows-link",
+ "windows-link 0.1.1",
  "windows-result",
  "windows-strings",
 ]
@@ -2545,13 +2651,19 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38"
 
+[[package]]
+name = "windows-link"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
+
 [[package]]
 name = "windows-result"
 version = "0.3.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
 dependencies = [
- "windows-link",
+ "windows-link 0.1.1",
 ]
 
 [[package]]
@@ -2560,7 +2672,7 @@ version = "0.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
 dependencies = [
- "windows-link",
+ "windows-link 0.1.1",
 ]
 
 [[package]]
@@ -2808,6 +2920,12 @@ version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
 
+[[package]]
+name = "xdg"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5"
+
 [[package]]
 name = "xmlwriter"
 version = "0.1.0"
index a87a94ff48aa68a68a2e20e6ef7836fa79ee55b3..530df04bb89f149402d86f0ed7b5ac4e349a85aa 100644 (file)
@@ -3,6 +3,7 @@ members = [
   "pspp",
   "pspp-derive",
   "pspp-lsp",
+  "paper-sizes",
 ]
 default-members = ["pspp"]
 resolver = "2"
diff --git a/rust/paper-sizes/Cargo.toml b/rust/paper-sizes/Cargo.toml
new file mode 100644 (file)
index 0000000..fad5d52
--- /dev/null
@@ -0,0 +1,16 @@
+[package]
+name = "paper-sizes"
+version = "0.1.0"
+edition = "2024"
+license = "MIT OR Apache-2.0 OR LGPL-2.1-or-later"
+author = "Ben Pfaff"
+description = "Detects paper sizes and defaults"
+
+[dependencies]
+xdg = "3.0.0"
+
+[target.'cfg(target_os = "linux")'.dependencies]
+libc = "0.2.176"
+
+[build-dependencies]
+bindgen = "0.72.1"
diff --git a/rust/paper-sizes/README.md b/rust/paper-sizes/README.md
new file mode 100644 (file)
index 0000000..e5965d6
--- /dev/null
@@ -0,0 +1,42 @@
+# paper-sizes
+
+`paper-sizes` is a library to detect the user's preferred paper size
+as well as system-wide and per-user known sizes.
+
+This is a Rust equivalent of the library features in [libpaper].
+
+This crate does not provide the `paper` or `paperconf` programs.  Use
+[libpaper] for those.
+
+[libpaper]: https://github.com/rrthomas/libpaper
+
+## Use
+
+To obtain the default paper size, create a `Catalog`, then obtain the
+default paper size:
+
+```rust
+use paper_sizes::Catalog;
+
+let catalog = Catalog::new();
+let default_size = catalog.default_paper().size;
+```
+
+See the documentation for more details.
+
+## License
+
+This crate is distributed under your choice of the following licenses:
+
+* The [MIT License].
+
+* The [GNU LGPL, version 2.1], or any later version.
+
+* The [Apache License, version 2.0].
+
+The `paperspecs` file in this crate is from [libpaper], which documents it
+to be in the public domain.
+
+[MIT License]: https://opensource.org/license/mit
+[GNU LGPL, version 2.1]: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html
+[Apache License, version 2.0]: https://www.apache.org/licenses/LICENSE-2.0
diff --git a/rust/paper-sizes/build.rs b/rust/paper-sizes/build.rs
new file mode 100644 (file)
index 0000000..6b4dca5
--- /dev/null
@@ -0,0 +1,104 @@
+use std::collections::HashSet;
+use std::env;
+use std::fs::File;
+use std::io::{BufRead, BufReader, Write};
+use std::path::PathBuf;
+
+fn main() {
+    build_bindings();
+    build_paperspecs();
+}
+
+fn build_bindings() {
+    let bindings = bindgen::Builder::default()
+        .header("wrapper.h")
+        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
+        .generate()
+        .expect("Unable to generate bindings");
+
+    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
+    bindings
+        .write_to_file(out_path.join("bindings.rs"))
+        .expect("Couldn't write bindings!");
+}
+
+fn build_paperspecs() {
+    let output_name = PathBuf::from(env::var("OUT_DIR").unwrap()).join("paperspecs.rs");
+    let mut output = File::create(&output_name).expect("Failed to create output file");
+
+    println!("cargo:rerun-if-changed=paperspecs");
+    let reader =
+        BufReader::new(File::open("paperspecs").expect("Unable to open `paperspecs` file"));
+    let mut ids = HashSet::new();
+    let mut fallbacks = Vec::new();
+    for line in reader
+        .lines()
+        .map(|result| result.expect("Error reading `paperspecs` file"))
+    {
+        let mut splitter = line.split(',');
+        let name = splitter.next().unwrap();
+        let raw_width = splitter.next().unwrap();
+        let raw_height = splitter.next().unwrap();
+        let raw_unit = splitter.next().unwrap();
+
+        // Turn name into Rust identifier.
+        let mut id = name.to_ascii_uppercase().replace(' ', "_");
+        if !id.chars().next().unwrap().is_alphabetic() {
+            id.insert_str(0, "PAPER_");
+        }
+        let alternate = if !ids.insert(id.clone()) {
+            id.push_str("_ISO");
+            true
+        } else {
+            false
+        };
+        fallbacks.push(id.clone());
+
+        // Make width and height valid Rust floating-point numbers.
+        fn to_float(s: &str) -> String {
+            let mut s = String::from(s);
+            if !s.contains('.') {
+                s.push_str(".0");
+            }
+            s
+        }
+        let width = to_float(raw_width);
+        let height = to_float(raw_height);
+
+        // Turn unit into Rust identifier.
+        let unit = match raw_unit {
+            "mm" => "Millimeter",
+            "in" => "Inch",
+            "pt" => "Point",
+            _ => panic!("Unknown unit {raw_unit}."),
+        };
+
+        writeln!(
+            &mut output,
+            "/// {name} paper size{} ({raw_width} x {raw_height} {raw_unit}).
+pub static {id}: PaperSpec = PaperSpec {{
+  name: Cow::Borrowed(\"{name}\"),
+  size: PaperSize {{
+     width: {width},
+     height: {height},
+     unit: Unit::{unit}
+  }},
+}};
+",
+            if alternate { " in millimeters" } else { "" }
+        )
+        .unwrap();
+    }
+
+    writeln!(
+        &mut output,
+        "/// Paper specifications from the standard `paperspecs` file.
+pub static STANDARD_PAPERSPECS: [&PaperSpec; {}] = [",
+        fallbacks.len()
+    )
+    .unwrap();
+    for id in fallbacks {
+        writeln!(&mut output, "    &{},", id).unwrap();
+    }
+    writeln!(&mut output, "];").unwrap();
+}
diff --git a/rust/paper-sizes/paperspecs b/rust/paper-sizes/paperspecs
new file mode 100644 (file)
index 0000000..6503142
--- /dev/null
@@ -0,0 +1,82 @@
+A4,210,297,mm
+Letter,8.5,11,in
+Note,8.5,11,in
+Legal,8.5,14,in
+Executive,7.25,10.5,in
+A5,148,210,mm
+Half Letter,5.5,8.5,in
+Half Executive,5.25,7.25,in
+A0,841,1189,mm
+A1,594,841,mm
+A2,420,594,mm
+A3,297,420,mm
+A6,105,148,mm
+A7,74,105,mm
+A8,52,74,mm
+A9,37,52,mm
+A10,26,37,mm
+Super A3,330,483,mm
+B0,1000,1414,mm
+B1,707,1000,mm
+B2,500,707,mm
+B3,353,500,mm
+B4,250,353,mm
+B5,176,250,mm
+B6,125,176,mm
+B7,88,125,mm
+B8,62,88,mm
+B9,44,62,mm
+B10,31,44,mm
+C0,917,1297,mm
+C1,648,917,mm
+C2,458,648,mm
+C3,324,458,mm
+C4,229,354,mm
+C5,162,229,mm
+C6,114,162,mm
+C7,81,114,mm
+C8,57,81,mm
+C9,40,57,mm
+C10,28,40,mm
+JIS B0,1030,1456,mm
+JIS B1,728,1030,mm
+JIS B2,515,728,mm
+JIS B3,364,515,mm
+JIS B4,257,364,mm
+JIS B5,182,257,mm
+JIS B6,128,182,mm
+JIS B7,91,128,mm
+JIS B8,64,91,mm
+JIS B9,45,64,mm
+JIS B10,32,45,mm
+11x17,11,17,in
+Statement,5.5,8.5,in
+Folio,8.5,13,in
+10x14,10,14,in
+Ledger,17,11,in
+Tabloid,11,17,in
+DL,110,220,mm
+Comm 10,4.125,9.5,in
+Monarch,3.875,7.5,in
+Arch A,9,12,in
+Arch B,12,18,in
+Arch C,18,24,in
+Arch D,24,36,in
+Arch E,36,48,in
+Arch E1,30,42,in
+Arch E2,26,36,in
+Arch E3,27,39,in
+FLSA,8.5,13,in
+FLSE,8.5,13,in
+A Sheet,8.5,11,in
+B Sheet,11,17,in
+C Sheet,17,22,in
+D Sheet,22,34,in
+E Sheet,34,44,in
+ANSI A,8.5,11,in
+ANSI B,11,17,in
+ANSI C,17,22,in
+ANSI D,22,34,in
+ANSI E,34,44,in
+ANSI Super B,13,19,in
+Letter,216,279,mm
diff --git a/rust/paper-sizes/src/lib.rs b/rust/paper-sizes/src/lib.rs
new file mode 100644 (file)
index 0000000..175b083
--- /dev/null
@@ -0,0 +1,886 @@
+//! # paper-sizes
+//!
+//! A library to detect the user's preferred paper size as well as
+//! system-wide and per-user known sizes.  This is a Rust equivalent of
+//! the library features in [libpaper].
+//!
+//! This crate does not provide the `paper` or `paperconf` programs.  Use
+//! [libpaper] for those.
+//!
+//! [libpaper]: https://github.com/rrthomas/libpaper
+//!
+//! # License
+//!
+//! This crate is distributed under your choice of the following licenses:
+//!
+//! * The [MIT License].
+//!
+//! * The [GNU LGPL, version 2.1], or any later version.
+//!
+//! * The [Apache License, version 2.0].
+//!
+//! The `paperspecs` file in this crate is from [libpaper], which documents it
+//! to be in the public domain.
+//!
+//! [MIT License]: https://opensource.org/license/mit
+//! [GNU LGPL, version 2.1]: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html
+//! [Apache License, version 2.0]: https://www.apache.org/licenses/LICENSE-2.0
+#![warn(missing_docs)]
+use std::{
+    borrow::Cow,
+    error::Error,
+    fmt::Display,
+    fs::File,
+    io::{BufRead, BufReader, ErrorKind},
+    ops::Not,
+    path::{Path, PathBuf},
+    str::FromStr,
+};
+
+use xdg::BaseDirectories;
+
+#[cfg(target_os = "linux")]
+mod locale;
+
+include!(concat!(env!("OUT_DIR"), "/paperspecs.rs"));
+
+static PAPERSIZE_FILENAME: &str = "papersize";
+static PAPERSPECS_FILENAME: &str = "paperspecs";
+
+enum DefaultPaper {
+    Name(String),
+    Size(PaperSize),
+}
+
+/// A unit of measurement used for [PaperSize]s.
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum Unit {
+    /// PostScript points (1/72 of an inch).
+    Point,
+
+    /// Inches.
+    Inch,
+
+    /// Millimeters.
+    Millimeter,
+}
+
+/// [Unit] name cannot be parsed.
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub struct ParseUnitError;
+
+impl FromStr for Unit {
+    type Err = ParseUnitError;
+
+    /// Parses the name of a unit in the form used in paperspecs files, one of
+    /// `pt`, `in`, or `mm`.
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "pt" => Ok(Self::Point),
+            "in" => Ok(Self::Inch),
+            "mm" => Ok(Self::Millimeter),
+            _ => Err(ParseUnitError),
+        }
+    }
+}
+
+impl Unit {
+    /// Returns the name of the unit in the form used in paperspecs files.
+    pub fn name(&self) -> &'static str {
+        match self {
+            Unit::Point => "pt",
+            Unit::Inch => "in",
+            Unit::Millimeter => "mm",
+        }
+    }
+
+    /// Returns the number of `other` in one unit of `self`.
+    ///
+    /// To convert a quantity of unit `a` into unit `b`, multiply by
+    /// `a.as_unit(b)`.
+    fn as_unit(&self, other: Unit) -> f64 {
+        match (*self, other) {
+            (Unit::Point, Unit::Point) => 1.0,
+            (Unit::Point, Unit::Inch) => 1.0 / 72.0,
+            (Unit::Point, Unit::Millimeter) => 25.4 / 72.0,
+            (Unit::Inch, Unit::Point) => 72.0,
+            (Unit::Inch, Unit::Inch) => 1.0,
+            (Unit::Inch, Unit::Millimeter) => 25.4,
+            (Unit::Millimeter, Unit::Point) => 72.0 / 25.4,
+            (Unit::Millimeter, Unit::Inch) => 1.0 / 25.4,
+            (Unit::Millimeter, Unit::Millimeter) => 1.0,
+        }
+    }
+}
+
+impl Display for Unit {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.name())
+    }
+}
+
+/// The size of a piece of paper.
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub struct PaperSize {
+    /// The paper's width, in [unit](Self::unit).
+    pub width: f64,
+
+    /// The paper's height (or length), in [unit](Self::unit).
+    pub height: f64,
+
+    /// The unit of [width](Self::width) and [height](Self::height).
+    pub unit: Unit,
+}
+
+impl Default for PaperSize {
+    /// A4, the internationally standard paper size.
+    fn default() -> Self {
+        Self::new(210.0, 297.0, Unit::Millimeter)
+    }
+}
+
+impl PaperSize {
+    /// Constructs a new `PaperSize`.
+    pub fn new(width: f64, height: f64, unit: Unit) -> Self {
+        Self {
+            width,
+            height,
+            unit,
+        }
+    }
+
+    /// Returns this paper size converted to `unit`.
+    pub fn as_unit(&self, unit: Unit) -> PaperSize {
+        Self {
+            width: self.width * self.unit.as_unit(unit),
+            height: self.height * self.unit.as_unit(unit),
+            unit,
+        }
+    }
+
+    /// Returns this paper size's `width` and `height`, discarding the unit.
+    pub fn into_width_height(self) -> (f64, f64) {
+        (self.width, self.height)
+    }
+
+    /// Returns true if `self` and `other` are equal to the nearest `unit`,
+    /// false otherwise.
+    pub fn eq_rounded(&self, other: &Self, unit: Unit) -> bool {
+        let (aw, ah) = self.as_unit(unit).into_width_height();
+        let (bw, bh) = other.as_unit(unit).into_width_height();
+        aw.round() == bw.round() && ah.round() == bh.round()
+    }
+}
+
+/// An error parsing a [PaperSpec].
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum ParsePaperSpecError {
+    /// Invalid paper height.
+    InvalidHeight,
+
+    /// Invalid paper width.
+    InvalidWidth,
+
+    /// Invalid unit of measurement.
+    InvalidUnit,
+
+    /// Missing field in paper specification.
+    MissingField,
+}
+
+impl Error for ParsePaperSpecError {}
+
+impl Display for ParsePaperSpecError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ParsePaperSpecError::InvalidHeight => write!(f, "Invalid paper height."),
+            ParsePaperSpecError::InvalidWidth => write!(f, "Invalid paper width."),
+            ParsePaperSpecError::InvalidUnit => write!(f, "Invalid unit of measurement."),
+            ParsePaperSpecError::MissingField => write!(f, "Missing field in paper specification."),
+        }
+    }
+}
+
+/// A named [PaperSize].
+#[derive(Clone, Debug, PartialEq)]
+pub struct PaperSpec {
+    /// The paper's name, such as `A4` or `Letter`.
+    pub name: Cow<'static, str>,
+
+    /// The paper's size.
+    pub size: PaperSize,
+}
+
+impl PaperSpec {
+    /// Construct a new `PaperSpec`.
+    pub fn new(name: impl Into<Cow<'static, str>>, size: PaperSize) -> Self {
+        Self {
+            name: name.into(),
+            size,
+        }
+    }
+}
+
+impl FromStr for PaperSpec {
+    type Err = ParsePaperSpecError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let mut tokens = s.split(',').map(|s| s.trim());
+        if let Some(name) = tokens.next()
+            && let Some(width) = tokens.next()
+            && let Some(height) = tokens.next()
+            && let Some(unit) = tokens.next()
+        {
+            let width = f64::from_str(width).map_err(|_| ParsePaperSpecError::InvalidWidth)?;
+            let height = f64::from_str(height).map_err(|_| ParsePaperSpecError::InvalidHeight)?;
+            let unit = Unit::from_str(unit).map_err(|_| ParsePaperSpecError::InvalidUnit)?;
+            Ok(Self {
+                name: String::from(name).into(),
+                size: PaperSize::new(width, height, unit),
+            })
+        } else {
+            Err(ParsePaperSpecError::MissingField)
+        }
+    }
+}
+
+/// An error encountered building a [Catalog].
+#[derive(Debug)]
+pub enum CatalogBuildError {
+    /// Line {line_number}: {error}
+    ParseError {
+        /// The file where the parse error occurred.
+        path: PathBuf,
+
+        /// The 1-based line number on which the parse error occurred.
+        line_number: usize,
+
+        /// The parse error.
+        error: ParsePaperSpecError,
+    },
+
+    /// I/O error.
+    IoError {
+        /// The file where the I/O error occurred.
+        path: PathBuf,
+
+        /// Error details.
+        error: std::io::Error,
+    },
+}
+
+impl Error for CatalogBuildError {}
+
+impl Display for CatalogBuildError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            CatalogBuildError::ParseError {
+                path,
+                line_number,
+                error,
+            } => write!(f, "{}:{line_number}: {error}", path.display()),
+            CatalogBuildError::IoError { path, error } => {
+                write!(f, "{}: {error}", path.display())
+            }
+        }
+    }
+}
+
+/// A builder for constructing a [Catalog].
+///
+/// `CatalogBuilder` allows control over the process of constructing a
+/// [Catalog].  If the default options are acceptable, [Catalog::new] bypasses
+/// the need for `CatalogBuilder`.
+pub struct CatalogBuilder<'a> {
+    papersize: Option<Option<&'a str>>,
+    use_locale: bool,
+    user_config_dir: Option<Option<&'a Path>>,
+    system_config_dir: Option<&'a Path>,
+    error_cb: Box<dyn FnMut(CatalogBuildError) + 'a>,
+}
+
+impl<'a> Default for CatalogBuilder<'a> {
+    fn default() -> Self {
+        Self {
+            use_locale: true,
+            papersize: None,
+            user_config_dir: None,
+            system_config_dir: Some(Path::new("/etc")),
+            error_cb: Box::new(drop),
+        }
+    }
+}
+
+fn fallback_specs() -> (Vec<PaperSpec>, PaperSpec) {
+    let specs = STANDARD_PAPERSPECS.into_iter().cloned().collect::<Vec<_>>();
+    let default = specs.first().unwrap().clone();
+    (specs, default)
+}
+
+fn read_specs<E>(
+    user_config_dir: Option<&Path>,
+    system_config_dir: Option<&Path>,
+    mut error_cb: E,
+) -> Option<(Vec<PaperSpec>, PaperSpec)>
+where
+    E: FnMut(CatalogBuildError),
+{
+    fn read_paperspecs_file(
+        directory: Option<&Path>,
+        error_cb: &mut dyn FnMut(CatalogBuildError),
+    ) -> Vec<PaperSpec> {
+        let mut specs = Vec::new();
+        if let Some(directory) = directory {
+            let path = directory.join(PAPERSPECS_FILENAME);
+            match File::open(&path) {
+                Ok(file) => {
+                    let reader = BufReader::new(file);
+                    for (line, line_number) in reader.lines().zip(1..) {
+                        match line
+                            .map_err(|error| CatalogBuildError::IoError {
+                                path: path.clone(),
+                                error,
+                            })
+                            .and_then(|line| {
+                                PaperSpec::from_str(&line).map_err(|error| {
+                                    CatalogBuildError::ParseError {
+                                        path: path.clone(),
+                                        line_number,
+                                        error,
+                                    }
+                                })
+                            }) {
+                            Ok(spec) => specs.push(spec),
+                            Err(error) => error_cb(error),
+                        }
+                    }
+                }
+                Err(error) if error.kind() == ErrorKind::NotFound => (),
+                Err(error) => error_cb(CatalogBuildError::IoError { path, error }),
+            }
+        }
+        specs
+    }
+
+    let user_specs = read_paperspecs_file(user_config_dir, &mut error_cb);
+    let system_specs = read_paperspecs_file(system_config_dir, &mut error_cb);
+    let default_spec = system_specs.first().or(user_specs.first())?.clone();
+    Some((
+        user_specs.into_iter().chain(system_specs).collect(),
+        default_spec,
+    ))
+}
+
+fn default_paper<E>(
+    papersize: Option<Option<&str>>,
+    user_config_dir: Option<&Path>,
+    _use_locale: bool,
+    system_config_dir: Option<&Path>,
+    default: &PaperSpec,
+    mut error_cb: E,
+) -> DefaultPaper
+where
+    E: FnMut(CatalogBuildError),
+{
+    fn read_papersize_file<P, E>(path: P, mut error_cb: E) -> Option<String>
+    where
+        P: AsRef<Path>,
+        E: FnMut(CatalogBuildError),
+    {
+        fn inner(path: &Path) -> std::io::Result<Option<String>> {
+            let file = BufReader::new(File::open(path)?);
+            let line = file.lines().next().unwrap_or(Ok(String::new()))?;
+            let name = line.split(',').next().unwrap_or("");
+            Ok(name.is_empty().not().then(|| name.into()))
+        }
+        let path = path.as_ref();
+        match inner(path) {
+            Ok(result) => result,
+            Err(error) => {
+                if error.kind() != ErrorKind::NotFound {
+                    error_cb(CatalogBuildError::IoError {
+                        path: path.to_path_buf(),
+                        error,
+                    });
+                }
+                None
+            }
+        }
+    }
+
+    // Use `PAPERSIZE` from the environment (or from the override).
+    let env_var;
+    let paper_name = match papersize {
+        Some(paper_name) => paper_name,
+        None => {
+            env_var = std::env::var("PAPERSIZE").ok();
+            env_var.as_deref()
+        }
+    };
+    if let Some(paper_name) = paper_name
+        && !paper_name.is_empty()
+    {
+        return DefaultPaper::Name(paper_name.into());
+    }
+
+    // Then try the user configuration directory.
+    if let Some(dir) = user_config_dir
+        && let path = dir.join(PAPERSIZE_FILENAME)
+        && let Some(paper_name) = read_papersize_file(path, &mut error_cb)
+    {
+        return DefaultPaper::Name(paper_name);
+    }
+
+    // Then try the locale.
+    #[cfg(target_os = "linux")]
+    if _use_locale && let Some(paper_size) = locale::locale_paper_size() {
+        return DefaultPaper::Size(paper_size);
+    }
+
+    if let Some(system_config_dir) = system_config_dir
+        && let Some(paper_name) =
+            read_papersize_file(system_config_dir.join(PAPERSIZE_FILENAME), &mut error_cb)
+    {
+        return DefaultPaper::Name(paper_name);
+    }
+
+    // Otherwise take it from the default papers.
+    DefaultPaper::Name(default.name.as_ref().into())
+}
+
+impl<'a> CatalogBuilder<'a> {
+    /// Constructs a new `CatalogBuilder` with default settings.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Builds a [Catalog] and chooses a default paper size by reading the by
+    /// reading `paperspecs` and `papersize` files and examining the
+    /// environment and (on GNU/Linux) locale.
+    ///
+    /// If no system or user `paperspecs` files exist, or if they exist but they
+    /// contain no valid paper specifications, then this method uses the
+    /// standard paper sizes in [`STANDARD_PAPERSPECS`].  This is usually a
+    /// reasonable fallback.
+    pub fn build(self) -> Catalog {
+        self.build_inner(|user_config_dir, system_config_dir, error_cb| {
+            Some(
+                read_specs(user_config_dir, system_config_dir, error_cb)
+                    .unwrap_or_else(fallback_specs),
+            )
+        })
+        .unwrap()
+    }
+
+    /// Builds a [Catalog] from [`STANDARD_PAPERSPECS`] and chooses a default
+    /// paper size by reading the by reading `papersize` files and examining the
+    /// environment and (on GNU/Linux) locale.
+    ///
+    /// This is a reasonable choice if it is unlikely for `paperspecs` to be
+    /// installed but it is still desirable to detect a default paper size.
+    pub fn build_from_fallback(self) -> Catalog {
+        self.build_inner(|_, _, _| Some(fallback_specs())).unwrap()
+    }
+
+    /// Tries to build a [Catalog] and chooses a default paper size by reading
+    /// the by reading `paperspecs` and `papersize` files and examining the
+    /// environment and (on GNU/Linux) locale.
+    ///
+    /// If no system or user `paperspecs` files exist, or if they exist but they
+    /// contain no valid paper specifications, this method fails and returns
+    /// `None`.
+    pub fn build_without_fallback(self) -> Option<Catalog> {
+        self.build_inner(|user_config_dir, system_config_dir, error_cb| {
+            read_specs(user_config_dir, system_config_dir, error_cb)
+        })
+    }
+
+    /// Sets `papersize` to be used for the value of the `PAPERSIZE` environment
+    /// variable, instead of obtaining it from the process environment.  `None`
+    /// means that the environment variable is assumed to be empty or absent.
+    pub fn with_papersize_value(self, papersize: Option<&'a str>) -> Self {
+        Self {
+            papersize: Some(papersize),
+            ..self
+        }
+    }
+
+    /// On GNU/Linux, by default, `CatalogBuilder` will consider the paper size
+    /// setting in the glibc locale `LC_PAPER`.  This method disables this
+    /// feature.
+    ///
+    /// This setting has no effect on other operating systems, which do not
+    /// support paper size as part of their locales.
+    pub fn without_locale(self) -> Self {
+        Self {
+            use_locale: false,
+            ..self
+        }
+    }
+
+    /// Overrides the name of the user-specific configuration directory.
+    ///
+    /// This directory is searched for the user-specified `paperspecs` and
+    /// `papersize` files.  It defaults to `$XDG_CONFIG_HOME`, which is usually
+    /// `$HOME/.config`.
+    ///
+    /// Passing `None` will disable reading `paperspec` or `papersize` from the
+    /// user configuration directory.
+    pub fn with_user_config_dir(self, user_config_dir: Option<&'a Path>) -> Self {
+        Self {
+            user_config_dir: Some(user_config_dir),
+            ..self
+        }
+    }
+
+    /// Overrides the name of the system configuration directory.
+    ///
+    /// This directory is searched for the system `paperspecs` and `papersize`
+    /// files.  It defaults to `/etc`.
+    ///
+    /// Passing `None` will disable reading `paperspec` or `papersize` from the
+    /// system configuration directory.
+    pub fn with_system_config_dir(self, system_config_dir: Option<&'a Path>) -> Self {
+        Self {
+            system_config_dir,
+            ..self
+        }
+    }
+
+    /// Sets an error reporting callback.
+    ///
+    /// By default, [CatalogBuilder] ignores errors while building the catalog.
+    /// The `error_cb` callback allows the caller to receive information about
+    /// these errors.
+    ///
+    /// It is not considered an error if `paperspecs` or `papersize` files do
+    /// not exist.
+    pub fn with_error_callback(self, error_cb: Box<dyn FnMut(CatalogBuildError) + 'a>) -> Self {
+        Self { error_cb, ..self }
+    }
+
+    fn build_inner<F>(mut self, f: F) -> Option<Catalog>
+    where
+        F: Fn(
+            Option<&Path>,
+            Option<&Path>,
+            &mut Box<dyn FnMut(CatalogBuildError) + 'a>,
+        ) -> Option<(Vec<PaperSpec>, PaperSpec)>,
+    {
+        let base_directories;
+        let user_config_dir = match self.user_config_dir {
+            Some(user_config_dir) => user_config_dir,
+            None => {
+                base_directories = BaseDirectories::new();
+                base_directories.config_home.as_deref()
+            }
+        };
+        let (specs, default) = f(user_config_dir, self.system_config_dir, &mut self.error_cb)?;
+        let default = match default_paper(
+            self.papersize,
+            user_config_dir,
+            self.use_locale,
+            self.system_config_dir,
+            &default,
+            &mut self.error_cb,
+        ) {
+            DefaultPaper::Name(name) => specs
+                .iter()
+                .find(|spec| spec.name.eq_ignore_ascii_case(&name))
+                .cloned()
+                .unwrap_or(default),
+            DefaultPaper::Size(size) => specs
+                .iter()
+                .find(|spec| spec.size.eq_rounded(&size, Unit::Point))
+                .cloned()
+                .unwrap_or_else(|| PaperSpec::new(Cow::from("Locale"), size)),
+        };
+
+        Some(Catalog { specs, default })
+    }
+}
+
+/// A collection of [PaperSpec]s and a default paper size.
+pub struct Catalog {
+    specs: Vec<PaperSpec>,
+    default: PaperSpec,
+}
+
+impl Default for Catalog {
+    fn default() -> Self {
+        Self::builder().build()
+    }
+}
+
+impl Catalog {
+    /// Constructs a new [CatalogBuilder].
+    pub fn builder<'a>() -> CatalogBuilder<'a> {
+        CatalogBuilder::new()
+    }
+
+    /// Constructs a new catalog by reading `paperspecs` and `papersize` files
+    /// and examining the environment.
+    ///
+    /// This is equivalent to `Catalog::builder().build()`.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Returns the contents of the catalog, as a nonempty list of user-specific
+    /// paper sizes, followed by system paper sizes.
+    pub fn specs(&self) -> &[PaperSpec] {
+        &self.specs
+    }
+
+    /// Returns the default paper size.
+    ///
+    /// This paper size might not be in the catalog's list of [PaperSpec]s
+    /// because the default can be specified in terms of measurements rather
+    /// than as a name.
+    pub fn default_paper(&self) -> &PaperSpec {
+        &self.default
+    }
+
+    /// Returns the first [PaperSpec] in the catalog with the given `size` (to
+    /// the nearest PostScript point).
+    pub fn get_by_size(&self, size: &PaperSize) -> Option<&PaperSpec> {
+        self.specs
+            .iter()
+            .find(|spec| spec.size.eq_rounded(size, Unit::Point))
+    }
+
+    /// Returns the first [PaperSpec] in the catalog whose name equals `name`,
+    /// disregarding ASCII case.
+    pub fn get_by_name(&self, name: &str) -> Option<&PaperSpec> {
+        self.specs
+            .iter()
+            .find(|spec| spec.name.eq_ignore_ascii_case(name))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{borrow::Cow, path::Path};
+
+    use crate::{
+        CatalogBuildError, CatalogBuilder, PaperSize, PaperSpec, ParsePaperSpecError, Unit, locale,
+    };
+
+    #[test]
+    fn unit() {
+        assert_eq!(Unit::Point.to_string(), "pt");
+        assert_eq!(Unit::Millimeter.to_string(), "mm");
+        assert_eq!(Unit::Inch.to_string(), "in");
+
+        assert_eq!("pt".parse(), Ok(Unit::Point));
+        assert_eq!("mm".parse(), Ok(Unit::Millimeter));
+        assert_eq!("in".parse(), Ok(Unit::Inch));
+
+        assert_eq!(
+            format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Millimeter)),
+            "25.400"
+        );
+        assert_eq!(
+            format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Inch)),
+            "1.000"
+        );
+        assert_eq!(
+            format!("{:.3}", 1.0 * Unit::Inch.as_unit(Unit::Point)),
+            "72.000"
+        );
+        assert_eq!(
+            format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Millimeter)),
+            "12.700"
+        );
+        assert_eq!(
+            format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Inch)),
+            "0.500"
+        );
+        assert_eq!(
+            format!("{:.3}", 36.0 * Unit::Point.as_unit(Unit::Point)),
+            "36.000"
+        );
+        assert_eq!(
+            format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Millimeter)),
+            "12.700"
+        );
+        assert_eq!(
+            format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Inch)),
+            "0.500"
+        );
+        assert_eq!(
+            format!("{:.3}", 12.7 * Unit::Millimeter.as_unit(Unit::Point)),
+            "36.000"
+        );
+    }
+
+    #[test]
+    fn paperspec() {
+        assert_eq!(
+            "Letter,8.5,11,in".parse(),
+            Ok(PaperSpec::new(
+                Cow::from("Letter"),
+                PaperSize::new(8.5, 11.0, Unit::Inch)
+            ))
+        );
+    }
+
+    #[test]
+    fn default() {
+        // Default from $PAPERSIZE.
+        assert_eq!(
+            CatalogBuilder::new()
+                .with_papersize_value(Some("legal"))
+                .with_user_config_dir(Some(Path::new("testdata/td1")))
+                .without_locale()
+                .build_from_fallback()
+                .default_paper(),
+            &PaperSpec::new(Cow::from("Legal"), PaperSize::new(8.5, 14.0, Unit::Inch))
+        );
+
+        // Default from user_config_dir.
+        assert_eq!(
+            CatalogBuilder::new()
+                .with_papersize_value(None)
+                .with_user_config_dir(Some(Path::new("testdata/td1")))
+                .without_locale()
+                .build_from_fallback()
+                .default_paper(),
+            &PaperSpec::new(Cow::from("Ledger"), PaperSize::new(17.0, 11.0, Unit::Inch))
+        );
+
+        // Default from system_config_dir.
+        assert_eq!(
+            CatalogBuilder::new()
+                .with_papersize_value(None)
+                .with_user_config_dir(None)
+                .with_system_config_dir(Some(Path::new("testdata/td2")))
+                .without_locale()
+                .build_from_fallback()
+                .default_paper(),
+            &PaperSpec::new(
+                Cow::from("Executive"),
+                PaperSize::new(7.25, 10.5, Unit::Inch)
+            )
+        );
+
+        // Default from the first system paper size.
+        assert_eq!(
+            CatalogBuilder::new()
+                .with_papersize_value(None)
+                .with_user_config_dir(None)
+                .with_system_config_dir(Some(Path::new("testdata/td2")))
+                .without_locale()
+                .build()
+                .default_paper(),
+            &PaperSpec::new(
+                Cow::from("A0"),
+                PaperSize::new(841.0, 1189.0, Unit::Millimeter)
+            )
+        );
+
+        // Default from the first user paper size.
+        assert_eq!(
+            CatalogBuilder::new()
+                .with_papersize_value(None)
+                .with_user_config_dir(Some(Path::new("testdata/td3")))
+                .with_system_config_dir(None)
+                .without_locale()
+                .build()
+                .default_paper(),
+            &PaperSpec::new(
+                Cow::from("B0"),
+                PaperSize::new(1000.0, 1414.0, Unit::Millimeter)
+            )
+        );
+
+        // Default when nothing can be read and fallback triggers.
+        assert_eq!(
+            CatalogBuilder::new()
+                .with_papersize_value(None)
+                .with_user_config_dir(None)
+                .with_system_config_dir(None)
+                .without_locale()
+                .build()
+                .default_paper(),
+            &PaperSpec::new(
+                Cow::from("A4"),
+                PaperSize::new(210.0, 297.0, Unit::Millimeter)
+            )
+        );
+
+        // Verify that nothing can be read in the previous case.
+        assert!(
+            CatalogBuilder::new()
+                .with_papersize_value(None)
+                .with_user_config_dir(None)
+                .with_system_config_dir(None)
+                .without_locale()
+                .build_without_fallback()
+                .is_none()
+        );
+    }
+
+    #[test]
+    fn errors() {
+        // Missing files are not errors.
+        let mut errors = Vec::new();
+        let _ = CatalogBuilder::new()
+            .with_papersize_value(None)
+            .with_user_config_dir(Some(Path::new("nonexistent/user")))
+            .with_system_config_dir(Some(Path::new("nonexistent/system")))
+            .without_locale()
+            .with_error_callback(Box::new(|error| errors.push(error)))
+            .build()
+            .default_paper();
+        assert_eq!(errors.len(), 0);
+
+        // Test parse errors.
+        let mut errors = Vec::new();
+        let _ = CatalogBuilder::new()
+            .with_papersize_value(None)
+            .with_user_config_dir(None)
+            .with_system_config_dir(Some(Path::new("testdata/td4")))
+            .without_locale()
+            .with_error_callback(Box::new(|error| errors.push(error)))
+            .build()
+            .default_paper();
+
+        assert_eq!(errors.len(), 4);
+        for ((error, expect_line_number), expect_error) in errors.iter().zip(1..).zip([
+            ParsePaperSpecError::MissingField,
+            ParsePaperSpecError::InvalidWidth,
+            ParsePaperSpecError::InvalidHeight,
+            ParsePaperSpecError::InvalidUnit,
+        ]) {
+            let CatalogBuildError::ParseError {
+                path,
+                line_number,
+                error,
+            } = error
+            else {
+                unreachable!()
+            };
+            assert_eq!(path.as_path(), Path::new("testdata/td4/paperspecs"));
+            assert_eq!(*line_number, expect_line_number);
+            assert_eq!(*error, expect_error);
+        }
+    }
+
+    #[cfg(target_os = "linux")]
+    #[test]
+    fn lc_paper() {
+        // Haven't figured out a good way to test this.
+        //
+        // I expect that all locales default to either A4 or letter-sized paper,
+        // so just check for that.
+        if let Some(size) = locale::locale_paper_size() {
+            assert_eq!(size.unit, Unit::Millimeter);
+            let (w, h) = size.into_width_height();
+            assert!(
+                (w, h) == (210.0, 297.0) || (w, h) == (216.0, 279.0),
+                "Expected A4 (210x297) or letter (216x279) paper, got {w}x{h} mm"
+            );
+        }
+    }
+}
diff --git a/rust/paper-sizes/src/locale.rs b/rust/paper-sizes/src/locale.rs
new file mode 100644 (file)
index 0000000..13266f8
--- /dev/null
@@ -0,0 +1,32 @@
+#[allow(non_upper_case_globals)]
+#[allow(non_camel_case_types)]
+#[allow(non_snake_case)]
+#[allow(dead_code)]
+mod sys {
+    include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
+}
+
+use libc::{c_char, nl_item, nl_langinfo};
+use sys::{_NL_PAPER_HEIGHT, _NL_PAPER_WIDTH};
+
+use crate::{PaperSize, Unit};
+
+fn paper_info(item: nl_item) -> u32 {
+    let pointer = unsafe { nl_langinfo(item) } as usize;
+    let bytes = pointer.to_ne_bytes();
+    u32::from_ne_bytes(bytes[..4].try_into().unwrap())
+}
+
+pub fn locale_paper_size() -> Option<PaperSize> {
+    if !unsafe { libc::setlocale(libc::LC_PAPER, &[0i8] as *const c_char) }.is_null() {
+        let width = paper_info(_NL_PAPER_WIDTH as nl_item);
+        let height = paper_info(_NL_PAPER_HEIGHT as nl_item);
+        Some(PaperSize::new(
+            width as f64,
+            height as f64,
+            Unit::Millimeter,
+        ))
+    } else {
+        None
+    }
+}
diff --git a/rust/paper-sizes/testdata/td1/papersize b/rust/paper-sizes/testdata/td1/papersize
new file mode 100644 (file)
index 0000000..4956024
--- /dev/null
@@ -0,0 +1 @@
+Ledger
diff --git a/rust/paper-sizes/testdata/td2/papersize b/rust/paper-sizes/testdata/td2/papersize
new file mode 100644 (file)
index 0000000..623280d
--- /dev/null
@@ -0,0 +1 @@
+executive
diff --git a/rust/paper-sizes/testdata/td2/paperspecs b/rust/paper-sizes/testdata/td2/paperspecs
new file mode 100644 (file)
index 0000000..3cc4d41
--- /dev/null
@@ -0,0 +1,9 @@
+A0,841,1189,mm
+A1,594,841,mm
+A2,420,594,mm
+A3,297,420,mm
+A6,105,148,mm
+A7,74,105,mm
+A8,52,74,mm
+A9,37,52,mm
+A10,26,37,mm
diff --git a/rust/paper-sizes/testdata/td3/paperspecs b/rust/paper-sizes/testdata/td3/paperspecs
new file mode 100644 (file)
index 0000000..e09bcea
--- /dev/null
@@ -0,0 +1,11 @@
+B0,1000,1414,mm
+B1,707,1000,mm
+B2,500,707,mm
+B3,353,500,mm
+B4,250,353,mm
+B5,176,250,mm
+B6,125,176,mm
+B7,88,125,mm
+B8,62,88,mm
+B9,44,62,mm
+B10,31,44,mm
diff --git a/rust/paper-sizes/testdata/td4/paperspecs b/rust/paper-sizes/testdata/td4/paperspecs
new file mode 100644 (file)
index 0000000..8123450
--- /dev/null
@@ -0,0 +1,4 @@
+not,enough,fields
+OK,not a width,11,in
+OK,8.5,not a height,in
+OK,8.5,11,not a unit
\ No newline at end of file
diff --git a/rust/paper-sizes/wrapper.h b/rust/paper-sizes/wrapper.h
new file mode 100644 (file)
index 0000000..0219213
--- /dev/null
@@ -0,0 +1,2 @@
+#include <nl_types.h>
+#include <langinfo.h>