Files
OneCAD-Draft/src/io/paper_catalog.rs
T

868 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Paper catalogue with the source application-compatible media names.
//!
//! the source application stores a layout's paper as the plotter driver's *canonical media
//! name* — `ISO_A4_(210.00_x_297.00_MM)`, `ANSI_B_(11.00_x_17.00_Inches)`,
//! `ARCH_full_bleed_D_(24.00_x_36.00_Inches)` — always with the portrait
//! dimensions; landscape is expressed through the plot rotation. A drawing
//! saved here has to carry those exact names so the source application finds the media in its
//! `DWG To PDF.pc3` list instead of silently falling back to a default sheet,
//! and a name that arrives from the source application (including a custom size this catalogue
//! has never seen) must still resolve to its dimensions and go back out
//! untouched. The name itself encodes the dimensions, which is what makes the
//! second half possible: [`PaperSize::parse_canonical`] never needs a table.
use std::borrow::Cow;
use std::sync::OnceLock;
/// Unit the media is defined in. the source application keeps ISO/JIS sheets in millimetres
/// and ANSI/ARCH sheets in inches, and the unit is part of the media name.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, serde::Serialize, serde::Deserialize)]
pub enum PaperUnits {
Millimeters,
Inches,
}
impl PaperUnits {
pub const MM_PER_INCH: f64 = 25.4;
pub fn to_mm(self, value: f64) -> f64 {
match self {
PaperUnits::Millimeters => value,
PaperUnits::Inches => value * Self::MM_PER_INCH,
}
}
pub fn from_mm(self, value: f64) -> f64 {
match self {
PaperUnits::Millimeters => value,
PaperUnits::Inches => value / Self::MM_PER_INCH,
}
}
/// The unit token the source application writes inside the media name.
fn canonical_suffix(self) -> &'static str {
match self {
PaperUnits::Millimeters => "MM",
PaperUnits::Inches => "Inches",
}
}
fn from_canonical_suffix(token: &str) -> Option<Self> {
if token.eq_ignore_ascii_case("MM") {
Some(PaperUnits::Millimeters)
} else if token.eq_ignore_ascii_case("Inches") || token.eq_ignore_ascii_case("Inch") {
Some(PaperUnits::Inches)
} else {
None
}
}
/// Short unit label for display (`mm` / `in`).
pub fn short_label(self) -> &'static str {
match self {
PaperUnits::Millimeters => "mm",
PaperUnits::Inches => "in",
}
}
}
/// The sheet family, used to group the picker and to prefer a standard sheet
/// over its full-bleed twin when matching by dimensions.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
pub enum PaperSeries {
IsoA,
IsoB,
Ansi,
Arch,
Jis,
/// Letter / Legal / Tabloid under their US names, and any user-defined sheet.
Other,
}
/// the source application offers three printable-area variants of most sheets: the standard
/// one with driver margins, *full bleed* with zero margins, and *expand*
/// (the driver's maximum printable area). The sheet dimensions are identical;
/// only the media name and the margins differ.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
pub enum PaperVariant {
Standard,
FullBleed,
Expand,
}
impl PaperVariant {
/// The infix the source application places between the series and the sheet letter.
fn canonical_infix(self) -> &'static str {
match self {
PaperVariant::Standard => "",
PaperVariant::FullBleed => "full_bleed_",
PaperVariant::Expand => "expand_",
}
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum Orientation {
Portrait,
Landscape,
}
/// Unprintable margins of a sheet, in the order the source application stores them. The unit
/// is whatever the context says — millimetres in a drawing, the sheet's own
/// unit in a user-defined size.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Margins {
pub left: f64,
pub bottom: f64,
pub right: f64,
pub top: f64,
}
impl Margins {
pub const ZERO: Margins = Margins {
left: 0.0,
bottom: 0.0,
right: 0.0,
top: 0.0,
};
pub const fn uniform(value: f64) -> Self {
Margins {
left: value,
bottom: value,
right: value,
top: value,
}
}
/// The same margins expressed in millimetres when they were given in
/// `units`.
pub fn to_mm(self, units: PaperUnits) -> Self {
Margins {
left: units.to_mm(self.left),
bottom: units.to_mm(self.bottom),
right: units.to_mm(self.right),
top: units.to_mm(self.top),
}
}
}
/// A sheet the user defined, kept in the application settings the way
/// the source application keeps custom sizes in a plotter configuration: a name, the
/// dimensions in the sheet's own unit, and the printable margins.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct CustomPaper {
/// Human name, e.g. `Roll 24`; becomes `Roll_24_(…)` in the media name.
pub name: String,
pub width: f64,
pub height: f64,
pub units: PaperUnits,
/// Unprintable margins in `units`.
pub margins: Margins,
}
impl Default for CustomPaper {
fn default() -> Self {
Self {
name: String::new(),
width: 0.0,
height: 0.0,
units: PaperUnits::Millimeters,
margins: Margins::ZERO,
}
}
}
impl CustomPaper {
/// The catalogue sheet this definition describes.
pub fn paper(&self) -> PaperSize {
PaperSize::custom_named(&self.name, self.width, self.height, self.units)
}
pub fn canonical(&self) -> String {
self.paper().canonical.into_owned()
}
pub fn margins_mm(&self) -> Margins {
self.margins.to_mm(self.units)
}
}
/// One sheet of the catalogue, or a sheet reconstructed from a media name.
#[derive(Clone, PartialEq, Debug)]
pub struct PaperSize {
/// the source application canonical media name, e.g. `ISO_A4_(210.00_x_297.00_MM)`. This is
/// the value written to `PlotSettings::paper_size` and the key the plot
/// dialog stores.
pub canonical: Cow<'static, str>,
/// Human name without dimensions: `ISO A4`, `ANSI full bleed B`.
pub label: Cow<'static, str>,
pub series: PaperSeries,
pub variant: PaperVariant,
/// Portrait width in `units` (width ≤ height).
pub width: f64,
/// Portrait height in `units`.
pub height: f64,
pub units: PaperUnits,
}
impl PaperSize {
/// Portrait dimensions in millimetres.
pub fn portrait_mm(&self) -> (f64, f64) {
(self.units.to_mm(self.width), self.units.to_mm(self.height))
}
/// Sheet dimensions in millimetres for the given orientation.
pub fn sheet_mm(&self, orientation: Orientation) -> (f64, f64) {
let (w, h) = self.portrait_mm();
match orientation {
Orientation::Portrait => (w, h),
Orientation::Landscape => (h, w),
}
}
/// Picker text: the human name followed by the dimensions in the sheet's
/// own unit — `ISO A4 (210 × 297 mm)`, `ANSI B (11.00 × 17.00 in)`.
pub fn display(&self) -> String {
format!(
"{} ({} × {} {})",
self.label,
format_dimension(self.width, self.units),
format_dimension(self.height, self.units),
self.units.short_label()
)
}
/// A user-defined sheet, named the way the source application names custom media created
/// in the plot dialog (`UserDefinedMetric_(420.00_x_297.00_MM)`). The
/// dimensions are normalised to portrait so the name stays canonical.
pub fn custom(width: f64, height: f64, units: PaperUnits) -> Self {
let label = match units {
PaperUnits::Millimeters => "UserDefinedMetric",
PaperUnits::Inches => "UserDefinedInches",
};
Self::custom_named(label, width, height, units)
}
/// A user-defined sheet under its own name, the way a plotter
/// configuration's custom sizes are named (`Roll_24_(609.60_x_1500.00_MM)`).
/// A blank name falls back to the source application's generic one; spaces become
/// underscores in the media name and parentheses are dropped so the name
/// stays parseable.
pub fn custom_named(name: &str, width: f64, height: f64, units: PaperUnits) -> Self {
let (width, height) = if width <= height {
(width, height)
} else {
(height, width)
};
let cleaned: String = name
.trim()
.chars()
.filter(|c| !matches!(c, '(' | ')'))
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if cleaned.is_empty() {
return Self::custom(width, height, units);
}
Self {
canonical: Cow::Owned(canonical_name(
&cleaned.replace(' ', "_"),
width,
height,
units,
)),
label: Cow::Owned(cleaned),
series: PaperSeries::Other,
variant: PaperVariant::Standard,
width,
height,
units,
}
}
/// Rebuild a sheet from any media name that follows the source application's
/// `<name>_(<width>_x_<height>_<MM|Inches>)` convention, whether or not the
/// catalogue knows it. The dialog form with spaces (`ISO A4 (210.00 x
/// 297.00 MM)`) is accepted too. Returns `None` for names without an
/// embedded dimension group (a bare printer paper name such as `A4`, or a
/// raster `Pixels` size).
pub fn parse_canonical(name: &str) -> Option<Self> {
let name = name.trim();
let open = name.rfind('(')?;
let close = name[open..].find(')')? + open;
let dims = name[open + 1..close].trim_matches(['_', ' ']);
let raw_label = name[..open].trim_matches(['_', ' ']);
if raw_label.is_empty() {
return None;
}
// `210.00_x_297.00_MM`, or `210.00 x 297.00 MM` from a dialog string.
let normalized: String = dims.replace(' ', "_");
let mut parts = normalized.split('_').filter(|part| !part.is_empty());
let width: f64 = parts.next()?.parse().ok()?;
if !parts.next()?.eq_ignore_ascii_case("x") {
return None;
}
let height: f64 = parts.next()?.parse().ok()?;
let units = PaperUnits::from_canonical_suffix(parts.next()?)?;
if parts.next().is_some()
|| width <= 0.0
|| height <= 0.0
|| !width.is_finite()
|| !height.is_finite()
{
return None;
}
let label = raw_label.replace('_', " ");
let (series, variant) = classify_label(&label);
let (portrait_width, portrait_height) = if width <= height {
(width, height)
} else {
(height, width)
};
Some(Self {
canonical: Cow::Owned(canonical_name(
&raw_label.replace(' ', "_"),
width,
height,
units,
)),
label: Cow::Owned(label),
series,
variant,
width: portrait_width,
height: portrait_height,
units,
})
}
}
fn format_dimension(value: f64, units: PaperUnits) -> String {
match units {
// Millimetre sheets are whole numbers in every standard; keep the
// fraction only when a custom size needs it.
PaperUnits::Millimeters if (value - value.round()).abs() < 1e-6 => {
format!("{}", value.round() as i64)
}
PaperUnits::Millimeters => format!("{value:.1}"),
PaperUnits::Inches => format!("{value:.2}"),
}
}
/// `<prefix>_(<w>_x_<h>_<units>)` exactly as the source application spells it: two decimals,
/// portrait order.
fn canonical_name(prefix: &str, width: f64, height: f64, units: PaperUnits) -> String {
format!(
"{prefix}_({width:.2}_x_{height:.2}_{})",
units.canonical_suffix()
)
}
/// Series and variant from a human label (`ISO full bleed A4`, `ARCH expand D`).
fn classify_label(label: &str) -> (PaperSeries, PaperVariant) {
let lower = label.to_ascii_lowercase();
let variant = if lower.contains("full bleed") {
PaperVariant::FullBleed
} else if lower.contains("expand") {
PaperVariant::Expand
} else {
PaperVariant::Standard
};
let series = if lower.starts_with("iso b") {
PaperSeries::IsoB
} else if lower.starts_with("iso") {
PaperSeries::IsoA
} else if lower.starts_with("ansi") {
PaperSeries::Ansi
} else if lower.starts_with("arch") {
PaperSeries::Arch
} else if lower.starts_with("jis") {
PaperSeries::Jis
} else {
PaperSeries::Other
};
(series, variant)
}
// ── Catalogue ─────────────────────────────────────────────────────────────
/// Compact source row: series prefix as the source application writes it, sheet letter,
/// portrait width/height, units, and whether the source application ships full-bleed /
/// expand twins of it in `DWG To PDF.pc3`.
struct Row {
series: PaperSeries,
prefix: &'static str,
sheet: &'static str,
width: f64,
height: f64,
units: PaperUnits,
twins: bool,
}
const fn mm(
series: PaperSeries,
prefix: &'static str,
sheet: &'static str,
w: f64,
h: f64,
twins: bool,
) -> Row {
Row {
series,
prefix,
sheet,
width: w,
height: h,
units: PaperUnits::Millimeters,
twins,
}
}
const fn inch(
series: PaperSeries,
prefix: &'static str,
sheet: &'static str,
w: f64,
h: f64,
twins: bool,
) -> Row {
Row {
series,
prefix,
sheet,
width: w,
height: h,
units: PaperUnits::Inches,
twins,
}
}
/// The sheets the source application's `DWG To PDF.pc3` offers, in its display order, plus
/// the ISO B / JIS B series that other drivers add and the US names system
/// printers report.
const ROWS: &[Row] = &[
mm(PaperSeries::IsoA, "ISO", "A0", 841.0, 1189.0, true),
mm(PaperSeries::IsoA, "ISO", "A1", 594.0, 841.0, true),
mm(PaperSeries::IsoA, "ISO", "A2", 420.0, 594.0, true),
mm(PaperSeries::IsoA, "ISO", "A3", 297.0, 420.0, true),
mm(PaperSeries::IsoA, "ISO", "A4", 210.0, 297.0, true),
mm(PaperSeries::IsoA, "ISO", "A5", 148.0, 210.0, false),
mm(PaperSeries::IsoB, "ISO", "B0", 1000.0, 1414.0, false),
mm(PaperSeries::IsoB, "ISO", "B1", 707.0, 1000.0, false),
mm(PaperSeries::IsoB, "ISO", "B2", 500.0, 707.0, false),
mm(PaperSeries::IsoB, "ISO", "B3", 353.0, 500.0, false),
mm(PaperSeries::IsoB, "ISO", "B4", 250.0, 353.0, false),
mm(PaperSeries::IsoB, "ISO", "B5", 176.0, 250.0, false),
inch(PaperSeries::Ansi, "ANSI", "A", 8.5, 11.0, true),
inch(PaperSeries::Ansi, "ANSI", "B", 11.0, 17.0, true),
inch(PaperSeries::Ansi, "ANSI", "C", 17.0, 22.0, true),
inch(PaperSeries::Ansi, "ANSI", "D", 22.0, 34.0, true),
inch(PaperSeries::Ansi, "ANSI", "E", 34.0, 44.0, true),
inch(PaperSeries::Arch, "ARCH", "A", 9.0, 12.0, true),
inch(PaperSeries::Arch, "ARCH", "B", 12.0, 18.0, true),
inch(PaperSeries::Arch, "ARCH", "C", 18.0, 24.0, true),
inch(PaperSeries::Arch, "ARCH", "D", 24.0, 36.0, true),
inch(PaperSeries::Arch, "ARCH", "E", 36.0, 48.0, true),
inch(PaperSeries::Arch, "ARCH", "E1", 30.0, 42.0, true),
mm(PaperSeries::Jis, "JIS", "B0", 1030.0, 1456.0, false),
mm(PaperSeries::Jis, "JIS", "B1", 728.0, 1030.0, false),
mm(PaperSeries::Jis, "JIS", "B2", 515.0, 728.0, false),
mm(PaperSeries::Jis, "JIS", "B3", 364.0, 515.0, false),
mm(PaperSeries::Jis, "JIS", "B4", 257.0, 364.0, false),
mm(PaperSeries::Jis, "JIS", "B5", 182.0, 257.0, false),
inch(PaperSeries::Other, "Letter", "", 8.5, 11.0, false),
inch(PaperSeries::Other, "Legal", "", 8.5, 14.0, false),
inch(PaperSeries::Other, "Tabloid", "", 11.0, 17.0, false),
];
fn build_catalog() -> Vec<PaperSize> {
let mut sizes = Vec::with_capacity(ROWS.len() * 3);
for row in ROWS {
let variants: &[PaperVariant] = if row.twins {
&[
PaperVariant::Standard,
PaperVariant::FullBleed,
PaperVariant::Expand,
]
} else {
&[PaperVariant::Standard]
};
for &variant in variants {
// `ISO_full_bleed_A4`, `ANSI_expand_B`, `Letter`.
let prefix = if row.sheet.is_empty() {
row.prefix.to_string()
} else {
format!("{}_{}{}", row.prefix, variant.canonical_infix(), row.sheet)
};
sizes.push(PaperSize {
canonical: Cow::Owned(canonical_name(&prefix, row.width, row.height, row.units)),
label: Cow::Owned(prefix.replace('_', " ")),
series: row.series,
variant,
width: row.width,
height: row.height,
units: row.units,
});
}
}
sizes
}
/// Every catalogued sheet in picker order.
pub fn catalog() -> &'static [PaperSize] {
static CATALOG: OnceLock<Vec<PaperSize>> = OnceLock::new();
CATALOG.get_or_init(build_catalog)
}
/// The sheet a fresh drawing or a page setup without one falls back to.
pub fn default_paper() -> &'static PaperSize {
catalog()
.iter()
.find(|paper| paper.canonical == "ISO_A4_(210.00_x_297.00_MM)")
.expect("catalogue always carries ISO A4")
}
/// Short names a system printer driver, an older Open CAD Studio config or a
/// user at the command line may spell a sheet with, mapped onto the canonical
/// sheet. `Ledger` is the landscape name of Tabloid; the sheet is the same.
const ALIASES: &[(&str, &str)] = &[
("A0", "ISO_A0_(841.00_x_1189.00_MM)"),
("A1", "ISO_A1_(594.00_x_841.00_MM)"),
("A2", "ISO_A2_(420.00_x_594.00_MM)"),
("A3", "ISO_A3_(297.00_x_420.00_MM)"),
("A4", "ISO_A4_(210.00_x_297.00_MM)"),
("A5", "ISO_A5_(148.00_x_210.00_MM)"),
("B4", "ISO_B4_(250.00_x_353.00_MM)"),
("B5", "ISO_B5_(176.00_x_250.00_MM)"),
("Letter", "Letter_(8.50_x_11.00_Inches)"),
("US Letter", "Letter_(8.50_x_11.00_Inches)"),
("Legal", "Legal_(8.50_x_14.00_Inches)"),
("US Legal", "Legal_(8.50_x_14.00_Inches)"),
("Tabloid", "Tabloid_(11.00_x_17.00_Inches)"),
("Ledger", "Tabloid_(11.00_x_17.00_Inches)"),
];
/// Resolve any spelling of a sheet: an exact canonical name (case-insensitive,
/// underscores or spaces), a driver/config alias such as `A4` or `Letter`, or
/// an unknown but well-formed the source application name, which is reconstructed from its
/// embedded dimensions. `None` only for names that carry no size at all.
pub fn resolve(name: &str) -> Option<PaperSize> {
let name = name.trim();
if name.is_empty() {
return None;
}
let spaced = name.replace('_', " ");
if let Some(paper) = catalog().iter().find(|paper| {
paper
.canonical
.replace('_', " ")
.eq_ignore_ascii_case(&spaced)
}) {
return Some(paper.clone());
}
if let Some((_, canonical)) = ALIASES
.iter()
.find(|(alias, _)| alias.eq_ignore_ascii_case(name))
{
return catalog()
.iter()
.find(|paper| paper.canonical == *canonical)
.cloned();
}
PaperSize::parse_canonical(name)
}
/// The catalogued sheet whose portrait size matches `(w, h)` in millimetres
/// (either order) within half a millimetre. Standard variants win over their
/// full-bleed / expand twins so a size read from a drawing without a usable
/// name lands on the plain sheet.
pub fn match_dimensions_mm(w: f64, h: f64) -> Option<&'static PaperSize> {
const TOLERANCE_MM: f64 = 0.5;
let (short, long) = if w <= h { (w, h) } else { (h, w) };
catalog()
.iter()
.filter(|paper| paper.variant == PaperVariant::Standard)
.filter(|paper| {
let (pw, ph) = paper.portrait_mm();
(pw - short).abs() <= TOLERANCE_MM && (ph - long).abs() <= TOLERANCE_MM
})
// ANSI A and Letter share a size; prefer the named series.
.min_by_key(|paper| paper.series)
}
/// Sheet for a name as read from a drawing: the catalogued or reconstructed
/// sheet when the name carries one, otherwise the catalogued sheet with the
/// given dimensions, otherwise a custom sheet built from the dimensions alone.
pub fn from_drawing(name: &str, width_mm: f64, height_mm: f64) -> PaperSize {
if let Some(paper) = resolve(name) {
return paper;
}
if let Some(paper) = match_dimensions_mm(width_mm, height_mm) {
return paper.clone();
}
if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
return default_paper().clone();
}
PaperSize::custom(width_mm, height_mm, PaperUnits::Millimeters)
}
// ── Plot geometry helpers ─────────────────────────────────────────────────
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum PlotScale {
/// Scale so the window fills the sheet minus a 5% margin.
Fit,
/// Exact scale factor (mm per drawing unit).
Ratio(f64),
}
/// Map a world-space window onto a sheet. Returns (scale, offset_x, offset_y),
/// where the scaled window is centered on the sheet and (offset_x, offset_y) is
/// the sheet-mm position of the window's min corner. The caller turns that into
/// its own coordinate offset.
pub fn window_to_sheet(
window_wh: (f64, f64),
sheet_mm: (f64, f64),
scale: PlotScale,
) -> (f64, f64, f64) {
let (ww, wh) = (window_wh.0.max(1e-9), window_wh.1.max(1e-9));
let scale = match scale {
PlotScale::Ratio(r) => r.max(1e-9),
PlotScale::Fit => {
const MARGIN: f64 = 1.05;
let sx = (sheet_mm.0 / MARGIN) / ww;
let sy = (sheet_mm.1 / MARGIN) / wh;
sx.min(sy)
}
};
let scaled_w = ww * scale;
let scaled_h = wh * scale;
let offset_x = (sheet_mm.0 - scaled_w) / 2.0;
let offset_y = (sheet_mm.1 - scaled_h) / 2.0;
(scale, offset_x, offset_y)
}
#[cfg(test)]
mod tests {
use super::*;
fn a4() -> PaperSize {
resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap()
}
#[test]
fn catalogue_names_follow_source_app_spelling() {
let names: Vec<&str> = catalog().iter().map(|p| p.canonical.as_ref()).collect();
for expected in [
"ISO_A4_(210.00_x_297.00_MM)",
"ISO_full_bleed_A4_(210.00_x_297.00_MM)",
"ISO_expand_A0_(841.00_x_1189.00_MM)",
"ISO_B4_(250.00_x_353.00_MM)",
"ANSI_A_(8.50_x_11.00_Inches)",
"ANSI_full_bleed_B_(11.00_x_17.00_Inches)",
"ARCH_D_(24.00_x_36.00_Inches)",
"ARCH_expand_E1_(30.00_x_42.00_Inches)",
"JIS_B4_(257.00_x_364.00_MM)",
"Letter_(8.50_x_11.00_Inches)",
"Legal_(8.50_x_14.00_Inches)",
"Tabloid_(11.00_x_17.00_Inches)",
] {
assert!(names.contains(&expected), "missing {expected}");
}
// Every name is portrait and unique.
let mut seen = std::collections::HashSet::new();
for paper in catalog() {
assert!(
paper.width <= paper.height,
"{} is not portrait",
paper.canonical
);
assert!(
seen.insert(paper.canonical.clone()),
"duplicate {}",
paper.canonical
);
}
}
#[test]
fn every_catalogue_name_round_trips_through_the_parser() {
for paper in catalog() {
let parsed = PaperSize::parse_canonical(&paper.canonical)
.unwrap_or_else(|| panic!("cannot parse {}", paper.canonical));
assert_eq!(parsed.canonical, paper.canonical);
assert_eq!(parsed.label, paper.label);
assert_eq!(parsed.series, paper.series);
assert_eq!(parsed.variant, paper.variant);
assert_eq!(parsed.units, paper.units);
assert!((parsed.width - paper.width).abs() < 1e-9);
assert!((parsed.height - paper.height).abs() < 1e-9);
}
}
#[test]
fn unknown_canonical_names_are_reconstructed_and_preserved() {
// A custom size created in a plot dialog.
let custom = resolve("UserDefinedMetric_(420.00_x_297.00_MM)").unwrap();
assert_eq!(custom.canonical, "UserDefinedMetric_(420.00_x_297.00_MM)");
assert_eq!(custom.label, "UserDefinedMetric");
assert_eq!(custom.series, PaperSeries::Other);
assert_eq!(custom.portrait_mm(), (297.0, 420.0));
// A driver sheet the catalogue does not list, in inches.
let oversize = resolve("Oversize_A1_(625.00_x_880.00_MM)").unwrap();
assert_eq!(oversize.portrait_mm(), (625.0, 880.0));
// The dialog spelling with spaces resolves to the same canonical name.
let spaced = resolve("ISO full bleed A3 (297.00 x 420.00 MM)").unwrap();
assert_eq!(spaced.canonical, "ISO_full_bleed_A3_(297.00_x_420.00_MM)");
assert_eq!(spaced.variant, PaperVariant::FullBleed);
assert_eq!(spaced.series, PaperSeries::IsoA);
// A raster size or a bare driver name carries no usable dimensions.
assert!(resolve("Sun_Hi-Res_(1600.00_x_1280.00_Pixels)").is_none());
assert!(resolve("A4 Plain").is_none());
assert!(resolve("").is_none());
}
#[test]
fn aliases_and_case_insensitive_names_resolve() {
assert_eq!(resolve("A4").unwrap().canonical, a4().canonical);
assert_eq!(
resolve("a3").unwrap().canonical,
"ISO_A3_(297.00_x_420.00_MM)"
);
assert_eq!(
resolve("Letter").unwrap().canonical,
"Letter_(8.50_x_11.00_Inches)"
);
assert_eq!(
resolve("Ledger").unwrap().canonical,
"Tabloid_(11.00_x_17.00_Inches)"
);
assert_eq!(
resolve("iso_a4_(210.00_x_297.00_mm)").unwrap().canonical,
a4().canonical
);
}
#[test]
fn dimensions_match_the_plain_sheet_in_either_orientation() {
assert_eq!(
match_dimensions_mm(297.0, 210.0).unwrap().canonical,
a4().canonical
);
assert_eq!(
match_dimensions_mm(210.0, 297.0).unwrap().canonical,
a4().canonical
);
assert_eq!(
match_dimensions_mm(210.3, 297.4).unwrap().canonical,
a4().canonical
);
assert!(match_dimensions_mm(215.0, 297.0).is_none());
// 8.5 × 11 in prefers ANSI A over the Letter alias sheet.
assert_eq!(
match_dimensions_mm(215.9, 279.4).unwrap().canonical,
"ANSI_A_(8.50_x_11.00_Inches)"
);
assert_eq!(
match_dimensions_mm(609.6, 914.4).unwrap().canonical,
"ARCH_D_(24.00_x_36.00_Inches)"
);
}
#[test]
fn from_drawing_falls_back_from_name_to_size_to_custom() {
assert_eq!(
from_drawing("ISO_A3_(297.00_x_420.00_MM)", 0.0, 0.0).label,
"ISO A3"
);
// A legacy drawing with a bare driver name and A4 dimensions.
assert_eq!(
from_drawing("Plain paper", 297.0, 210.0).canonical,
a4().canonical
);
let custom = from_drawing("", 300.0, 500.0);
assert_eq!(custom.canonical, "UserDefinedMetric_(300.00_x_500.00_MM)");
assert_eq!(custom.portrait_mm(), (300.0, 500.0));
assert_eq!(from_drawing("", 0.0, f64::INFINITY), *default_paper());
}
#[test]
fn parser_rejects_non_finite_dimensions() {
assert!(PaperSize::parse_canonical("Custom_(inf_x_297.00_MM)").is_none());
assert!(PaperSize::parse_canonical("Custom_(210.00_x_NaN_MM)").is_none());
}
#[test]
fn sheet_and_display_formatting() {
assert_eq!(a4().sheet_mm(Orientation::Portrait), (210.0, 297.0));
assert_eq!(a4().sheet_mm(Orientation::Landscape), (297.0, 210.0));
assert_eq!(a4().display(), "ISO A4 (210 × 297 mm)");
let ansi_b = resolve("ANSI_B_(11.00_x_17.00_Inches)").unwrap();
assert_eq!(ansi_b.display(), "ANSI B (11.00 × 17.00 in)");
let (w, h) = ansi_b.portrait_mm();
assert!((w - 279.4).abs() < 1e-9 && (h - 431.8).abs() < 1e-9);
assert_eq!(
PaperSize::custom(297.5, 210.0, PaperUnits::Millimeters).display(),
"UserDefinedMetric (210 × 297.5 mm)"
);
assert_eq!(default_paper().canonical, a4().canonical);
}
#[test]
fn user_defined_sheets_carry_their_name_and_margins() {
let roll = CustomPaper {
name: "Roll 24 (long)".into(),
width: 1500.0,
height: 609.6,
units: PaperUnits::Millimeters,
margins: Margins {
left: 5.0,
bottom: 17.0,
right: 6.0,
top: 18.0,
},
};
assert_eq!(roll.canonical(), "Roll_24_long_(609.60_x_1500.00_MM)");
assert_eq!(roll.paper().label, "Roll 24 long");
assert_eq!(roll.paper().display(), "Roll 24 long (609.6 × 1500 mm)");
// Round-trips through the parser like any the source application name.
assert_eq!(
resolve(&roll.canonical()).unwrap().portrait_mm(),
(609.6, 1500.0)
);
let arch = CustomPaper {
name: String::new(),
width: 30.0,
height: 42.0,
units: PaperUnits::Inches,
margins: Margins::uniform(0.25),
};
assert_eq!(arch.canonical(), "UserDefinedInches_(30.00_x_42.00_Inches)");
assert_eq!(arch.margins_mm(), Margins::uniform(6.35));
}
#[test]
fn fit_centers_and_scales_within_margin() {
// 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width:
// usable = 210/1.05 = 200; scale = 200/100 = 2.0.
let (s, ox, oy) = window_to_sheet((100.0, 100.0), (210.0, 297.0), PlotScale::Fit);
assert!((s - 2.0).abs() < 1e-9, "scale {s}");
// Window is 100*2 = 200 wide/tall; centered on 210×297.
assert!((ox - (210.0 - 200.0) / 2.0).abs() < 1e-9, "ox {ox}");
assert!((oy - (297.0 - 200.0) / 2.0).abs() < 1e-9, "oy {oy}");
}
#[test]
fn ratio_applies_exact_scale_and_centers() {
// Ratio(0.5): scale is exactly 0.5; the scaled window is centered and
// (ox, oy) is the sheet-mm position of its min corner.
let (s, ox, oy) = window_to_sheet((100.0, 80.0), (210.0, 297.0), PlotScale::Ratio(0.5));
assert!((s - 0.5).abs() < 1e-9);
// scaled window = 50×40; centered box origin = ((210-50)/2,(297-40)/2).
assert!((ox - (210.0 - 50.0) / 2.0).abs() < 1e-9, "ox {ox}");
assert!((oy - (297.0 - 40.0) / 2.0).abs() < 1e-9, "oy {oy}");
}
}