Merge pull request #1311 from ghost/plotting-station-a

This commit is contained in:
Hakan Seven committed 2026-09-17 06:53:00 +03:00
commit 1701e672dd
36 files changed
+3567 -425

No files matched your search

+2 -1
View File
@@ -20,7 +20,8 @@ pub mod xref_model;
pub mod linetypes;
pub mod patterns;
pub mod update_check;
pub mod paper_sizes;
pub mod paper_catalog;
pub mod plot_device;
pub mod thumbnail;
#[cfg(target_arch = "wasm32")]
mod web_worker;
+867
View File
@@ -0,0 +1,867 @@
//! 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}");
}
}
-143
View File
@@ -1,143 +0,0 @@
//! Standard paper sizes and sheet orientation for window plotting.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum PaperSize {
A4,
A3,
A2,
A1,
A0,
Letter,
Legal,
Tabloid,
}
impl PaperSize {
pub const ALL: [PaperSize; 8] = [
PaperSize::A4,
PaperSize::A3,
PaperSize::A2,
PaperSize::A1,
PaperSize::A0,
PaperSize::Letter,
PaperSize::Legal,
PaperSize::Tabloid,
];
pub fn label(self) -> &'static str {
match self {
PaperSize::A4 => "A4",
PaperSize::A3 => "A3",
PaperSize::A2 => "A2",
PaperSize::A1 => "A1",
PaperSize::A0 => "A0",
PaperSize::Letter => "Letter",
PaperSize::Legal => "Legal",
PaperSize::Tabloid => "Tabloid",
}
}
/// Portrait dimensions in mm (width, height); width < height.
pub fn dimensions_mm(self) -> (f64, f64) {
match self {
PaperSize::A4 => (210.0, 297.0),
PaperSize::A3 => (297.0, 420.0),
PaperSize::A2 => (420.0, 594.0),
PaperSize::A1 => (594.0, 841.0),
PaperSize::A0 => (841.0, 1189.0),
PaperSize::Letter => (215.9, 279.4),
PaperSize::Legal => (215.9, 355.6),
PaperSize::Tabloid => (279.4, 431.8),
}
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum Orientation {
Portrait,
Landscape,
}
/// Sheet dimensions in mm for the given size and orientation.
pub fn sheet_mm(size: PaperSize, o: Orientation) -> (f64, f64) {
let (w, h) = size.dimensions_mm();
match o {
Orientation::Portrait => (w, h),
Orientation::Landscape => (h, w),
}
}
#[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::*;
#[test]
fn paper_dimensions_and_orientation() {
assert_eq!(PaperSize::A4.dimensions_mm(), (210.0, 297.0));
assert_eq!(PaperSize::A0.dimensions_mm(), (841.0, 1189.0));
assert_eq!(PaperSize::Letter.dimensions_mm(), (215.9, 279.4));
assert_eq!(PaperSize::Legal.dimensions_mm(), (215.9, 355.6));
assert_eq!(PaperSize::Tabloid.dimensions_mm(), (279.4, 431.8));
assert_eq!(PaperSize::ALL.len(), 8);
assert_eq!(PaperSize::A3.label(), "A3");
// Portrait keeps (w,h); landscape swaps.
assert_eq!(sheet_mm(PaperSize::A4, Orientation::Portrait), (210.0, 297.0));
assert_eq!(sheet_mm(PaperSize::A4, Orientation::Landscape), (297.0, 210.0));
}
#[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}");
}
}
+599
View File
@@ -0,0 +1,599 @@
//! Plot devices: what a page setup names as its plotter, and what that device
//! can print.
//!
//! the source application stores the device in `PlotSettings::printer_name` as the plotter
//! configuration's name — `DWG To PDF.pc3` for its PDF driver, the Windows
//! printer's name for a system printer, `None` when nothing is assigned. A
//! drawing saved here has to use the same spelling so the source application attaches the
//! layout to its own PDF driver instead of reporting a missing device; and a
//! device name that arrives from the source application (an HP plotter's `.pc3`, `DWF6
//! ePlot.pc3`) must go back out untouched even though we cannot drive it.
//!
//! The device also decides the *printable area*: the sheet inset by the
//! driver's unprintable margins, which the source application writes into the layout and
//! draws as the dashed rectangle in paper space. The PDF driver's margins are
//! a fixed table; a CUPS printer reports its media and margins over IPP
//! (`media-col-database`), which is queried once per printer and cached.
pub use crate::io::paper_catalog::Margins;
use crate::io::paper_catalog::{self, CustomPaper, PaperSize, PaperUnits, PaperVariant};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
/// the source application's plotter configuration name for PDF output.
pub const PDF_DEVICE_NAME: &str = "DWG To PDF.pc3";
/// the source application's spelling for "no plotter assigned" in the plot dialog…
pub const NO_DEVICE_NAME: &str = "None";
/// …and the name it actually stores in the drawing for that choice.
const NO_DEVICE_STORED_NAME: &str = "none_device";
/// The label older Open CAD Studio builds wrote into drawings for PDF output.
/// Recognised on read so those drawings keep plotting to PDF, and always
/// rewritten with [`PDF_DEVICE_NAME`] on save.
const LEGACY_PDF_LABEL: &str = "Save to PDF file…";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlotDevice {
/// No plotter assigned (the source application `None`). We plot to the system default
/// printer in that case, which is what the plot dialog offers.
None,
/// PDF file output, the source application's `DWG To PDF.pc3`.
Pdf,
/// A system printer by its queue / spooler name.
Printer(String),
}
impl PlotDevice {
/// The name the source application would store for this device.
pub fn canonical_name(&self) -> String {
match self {
PlotDevice::None => NO_DEVICE_STORED_NAME.to_string(),
PlotDevice::Pdf => PDF_DEVICE_NAME.to_string(),
PlotDevice::Printer(name) => name.clone(),
}
}
/// Interpret a stored `printer_name`. Returns the device and whether the
/// stored spelling should be preserved on save: an the source application plotter
/// configuration we cannot drive (`DWF6 ePlot.pc3`, an HP `.pc3`) is
/// treated as PDF output but keeps its name, so the drawing still attaches
/// to that plotter in the source application; our own legacy label and an empty name are
/// not worth preserving.
pub fn from_stored_name(name: &str) -> (Self, bool) {
let name = name.trim();
if name.is_empty() || name.eq_ignore_ascii_case(NO_DEVICE_NAME) {
return (PlotDevice::None, false);
}
if name.eq_ignore_ascii_case(NO_DEVICE_STORED_NAME) {
// the source application's own spelling: keep it rather than rewriting it.
return (PlotDevice::None, true);
}
if name == LEGACY_PDF_LABEL {
return (PlotDevice::Pdf, false);
}
let lower = name.to_ascii_lowercase();
if lower.ends_with(".pc3") {
// Any the source application plotter configuration: PDF ones are ours to drive,
// the rest plot to PDF here but keep their name for the source application.
return (PlotDevice::Pdf, true);
}
(PlotDevice::Printer(name.to_string()), true)
}
/// Unprintable margins the device imposes on `paper`, in millimetres.
/// A sheet the user defined carries its own margins for every device,
/// the way a custom size in a plotter configuration does; a printer that
/// reports the same size still wins, since it knows its hardware.
pub fn margins_mm(&self, paper: &PaperSize, custom: &[CustomPaper]) -> Margins {
let user_defined = custom
.iter()
.find(|c| c.canonical() == paper.canonical)
.map(CustomPaper::margins_mm);
match self {
PlotDevice::Pdf => user_defined.unwrap_or_else(|| pdf_margins_mm(paper)),
PlotDevice::Printer(name) => cached_printer_capabilities(name)
.and_then(|caps| caps.margins_for(paper))
.or(user_defined)
.unwrap_or(Margins::ZERO),
// No device: the source application keeps the margins of the last device or its
// defaults; without one we show the full sheet as printable.
PlotDevice::None => user_defined.unwrap_or(Margins::ZERO),
}
}
}
// ── PDF driver ────────────────────────────────────────────────────────────
/// Unprintable margins of the source application's `DWG To PDF.pc3` sheets, as its Plotter
/// Configuration Editor lists them (Modify Standard Paper Sizes). They depend
/// on the sheet family and variant, not on the individual size: every ISO
/// sheet shares one set in millimetres, every ANSI / ARCH sheet one set in
/// inches. Order inside each set is left, bottom, right, top.
const PDF_MM_STANDARD: Margins = Margins {
left: 5.0,
bottom: 17.0,
right: 6.0,
top: 18.0,
};
const PDF_MM_EXPAND: Margins = Margins {
left: 5.0,
bottom: 10.0,
right: 6.0,
top: 11.0,
};
const PDF_MM_FULL_BLEED: Margins = Margins {
left: 0.0,
bottom: 1.0,
right: 0.0,
top: 1.0,
};
const PDF_IN_STANDARD: Margins = Margins {
left: 0.23,
bottom: 0.70,
right: 0.23,
top: 0.70,
};
const PDF_IN_EXPAND: Margins = Margins {
left: 0.23,
bottom: 0.42,
right: 0.23,
top: 0.42,
};
const PDF_IN_FULL_BLEED: Margins = Margins::uniform(0.03);
fn pdf_margins_mm(paper: &PaperSize) -> Margins {
let set = match (paper.units, paper.variant) {
(PaperUnits::Millimeters, PaperVariant::Standard) => PDF_MM_STANDARD,
(PaperUnits::Millimeters, PaperVariant::Expand) => PDF_MM_EXPAND,
(PaperUnits::Millimeters, PaperVariant::FullBleed) => PDF_MM_FULL_BLEED,
(PaperUnits::Inches, PaperVariant::Standard) => PDF_IN_STANDARD,
(PaperUnits::Inches, PaperVariant::Expand) => PDF_IN_EXPAND,
(PaperUnits::Inches, PaperVariant::FullBleed) => PDF_IN_FULL_BLEED,
};
set.to_mm(paper.units)
}
// ── System printers ───────────────────────────────────────────────────────
/// One sheet a printer reports it can load, with its unprintable margins.
#[derive(Clone, Debug, PartialEq)]
pub struct PrinterMedia {
/// The sheet as our catalogue knows it (matched by size), or a custom
/// sheet built from the reported dimensions.
pub paper: PaperSize,
pub margins: Margins,
/// `true` when the printer also reports a borderless variant of this size.
pub borderless: bool,
}
/// What a printer reported about its media, cached for the session.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PrinterCapabilities {
/// Sheets in the printer's own order, one entry per distinct size.
pub media: Vec<PrinterMedia>,
/// The printer's default sheet, when reported.
pub default_paper: Option<PaperSize>,
}
impl PrinterCapabilities {
/// Margins the printer reports for `paper`, matched by size. `None` when
/// the printer never mentioned a sheet of that size.
pub fn margins_for(&self, paper: &PaperSize) -> Option<Margins> {
let (w, h) = paper.portrait_mm();
self.media
.iter()
.find(|media| {
let (mw, mh) = media.paper.portrait_mm();
(mw - w).abs() <= 0.5 && (mh - h).abs() <= 0.5
})
.map(|media| media.margins)
}
}
fn capability_cache() -> &'static Mutex<HashMap<String, Arc<PrinterCapabilities>>> {
static CACHE: OnceLock<Mutex<HashMap<String, Arc<PrinterCapabilities>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// The cached capabilities of `printer`, if they were queried already.
pub fn cached_printer_capabilities(printer: &str) -> Option<Arc<PrinterCapabilities>> {
capability_cache().lock().ok()?.get(printer).cloned()
}
/// Query `printer` for its media (blocking, one process spawn) and cache the
/// answer. Returns `None` when the platform cannot answer — the plot dialog
/// then falls back to the paper catalogue with unverified margins.
pub fn printer_capabilities(printer: &str) -> Option<Arc<PrinterCapabilities>> {
if let Some(cached) = cached_printer_capabilities(printer) {
return Some(cached);
}
let caps = Arc::new(query_printer_capabilities(printer)?);
if let Ok(mut cache) = capability_cache().lock() {
cache.insert(printer.to_string(), Arc::clone(&caps));
}
Some(caps)
}
/// Name of the system default printer, when the platform reports one.
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
pub fn default_printer_name() -> Option<String> {
// `lpstat -d` → "system default destination: NAME" (the prefix is
// localized, the name follows the last colon).
let output = std::process::Command::new("lpstat")
.arg("-d")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let line = text.lines().find(|line| line.contains(':'))?;
let name = line.rsplit(':').next()?.trim();
(!name.is_empty()).then(|| name.to_string())
}
/// Windows default printer discovery lands with native GDI printing.
#[cfg(any(target_arch = "wasm32", target_os = "windows"))]
pub fn default_printer_name() -> Option<String> {
None
}
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
fn query_printer_capabilities(printer: &str) -> Option<PrinterCapabilities> {
// cupsd answers for the queue itself, so this is quick even when the
// printer is off; `ipptool` is part of every CUPS install that has
// `lpstat`. The standard test file asks for every attribute; the two we
// read are `media-col-database` and `media-default`.
let uri = format!("ipp://localhost/printers/{}", printer.replace(' ', "%20"));
let output = std::process::Command::new("ipptool")
.args(["-tv", &uri, "get-printer-attributes.test"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_ipp_media(&String::from_utf8_lossy(&output.stdout))
}
/// Windows media enumeration (`DeviceCapabilities`) lands with native GDI
/// printing; until then the catalogue serves every printer.
#[cfg(any(target_arch = "wasm32", target_os = "windows"))]
fn query_printer_capabilities(_printer: &str) -> Option<PrinterCapabilities> {
None
}
/// Read the media a printer reports from `ipptool -tv` output.
///
/// The relevant lines look like
/// `media-col-database (1setOf collection) = {media-size={x-dimension=21000
/// y-dimension=29700} media-bottom-margin=300 media-left-margin=300 …},{…}`
/// with sizes and margins in hundredths of a millimetre, and
/// `media-default (keyword) = iso_a4_210x297mm`. Borderless variants repeat a
/// size with zero margins; they collapse onto the bordered entry.
fn parse_ipp_media(text: &str) -> Option<PrinterCapabilities> {
let database = attribute_value(text, "media-col-database")?;
let mut media: Vec<PrinterMedia> = Vec::new();
for entry in database.split("},{") {
let Some((w, h)) = collection_size_mm(entry) else {
continue;
};
let margins = Margins {
left: hundredths_mm(entry, "media-left-margin"),
bottom: hundredths_mm(entry, "media-bottom-margin"),
right: hundredths_mm(entry, "media-right-margin"),
top: hundredths_mm(entry, "media-top-margin"),
};
let borderless = margins == Margins::ZERO;
if let Some(existing) = media.iter_mut().find(|m| {
let (mw, mh) = m.paper.portrait_mm();
let (pw, ph) = portrait(w, h);
(mw - pw).abs() <= 0.5 && (mh - ph).abs() <= 0.5
}) {
// Keep the bordered margins as the sheet's printable area and
// remember that edge-to-edge is possible too.
if borderless {
existing.borderless = true;
} else if existing.margins == Margins::ZERO {
existing.margins = margins;
existing.borderless = true;
}
continue;
}
let (pw, ph) = portrait(w, h);
let paper = paper_catalog::match_dimensions_mm(pw, ph)
.cloned()
.unwrap_or_else(|| PaperSize::custom(pw, ph, PaperUnits::Millimeters));
media.push(PrinterMedia {
paper,
margins,
borderless,
});
}
if media.is_empty() {
return None;
}
let default_paper = attribute_value(text, "media-default")
.and_then(|name| pwg_size_mm(name.trim()))
.and_then(|(w, h)| {
let (w, h) = portrait(w, h);
media
.iter()
.find(|m| {
let (mw, mh) = m.paper.portrait_mm();
(mw - w).abs() <= 0.5 && (mh - h).abs() <= 0.5
})
.map(|m| m.paper.clone())
});
Some(PrinterCapabilities {
media,
default_paper,
})
}
/// The text after `name (…) = ` on the line that declares `name`.
fn attribute_value<'a>(text: &'a str, name: &str) -> Option<&'a str> {
text.lines().find_map(|line| {
let line = line.trim_start();
let rest = line.strip_prefix(name)?;
// The attribute name is followed by its syntax in parentheses.
if !rest.starts_with(' ') && !rest.starts_with('(') {
return None;
}
let (_, value) = rest.split_once('=')?;
Some(value.trim())
})
}
fn collection_size_mm(entry: &str) -> Option<(f64, f64)> {
let x = hundredths(entry, "x-dimension")?;
let y = hundredths(entry, "y-dimension")?;
(x > 0.0 && y > 0.0).then_some((x, y))
}
fn hundredths(entry: &str, key: &str) -> Option<f64> {
let start = entry.find(key)? + key.len();
let rest = entry[start..].trim_start_matches('=');
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse::<f64>().ok().map(|v| v / 100.0)
}
fn hundredths_mm(entry: &str, key: &str) -> f64 {
hundredths(entry, key).unwrap_or(0.0)
}
fn portrait(w: f64, h: f64) -> (f64, f64) {
if w <= h {
(w, h)
} else {
(h, w)
}
}
/// Dimensions from a PWG self-describing media name such as
/// `iso_a4_210x297mm` or `na_letter_8.5x11in`.
fn pwg_size_mm(name: &str) -> Option<(f64, f64)> {
let dims = name.rsplit('_').next()?;
let (dims, units) = dims
.strip_suffix("mm")
.map(|d| (d, PaperUnits::Millimeters))
.or_else(|| dims.strip_suffix("in").map(|d| (d, PaperUnits::Inches)))?;
let (w, h) = dims.split_once('x')?;
let w: f64 = w.parse().ok()?;
let h: f64 = h.parse().ok()?;
Some((units.to_mm(w), units.to_mm(h)))
}
#[cfg(test)]
mod tests {
use super::*;
const IPP_SAMPLE: &str = " media-default (keyword) = iso_a4_210x297mm\n\
media-supported (1setOf keyword) = iso_a4_210x297mm,om_a-4.borderless_210x297mm,na_letter_8.5x11in,custom_215x345mm_215x345mm\n\
media-col-database (1setOf collection) = {media-size={x-dimension=21000 y-dimension=29700} media-bottom-margin=300 media-left-margin=300 media-right-margin=300 media-top-margin=300},{media-size={x-dimension=21000 y-dimension=29700} media-bottom-margin=0 media-left-margin=0 media-right-margin=0 media-top-margin=0},{media-size={x-dimension=21590 y-dimension=27940} media-bottom-margin=635 media-left-margin=635 media-right-margin=635 media-top-margin=635},{media-size={x-dimension=21500 y-dimension=34500} media-bottom-margin=300 media-left-margin=300 media-right-margin=300 media-top-margin=300}\n";
#[test]
fn canonical_names_follow_source_app() {
assert_eq!(PlotDevice::Pdf.canonical_name(), "DWG To PDF.pc3");
// What the source application writes for "None" in its plot dialog.
assert_eq!(PlotDevice::None.canonical_name(), "none_device");
assert_eq!(
PlotDevice::Printer("L3270".into()).canonical_name(),
"L3270"
);
}
#[test]
fn stored_names_resolve_and_say_whether_to_keep_them() {
assert_eq!(PlotDevice::from_stored_name(""), (PlotDevice::None, false));
assert_eq!(
PlotDevice::from_stored_name("None"),
(PlotDevice::None, false)
);
assert_eq!(
PlotDevice::from_stored_name("none_device"),
(PlotDevice::None, true)
);
assert_eq!(
PlotDevice::from_stored_name("NONE_DEVICE"),
(PlotDevice::None, true)
);
assert_eq!(
PlotDevice::from_stored_name("Save to PDF file…"),
(PlotDevice::Pdf, false)
);
assert_eq!(
PlotDevice::from_stored_name("DWG To PDF.pc3"),
(PlotDevice::Pdf, true)
);
assert_eq!(
PlotDevice::from_stored_name("the source application PDF (High Quality Print).pc3"),
(PlotDevice::Pdf, true)
);
// A plotter we cannot drive plots to PDF here but keeps its name.
assert_eq!(
PlotDevice::from_stored_name("DWF6 ePlot.pc3"),
(PlotDevice::Pdf, true)
);
assert_eq!(
PlotDevice::from_stored_name("HP LaserJet 4"),
(PlotDevice::Printer("HP LaserJet 4".into()), true)
);
}
#[test]
fn pdf_margins_follow_source_app_per_family_and_variant() {
let m =
|name: &str| PlotDevice::Pdf.margins_mm(&paper_catalog::resolve(name).unwrap(), &[]);
// ISO sheets share one millimetre set regardless of size.
let iso_standard = Margins {
left: 5.0,
bottom: 17.0,
right: 6.0,
top: 18.0,
};
assert_eq!(m("ISO_A4_(210.00_x_297.00_MM)"), iso_standard);
assert_eq!(m("ISO_A0_(841.00_x_1189.00_MM)"), iso_standard);
assert_eq!(
m("ISO_expand_A4_(210.00_x_297.00_MM)"),
Margins {
left: 5.0,
bottom: 10.0,
right: 6.0,
top: 11.0
}
);
assert_eq!(
m("ISO_full_bleed_A4_(210.00_x_297.00_MM)"),
Margins {
left: 0.0,
bottom: 1.0,
right: 0.0,
top: 1.0
}
);
// Inch sheets are listed in inches and stored in millimetres.
let close = |a: Margins, b: Margins| {
(a.left - b.left).abs() < 1e-9
&& (a.bottom - b.bottom).abs() < 1e-9
&& (a.right - b.right).abs() < 1e-9
&& (a.top - b.top).abs() < 1e-9
};
let inch = |l: f64, b: f64, r: f64, t: f64| Margins {
left: l * 25.4,
bottom: b * 25.4,
right: r * 25.4,
top: t * 25.4,
};
assert!(close(
m("ANSI_A_(8.50_x_11.00_Inches)"),
inch(0.23, 0.70, 0.23, 0.70)
));
assert!(close(
m("ARCH_D_(24.00_x_36.00_Inches)"),
inch(0.23, 0.70, 0.23, 0.70)
));
assert!(close(
m("ARCH_expand_D_(24.00_x_36.00_Inches)"),
inch(0.23, 0.42, 0.23, 0.42)
));
assert!(close(
m("ANSI_full_bleed_B_(11.00_x_17.00_Inches)"),
inch(0.03, 0.03, 0.03, 0.03)
));
assert_eq!(
PlotDevice::None.margins_mm(
&paper_catalog::resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(),
&[]
),
Margins::ZERO
);
}
#[test]
fn user_defined_sheets_bring_their_own_margins() {
let roll = CustomPaper {
name: "Roll".into(),
width: 900.0,
height: 1200.0,
units: PaperUnits::Millimeters,
margins: Margins::uniform(3.0),
};
let paper = roll.paper();
assert_eq!(
PlotDevice::Pdf.margins_mm(&paper, &[roll.clone()]),
Margins::uniform(3.0)
);
assert_eq!(
PlotDevice::None.margins_mm(&paper, &[roll.clone()]),
Margins::uniform(3.0)
);
// An unknown printer falls back to the user's margins too.
assert_eq!(
PlotDevice::Printer("Nowhere".into()).margins_mm(&paper, &[roll]),
Margins::uniform(3.0)
);
// Without a definition a custom-looking sheet gets the family set.
assert_eq!(
PlotDevice::Pdf.margins_mm(&paper, &[]),
Margins {
left: 5.0,
bottom: 17.0,
right: 6.0,
top: 18.0
}
);
}
#[test]
fn ipp_media_parses_sizes_margins_borderless_and_default() {
let caps = parse_ipp_media(IPP_SAMPLE).expect("media parsed");
assert_eq!(
caps.media.len(),
3,
"borderless A4 folds into the bordered entry"
);
let a4 = &caps.media[0];
assert_eq!(a4.paper.canonical, "ISO_A4_(210.00_x_297.00_MM)");
assert_eq!(a4.margins, Margins::uniform(3.0));
assert!(a4.borderless);
let letter = &caps.media[1];
assert_eq!(letter.paper.canonical, "ANSI_A_(8.50_x_11.00_Inches)");
assert_eq!(letter.margins, Margins::uniform(6.35));
assert!(!letter.borderless);
let custom = &caps.media[2];
assert_eq!(
custom.paper.canonical,
"UserDefinedMetric_(215.00_x_345.00_MM)"
);
assert_eq!(
caps.default_paper.as_ref().map(|p| p.canonical.as_ref()),
Some("ISO_A4_(210.00_x_297.00_MM)")
);
// Margin lookup by sheet size, either orientation, tolerant of the
// catalogue's inch → mm rounding.
let ansi_a = paper_catalog::resolve("Letter_(8.50_x_11.00_Inches)").unwrap();
assert_eq!(caps.margins_for(&ansi_a), Some(Margins::uniform(6.35)));
let a3 = paper_catalog::resolve("ISO_A3_(297.00_x_420.00_MM)").unwrap();
assert_eq!(caps.margins_for(&a3), None);
}
#[test]
fn ipp_output_without_media_is_rejected() {
assert!(parse_ipp_media("printer-state (enum) = 3\n").is_none());
assert!(parse_ipp_media(
"media-col-database (1setOf collection) = {media-size={x-dimension=0 y-dimension=0}}\n"
)
.is_none());
}
#[test]
fn pwg_names_carry_their_size() {
assert_eq!(pwg_size_mm("iso_a4_210x297mm"), Some((210.0, 297.0)));
let (w, h) = pwg_size_mm("na_letter_8.5x11in").unwrap();
assert!((w - 215.9).abs() < 1e-9 && (h - 279.4).abs() < 1e-9);
assert_eq!(
pwg_size_mm("custom_215x345mm_215x345mm"),
Some((215.0, 345.0))
);
assert_eq!(pwg_size_mm("iso_a4"), None);
}
}
+306 -2
View File
@@ -47,6 +47,12 @@ pub struct PrintOptions {
/// setting wins, so the field is legitimately unread there.
#[cfg_attr(target_os = "windows", allow(dead_code))]
pub quality: Option<String>,
/// Driver options chosen in the printer-properties editor, as CUPS
/// `key=value` pairs (`ColorModel=Gray`, `MediaType=Photographic`). Read
/// on the CUPS path (`lp -o`); Windows keeps such choices in the
/// printer's own preferences instead.
#[cfg_attr(target_os = "windows", allow(dead_code))]
pub driver_options: Vec<(String, String)>,
}
#[cfg(target_arch = "wasm32")]
@@ -238,13 +244,16 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec<Strin
.map(str::trim)
.filter(|name| !name.is_empty());
// `/e` opens the driver's *printing preferences* (paper, tray, duplex,
// quality — what a print job honours), not the device's admin properties
// sheet that `/p` shows.
#[cfg(target_os = "windows")]
let command = match named {
Some(name) => (
"rundll32.exe",
vec![
"printui.dll,PrintUIEntry".to_string(),
"/p".to_string(),
"/e".to_string(),
"/n".to_string(),
name.to_string(),
],
@@ -263,6 +272,7 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec<Strin
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
let command = {
// Reached only outside Linux; Linux opens the desktop's own panel.
let target = named
.map(|name| format!("http://localhost:631/printers/{name}"))
.unwrap_or_else(|| "http://localhost:631/printers".to_string());
@@ -275,6 +285,11 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec<Strin
/// Open the operating system's printer configuration surface.
#[cfg(not(target_arch = "wasm32"))]
pub fn open_printer_properties(printer: Option<&str>) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
return open_desktop_printer_settings(printer);
}
#[allow(unreachable_code)]
let (program, args) = printer_properties_command(printer);
std::process::Command::new(program)
.args(args)
@@ -406,6 +421,10 @@ fn dispatch_to_printer_opts(
};
cmd.arg("-o").arg(format!("print-quality={pq}"));
}
// The driver's own options, exactly as the PPD names them.
for (key, value) in &opts.driver_options {
cmd.arg("-o").arg(format!("{key}={value}"));
}
let lp_result = cmd
.arg("--")
.arg(path_str.as_ref())
@@ -468,7 +487,7 @@ mod printer_properties_tests {
"rundll32.exe",
vec![
"printui.dll,PrintUIEntry".to_string(),
"/p".to_string(),
"/e".to_string(),
"/n".to_string(),
"Office LaserJet".to_string(),
],
@@ -523,3 +542,288 @@ mod printer_properties_tests {
);
}
}
// ── Driver options (CUPS PPD) ─────────────────────────────────────────────
/// One option a CUPS printer's driver exposes, as `lpoptions -l` lists it:
/// the PPD keyword, its description, the choices, and the current default.
#[derive(Clone, Debug, PartialEq)]
pub struct PrinterOption {
/// PPD keyword, e.g. `MediaType`, passed back verbatim as `-o key=value`.
pub key: String,
/// The driver's description, e.g. `Media Type`.
pub label: String,
/// Keyword of the currently selected choice.
pub default: String,
pub choices: Vec<PrinterChoice>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PrinterChoice {
/// PPD choice keyword, e.g. `PhotographicHighGloss`.
pub keyword: String,
/// Readable form of the keyword, e.g. `Photographic High Gloss`.
pub label: String,
}
impl PrinterOption {
pub fn label_for(&self, keyword: &str) -> String {
self.choices
.iter()
.find(|choice| choice.keyword == keyword)
.map(|choice| choice.label.clone())
.unwrap_or_else(|| humanize_keyword(keyword))
}
}
/// Ask CUPS for the options of `printer` (blocking, one `lpoptions` spawn).
/// The sheet (`PageSize`) is left out: the plot dialog chooses it.
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
pub fn printer_options(printer: &str) -> Result<Vec<PrinterOption>, String> {
let output = std::process::Command::new("lpoptions")
.args(["-p", printer, "-l"])
.output()
.map_err(|error| format!("lpoptions: {error}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
Ok(parse_lpoptions(&String::from_utf8_lossy(&output.stdout)))
}
/// Windows drivers keep their options behind `DocumentProperties`; there is
/// no list to show in-line.
#[cfg(any(target_arch = "wasm32", target_os = "windows"))]
pub fn printer_options(_printer: &str) -> Result<Vec<PrinterOption>, String> {
Ok(Vec::new())
}
/// Parse `lpoptions -l` output: one option per line as
/// `Key/Description: choice *default choice …`, the asterisk marking the
/// current default. Numeric-range options print the same way and are kept.
pub fn parse_lpoptions(text: &str) -> Vec<PrinterOption> {
let mut options = Vec::new();
for line in text.lines() {
let Some((head, tail)) = line.split_once(':') else {
continue;
};
let (key, label) = head
.split_once('/')
.map(|(key, label)| (key.trim(), label.trim()))
.unwrap_or((head.trim(), head.trim()));
if key.is_empty() || key.eq_ignore_ascii_case("PageSize") {
continue;
}
let mut default = String::new();
let choices: Vec<PrinterChoice> = tail
.split_whitespace()
.map(|token| {
let (keyword, is_default) = match token.strip_prefix('*') {
Some(keyword) => (keyword, true),
None => (token, false),
};
if is_default {
default = keyword.to_string();
}
PrinterChoice {
keyword: keyword.to_string(),
label: humanize_keyword(keyword),
}
})
.collect();
if choices.is_empty() {
continue;
}
if default.is_empty() {
default = choices[0].keyword.clone();
}
options.push(PrinterOption {
key: key.to_string(),
label: if label.is_empty() { key.to_string() } else { label.to_string() },
default,
choices,
});
}
options
}
/// `PhotographicHighGloss` → `Photographic High Gloss`, `DuplexNoTumble` →
/// `Duplex No Tumble`; keywords that are already words or numbers pass
/// through. Consecutive capitals (`RGB`, `A4`) stay together.
pub fn humanize_keyword(keyword: &str) -> String {
let mut out = String::with_capacity(keyword.len() + 4);
let chars: Vec<char> = keyword.chars().collect();
for (index, &ch) in chars.iter().enumerate() {
if index > 0 && ch.is_ascii_uppercase() {
let previous = chars[index - 1];
let next_lower = chars.get(index + 1).is_some_and(|c| c.is_ascii_lowercase());
if previous.is_ascii_lowercase() || (previous.is_ascii_uppercase() && next_lower) {
out.push(' ');
}
}
out.push(ch);
}
out
}
/// The desktop environment's printer settings panel — where the driver
/// options cannot be listed (no CUPS) the user still gets somewhere useful.
/// Tries the panel of the running desktop first, then the
/// distribution-neutral tools, and only then the CUPS web interface.
#[cfg(target_os = "linux")]
pub fn open_desktop_printer_settings(printer: Option<&str>) -> Result<(), String> {
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_lowercase();
let mut candidates: Vec<(&str, Vec<String>)> = Vec::new();
if desktop.contains("gnome") || desktop.contains("ubuntu") || desktop.contains("cinnamon") {
candidates.push(("gnome-control-center", vec!["printers".into()]));
}
if desktop.contains("kde") || desktop.contains("plasma") {
candidates.push(("kcmshell6", vec!["kcm_printer_manager".into()]));
candidates.push(("kcmshell5", vec!["kcm_printer_manager".into()]));
}
candidates.push(("system-config-printer", Vec::new()));
candidates.push(("gnome-control-center", vec!["printers".into()]));
for (program, args) in candidates {
if std::process::Command::new(program)
.args(&args)
.spawn()
.is_ok()
{
return Ok(());
}
}
let target = printer
.map(|name| format!("http://localhost:631/printers/{name}"))
.unwrap_or_else(|| "http://localhost:631/printers".to_string());
std::process::Command::new("xdg-open")
.arg(target)
.spawn()
.map(|_| ())
.map_err(|error| format!("Could not open printer settings: {error}"))
}
/// Show the driver's own document-properties sheet for `printer`, owned by
/// the window `owner` (an `HWND`, 0 for none), and on OK store the result as
/// the user's printing preferences for that printer — which is what the
/// print job then honours. Returns `Ok(false)` when the user cancelled.
#[cfg(target_os = "windows")]
pub fn edit_printer_preferences(printer: &str, owner: isize) -> Result<bool, String> {
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Graphics::Gdi::DEVMODEW;
use windows_sys::Win32::Graphics::Printing::{
ClosePrinter, DocumentPropertiesW, OpenPrinterW, SetPrinterW, PRINTER_INFO_9W,
};
// winspool.h: DM_OUT_BUFFER = DM_COPY, DM_IN_PROMPT = DM_PROMPT,
// DM_IN_BUFFER = DM_MODIFY.
const DM_OUT_BUFFER: u32 = 2;
const DM_IN_PROMPT: u32 = 4;
const DM_IN_BUFFER: u32 = 8;
const IDOK: i32 = 1;
let name: Vec<u16> = printer.encode_utf16().chain(std::iter::once(0)).collect();
let mut handle: HANDLE = std::ptr::null_mut();
// SAFETY: `name` is NUL-terminated and outlives the call; a null default
// asks for PRINTER_ACCESS_USE, enough for per-user preferences.
if unsafe { OpenPrinterW(name.as_ptr(), &mut handle, std::ptr::null()) } == 0 {
return Err(format!("Could not open printer \"{printer}\""));
}
let owner = owner as windows_sys::Win32::Foundation::HWND;
let outcome = (|| {
// SAFETY: with null buffers the call only reports the DEVMODE size.
let size = unsafe {
DocumentPropertiesW(owner, handle, name.as_ptr(), std::ptr::null_mut(), std::ptr::null(), 0)
};
if size <= 0 {
return Err(format!("Printer \"{printer}\" reports no document properties"));
}
// DEVMODEW is u16-aligned; a u16 buffer keeps the cast sound.
let words = (size as usize).div_ceil(2);
let mut current = vec![0u16; words];
let mut edited = vec![0u16; words];
// SAFETY: both buffers hold `size` bytes as the driver requested.
let fetched = unsafe {
DocumentPropertiesW(
owner,
handle,
name.as_ptr(),
current.as_mut_ptr().cast::<DEVMODEW>(),
std::ptr::null(),
DM_OUT_BUFFER,
)
};
if fetched < 0 {
return Err(format!("Could not read the preferences of \"{printer}\""));
}
edited.copy_from_slice(&current);
// SAFETY: as above; DM_IN_PROMPT runs the driver's modal sheet.
let response = unsafe {
DocumentPropertiesW(
owner,
handle,
name.as_ptr(),
edited.as_mut_ptr().cast::<DEVMODEW>(),
current.as_ptr().cast::<DEVMODEW>(),
DM_IN_BUFFER | DM_IN_PROMPT | DM_OUT_BUFFER,
)
};
if response != IDOK {
return Ok(false);
}
let info = PRINTER_INFO_9W {
pDevMode: edited.as_mut_ptr().cast::<DEVMODEW>(),
};
// SAFETY: level 9 takes a PRINTER_INFO_9W; the DEVMODE outlives the call.
if unsafe { SetPrinterW(handle, 9, (&info as *const PRINTER_INFO_9W).cast::<u8>(), 0) } == 0 {
return Err(format!("Could not save the preferences of \"{printer}\""));
}
Ok(true)
})();
// SAFETY: `handle` came from OpenPrinterW above.
unsafe { ClosePrinter(handle) };
outcome
}
#[cfg(test)]
mod driver_option_tests {
use super::*;
const LPOPTIONS: &str = "PageSize/Media Size: 101.6x180.6mm *A4 A4.Borderless A5\n\
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic Envelope\n\
ColorModel/Print Color Mode: *RGB Gray AutoGray ProcessGray\n\
Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\
cupsPrintQuality/Print Quality: *Normal High\n\
print-scaling/Print Scaling: auto auto-fit fill *fit none\n\
Broken line without colon\n";
#[test]
fn lpoptions_output_becomes_options_with_defaults() {
let options = parse_lpoptions(LPOPTIONS);
let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect();
// PageSize is the plot dialog's business; everything else is listed.
assert_eq!(
keys,
["MediaType", "ColorModel", "Duplex", "cupsPrintQuality", "print-scaling"]
);
let media = &options[0];
assert_eq!(media.label, "Media Type");
assert_eq!(media.default, "Stationery");
assert_eq!(media.choices[1].keyword, "PhotographicHighGloss");
assert_eq!(media.choices[1].label, "Photographic High Gloss");
assert_eq!(media.label_for("Envelope"), "Envelope");
let scaling = &options[4];
assert_eq!(scaling.default, "fit");
assert_eq!(scaling.choices.len(), 5);
}
#[test]
fn keywords_read_as_words() {
assert_eq!(humanize_keyword("DuplexNoTumble"), "Duplex No Tumble");
assert_eq!(humanize_keyword("RGB"), "RGB");
assert_eq!(humanize_keyword("AutoGray"), "Auto Gray");
assert_eq!(humanize_keyword("ProcessGray"), "Process Gray");
assert_eq!(humanize_keyword("auto-fit"), "auto-fit");
assert_eq!(humanize_keyword("PhotographicSemiGloss"), "Photographic Semi Gloss");
assert_eq!(humanize_keyword("A4"), "A4");
}
}