From: Ben Pfaff Date: Fri, 3 Oct 2025 18:45:05 +0000 (-0700) Subject: rust: New Rust crate `paper-sizes` for detecting paper sizes and defaults. X-Git-Url: https://pintos-os.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=7d9ca33fc4425ee87e8be82473bc7b03d54debc2;p=pspp rust: New Rust crate `paper-sizes` for detecting paper sizes and defaults. --- diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f51fe61647..f67759c596 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -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" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index a87a94ff48..530df04bb8 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -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 index 0000000000..fad5d5262b --- /dev/null +++ b/rust/paper-sizes/Cargo.toml @@ -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 index 0000000000..e5965d60b2 --- /dev/null +++ b/rust/paper-sizes/README.md @@ -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 index 0000000000..6b4dca5453 --- /dev/null +++ b/rust/paper-sizes/build.rs @@ -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 index 0000000000..6503142c16 --- /dev/null +++ b/rust/paper-sizes/paperspecs @@ -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 index 0000000000..175b083ab2 --- /dev/null +++ b/rust/paper-sizes/src/lib.rs @@ -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 { + 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>, size: PaperSize) -> Self { + Self { + name: name.into(), + size, + } + } +} + +impl FromStr for PaperSpec { + type Err = ParsePaperSpecError; + + fn from_str(s: &str) -> Result { + 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>, + use_locale: bool, + user_config_dir: Option>, + system_config_dir: Option<&'a Path>, + error_cb: Box, +} + +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) { + let specs = STANDARD_PAPERSPECS.into_iter().cloned().collect::>(); + let default = specs.first().unwrap().clone(); + (specs, default) +} + +fn read_specs( + user_config_dir: Option<&Path>, + system_config_dir: Option<&Path>, + mut error_cb: E, +) -> Option<(Vec, PaperSpec)> +where + E: FnMut(CatalogBuildError), +{ + fn read_paperspecs_file( + directory: Option<&Path>, + error_cb: &mut dyn FnMut(CatalogBuildError), + ) -> Vec { + 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( + papersize: Option>, + 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(path: P, mut error_cb: E) -> Option + where + P: AsRef, + E: FnMut(CatalogBuildError), + { + fn inner(path: &Path) -> std::io::Result> { + 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 { + 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) -> Self { + Self { error_cb, ..self } + } + + fn build_inner(mut self, f: F) -> Option + where + F: Fn( + Option<&Path>, + Option<&Path>, + &mut Box, + ) -> Option<(Vec, 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, + 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 index 0000000000..13266f81e9 --- /dev/null +++ b/rust/paper-sizes/src/locale.rs @@ -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 { + 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 index 0000000000..4956024c08 --- /dev/null +++ b/rust/paper-sizes/testdata/td1/papersize @@ -0,0 +1 @@ +Ledger diff --git a/rust/paper-sizes/testdata/td2/papersize b/rust/paper-sizes/testdata/td2/papersize new file mode 100644 index 0000000000..623280d986 --- /dev/null +++ b/rust/paper-sizes/testdata/td2/papersize @@ -0,0 +1 @@ +executive diff --git a/rust/paper-sizes/testdata/td2/paperspecs b/rust/paper-sizes/testdata/td2/paperspecs new file mode 100644 index 0000000000..3cc4d41f31 --- /dev/null +++ b/rust/paper-sizes/testdata/td2/paperspecs @@ -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 index 0000000000..e09bceaa55 --- /dev/null +++ b/rust/paper-sizes/testdata/td3/paperspecs @@ -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 index 0000000000..8123450613 --- /dev/null +++ b/rust/paper-sizes/testdata/td4/paperspecs @@ -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 index 0000000000..0219213810 --- /dev/null +++ b/rust/paper-sizes/wrapper.h @@ -0,0 +1,2 @@ +#include +#include