diff --git a/src/app/mod.rs b/src/app/mod.rs index a46a17382..3a3107d67 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -943,8 +943,10 @@ pub(super) struct OpenCADStudio { mtext_click_count: u8, /// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None. plot_window: Option<(f64, f64, f64, f64)>, - plot_format: crate::io::paper_sizes::PaperSize, - plot_orientation: crate::io::paper_sizes::Orientation, + /// Sheet the Plot dialog opens with when the layout has no page setup; + /// updated from every plot / apply so the next dialog remembers it. + plot_paper: crate::io::paper_catalog::PaperSize, + plot_orientation: crate::io::paper_catalog::Orientation, /// Backing state for the full Plot / Print dialog. plot_dialog: crate::ui::window::plot::PlotDialogState, /// Snapshot of the dialog's settings taken when it opened, restored by the @@ -3317,10 +3319,6 @@ pub enum Message { PlotExport, /// Callback after the user picks (or cancels) the export path. PlotExportPath(Option), - /// User picked a paper size for the model-space window plot. - PlotFormat(crate::io::paper_sizes::PaperSize), - /// User picked a sheet orientation for the model-space window plot. - PlotOrientation(crate::io::paper_sizes::Orientation), /// Export the pending model-space plot window (from PLOTWINDOW) to PDF. PlotWindowExport, /// Callback after the user picks (or cancels) the window-export path. @@ -3944,8 +3942,8 @@ impl OpenCADStudio { mtext_click_off: 0, mtext_click_count: 0, plot_window: None, - plot_format: crate::io::paper_sizes::PaperSize::A4, - plot_orientation: crate::io::paper_sizes::Orientation::Landscape, + plot_paper: crate::io::paper_catalog::default_paper().clone(), + plot_orientation: crate::io::paper_catalog::Orientation::Landscape, plot_dialog: crate::ui::window::plot::PlotDialogState::default(), plot_prev: None, plot_setup_template: None, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 6d20f0430..4cb7e81e2 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -88,32 +88,60 @@ fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool { use crate::io::pdf_export::{PdfPageInput, PlotContent}; -fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) { - use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; - let orientation = if d.orientation == "Portrait" { +/// The dialog's sheet orientation as a catalogue value. +fn plot_dialog_orientation( + d: &crate::ui::window::plot::PlotDialogState, +) -> crate::io::paper_catalog::Orientation { + use crate::io::paper_catalog::Orientation; + if d.orientation == "Portrait" { Orientation::Portrait } else { Orientation::Landscape - }; - let standard = match d.paper.as_str() { - "A3" => Some(PaperSize::A3), - "A2" => Some(PaperSize::A2), - "A1" => Some(PaperSize::A1), - "A0" => Some(PaperSize::A0), - "A4" => Some(PaperSize::A4), - "Letter" => Some(PaperSize::Letter), - "Legal" => Some(PaperSize::Legal), - "Tabloid" => Some(PaperSize::Tabloid), - _ => None, - }; - if let Some(paper) = standard { - return sheet_mm(paper, orientation); } - let short = d.paper_width_mm.min(d.paper_height_mm).max(1.0); - let long = d.paper_width_mm.max(d.paper_height_mm).max(1.0); - match orientation { - Orientation::Portrait => (short, long), - Orientation::Landscape => (long, short), +} + +/// The sheet the dialog names, or — when the name carries no size (an old +/// config, a bare driver name) — a custom sheet built from the dialog's stored +/// dimensions, so a drawing whose paper we cannot identify still plots at its +/// own size. +fn plot_dialog_paper( + d: &crate::ui::window::plot::PlotDialogState, +) -> crate::io::paper_catalog::PaperSize { + use crate::io::paper_catalog::{from_drawing, resolve, PaperSize, PaperUnits}; + if d.paper_width_mm > 0.0 && d.paper_height_mm > 0.0 { + from_drawing(&d.paper, d.paper_width_mm, d.paper_height_mm) + } else { + resolve(&d.paper) + .unwrap_or_else(|| PaperSize::custom(210.0, 297.0, PaperUnits::Millimeters)) + } +} + +fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) { + plot_dialog_paper(d).sheet_mm(plot_dialog_orientation(d)) +} + +/// The media name to store for the dialog's sheet. A drawing keeps its own +/// spelling of a sheet the user did not change — a system printer's `A4` or a +/// plotter's custom name must not turn into the catalogue's canonical name +/// just by passing through the dialog, or AutoCAD would look the sheet up +/// under a name that driver never reported. Only a genuinely different +/// selection writes the canonical name. +fn paper_name_for_settings( + d: &crate::ui::window::plot::PlotDialogState, + stored: &acadrust::objects::PlotSettings, +) -> String { + let unchanged = !stored.paper_size.is_empty() + && crate::io::paper_catalog::from_drawing( + &stored.paper_size, + stored.paper_width, + stored.paper_height, + ) + .canonical + == d.paper; + if unchanged { + stored.paper_size.clone() + } else { + d.paper.clone() } } @@ -2916,9 +2944,9 @@ impl OpenCADStudio { .clone() .or_else(|| self.tabs[i].scene.plot_settings_for(&layout_name)) .unwrap_or_else(|| PlotSettings::new("")); + ps.paper_size = paper_name_for_settings(&dialog, &ps); ps.paper_width = w; ps.paper_height = h; - ps.paper_size = dialog.paper.clone(); ps.plot_type = match plot_area { "Window" => PlotType::Window, "Display" => PlotType::LastScreenDisplay, @@ -3637,7 +3665,7 @@ impl OpenCADStudio { /// Open the full Plot / Print dialog, seeding its state from the active /// layout's plot settings and the printers found on the system. pub(super) fn on_plot_dialog_open(&mut self) -> Task { - use crate::io::paper_sizes::Orientation; + use crate::io::paper_catalog::Orientation; let previous = self.plot_dialog.clone(); let scales: Vec<(String, f64)> = self.tabs[self.active_tab] .scene @@ -3671,7 +3699,8 @@ impl OpenCADStudio { d.scale = one_to_one.clone(); } d.paper_space = paper_space; - d.paper = self.plot_format.label().to_string(); + d.paper = self.plot_paper.canonical.to_string(); + (d.paper_width_mm, d.paper_height_mm) = self.plot_paper.sheet_mm(self.plot_orientation); d.orientation = match self.plot_orientation { Orientation::Portrait => "Portrait", Orientation::Landscape => "Landscape", @@ -4100,10 +4129,11 @@ impl OpenCADStudio { let is_model = self.tabs[self.active_tab].scene.current_layout == "Model"; let d = &mut self.plot_dialog; d.to_file = true; - d.paper = "A4".into(); + let a4 = crate::io::paper_catalog::default_paper(); + d.paper = a4.canonical.to_string(); d.orientation = "Landscape".into(); - d.paper_width_mm = 297.0; - d.paper_height_mm = 210.0; + (d.paper_width_mm, d.paper_height_mm) = + a4.sheet_mm(crate::io::paper_catalog::Orientation::Landscape); d.area = if is_model { "Window".into() } else { @@ -4164,9 +4194,9 @@ impl OpenCADStudio { settings } }; + ps.paper_size = paper_name_for_settings(d, &ps); ps.paper_width = w; ps.paper_height = h; - ps.paper_size = d.paper.clone(); ps.plot_type = match d.area.as_str() { "Window" => PlotType::Window, "Layout" => PlotType::Layout, @@ -4281,16 +4311,23 @@ impl OpenCADStudio { && active_style_name .as_deref() .is_some_and(|name| name.eq_ignore_ascii_case(&style_name)); - let (mut paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); - if !matches!( - paper.as_str(), - "A4" | "A3" | "A2" | "A1" | "A0" | "Letter" | "Legal" | "Tabloid" - ) && !ps.paper_size.is_empty() - { - paper = ps.paper_size.clone(); + // The stored media name wins (it is what AutoCAD wrote); a drawing + // without a usable name is matched by size, and an unknown size keeps + // its own dimensions as a custom sheet. The sheet's orientation is the + // stored width/height order; the rotation below may flip it again. + let paper = crate::io::paper_catalog::from_drawing( + &ps.paper_size, + ps.paper_width, + ps.paper_height, + ); + let orient = if ps.paper_width >= ps.paper_height { + "Landscape" + } else { + "Portrait" } + .to_string(); let d = &mut self.plot_dialog; - d.paper = paper; + d.paper = paper.canonical.to_string(); d.paper_width_mm = ps.paper_width.max(1.0); d.paper_height_mm = ps.paper_height.max(1.0); d.orientation = orient; @@ -4381,25 +4418,9 @@ impl OpenCADStudio { /// Copy transient paper/scale choices out of the dialog without changing /// the active layout. fn sync_dialog_plot_runtime(&mut self) { - use crate::io::paper_sizes::{Orientation, PaperSize}; - let d = self.plot_dialog.clone(); - let paper = match d.paper.as_str() { - "A3" => PaperSize::A3, - "A2" => PaperSize::A2, - "A1" => PaperSize::A1, - "A0" => PaperSize::A0, - "Letter" => PaperSize::Letter, - "Legal" => PaperSize::Legal, - "Tabloid" => PaperSize::Tabloid, - _ => PaperSize::A4, - }; - let orient = if d.orientation == "Portrait" { - Orientation::Portrait - } else { - Orientation::Landscape - }; - self.plot_format = paper; - self.plot_orientation = orient; + let d = &self.plot_dialog; + self.plot_paper = plot_dialog_paper(d); + self.plot_orientation = plot_dialog_orientation(d); } fn apply_dialog_to_layout(&mut self) { @@ -4651,7 +4672,7 @@ impl OpenCADStudio { /// Render a selected rectangle through one shared Model/Paper path. The /// window may lie partly or wholly outside a paper sheet. fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option { - use crate::io::paper_sizes::{window_to_sheet, PlotScale}; + use crate::io::paper_catalog::{window_to_sheet, PlotScale}; let i = self.active_tab; let (x0, y0, x1, y1) = window; if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 { @@ -4811,3 +4832,84 @@ impl OpenCADStudio { ) } } + +#[cfg(test)] +mod plot_paper_tests { + use crate::app::{Message, OpenCADStudio}; + use crate::ui::window::plot::PlotDlgMsg; + + /// A drawing on `Layout1` whose page setup names its sheet the way a + /// Windows printer driver does — a bare `A4` with landscape dimensions. + fn app_with_printer_named_sheet() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let _ = app.update(Message::LayoutSwitch("Layout1".into())); + let i = app.active_tab; + let mut ps = app.tabs[i] + .scene + .plot_settings_for("Layout1") + .expect("a fresh layout carries plot settings"); + ps.paper_size = "A4".into(); + ps.paper_width = 297.0; + ps.paper_height = 210.0; + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + app + } + + fn layout_paper(app: &OpenCADStudio) -> (String, f64, f64) { + let ps = app.tabs[app.active_tab] + .scene + .plot_settings_for("Layout1") + .unwrap(); + (ps.paper_size, ps.paper_width, ps.paper_height) + } + + #[test] + fn dialog_shows_the_catalogue_sheet_for_a_driver_name() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.paper, "ISO_A4_(210.00_x_297.00_MM)"); + assert_eq!(app.plot_dialog.orientation, "Landscape"); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (297.0, 210.0)); + } + + #[test] + fn applying_an_unchanged_sheet_keeps_the_drawing_media_name() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let (name, w, h) = layout_paper(&app); + assert_eq!(name, "A4", "the driver's own spelling must survive the dialog"); + assert_eq!((w, h), (297.0, 210.0)); + } + + #[test] + fn picking_another_sheet_writes_the_canonical_name() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into())); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (420.0, 297.0)); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let (name, w, h) = layout_paper(&app); + assert_eq!(name, "ISO_A3_(297.00_x_420.00_MM)"); + assert_eq!((w, h), (420.0, 297.0)); + // The next dialog remembers the sheet the user chose. + assert_eq!(app.plot_paper.canonical, "ISO_A3_(297.00_x_420.00_MM)"); + } + + #[test] + fn an_unknown_sheet_from_a_drawing_keeps_its_own_dimensions() { + let mut app = app_with_printer_named_sheet(); + let i = app.active_tab; + let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + ps.paper_size = "Roll_24_(609.60_x_1500.00_MM)".into(); + ps.paper_width = 1500.0; + ps.paper_height = 609.6; + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)"); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (1500.0, 609.6)); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + assert_eq!(layout_paper(&app).0, "Roll_24_(609.60_x_1500.00_MM)"); + } +} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index bfff47d56..e79247d0c 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -8497,14 +8497,6 @@ impl OpenCADStudio { Message::PlotExportPath(None) => Task::none(), Message::PlotExportPath(Some(path)) => self.on_plot_export_path_some(path), - Message::PlotFormat(f) => { - self.plot_format = f; - Task::none() - } - Message::PlotOrientation(o) => { - self.plot_orientation = o; - Task::none() - } Message::PlotWindowExport => { let i = self.active_tab; let stem = self.tabs[i] diff --git a/src/app/update/util.rs b/src/app/update/util.rs index cb78c36ab..8115d3f40 100644 --- a/src/app/update/util.rs +++ b/src/app/update/util.rs @@ -62,27 +62,6 @@ pub(super) fn layout_entry_name(s: &str) -> &str { s.trim_start_matches('*').trim_end_matches('*') } -/// Infer an A-series label when dimensions match; otherwise retain the size. -pub(super) fn paper_label_from_dims(w: f64, h: f64) -> (String, String) { - use crate::io::paper_sizes::PaperSize; - let orient = if w >= h { "Landscape" } else { "Portrait" }; - let (short, long) = if w <= h { (w, h) } else { (h, w) }; - let mut best = ("A4".to_string(), f64::INFINITY); - for p in PaperSize::ALL { - let (pw, ph) = p.dimensions_mm(); // portrait: pw < ph - let err = (pw - short).abs() + (ph - long).abs(); - if err < best.1 { - best = (p.label().to_string(), err); - } - } - let label = if best.1 <= 2.0 { - best.0 - } else { - format!("{w:.2} × {h:.2} mm") - }; - (label, orient.to_string()) -} - pub(super) fn parse_plot_scale(s: &str) -> (f64, f64) { if s == "Fit" { return (1.0, 1.0); diff --git a/src/io/mod.rs b/src/io/mod.rs index 55ae70b1f..886f49aee 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -20,7 +20,7 @@ pub mod xref_model; pub mod linetypes; pub mod patterns; pub mod update_check; -pub mod paper_sizes; +pub mod paper_catalog; pub mod thumbnail; #[cfg(target_arch = "wasm32")] mod web_worker; diff --git a/src/io/paper_catalog.rs b/src/io/paper_catalog.rs new file mode 100644 index 000000000..04e7164ee --- /dev/null +++ b/src/io/paper_catalog.rs @@ -0,0 +1,712 @@ +//! Paper catalogue with AutoCAD-compatible media names. +//! +//! AutoCAD 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 AutoCAD 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 AutoCAD (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. AutoCAD 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)] +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 AutoCAD 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 { + 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, +} + +/// AutoCAD 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 AutoCAD 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, +} + +/// One sheet of the catalogue, or a sheet reconstructed from a media name. +#[derive(Clone, PartialEq, Debug)] +pub struct PaperSize { + /// AutoCAD 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 AutoCAD 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 (width, height) = if width <= height { + (width, height) + } else { + (height, width) + }; + let label = match units { + PaperUnits::Millimeters => "UserDefinedMetric", + PaperUnits::Inches => "UserDefinedInches", + }; + Self { + canonical: Cow::Owned(canonical_name(label, width, height, units)), + label: Cow::Borrowed(label), + series: PaperSeries::Other, + variant: PaperVariant::Standard, + width, + height, + units, + } + } + + /// Rebuild a sheet from any media name that follows AutoCAD's + /// `_(_x__)` 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 { + 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_nan() + || height.is_nan() + { + return None; + } + let label = raw_label.replace('_', " "); + let (series, variant) = classify_label(&label); + Some(Self { + canonical: Cow::Owned(canonical_name( + &raw_label.replace(' ', "_"), + width, + height, + units, + )), + label: Cow::Owned(label), + series, + variant, + width, + 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}"), + } +} + +/// `_(_x__)` exactly as AutoCAD 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 AutoCAD writes it, sheet letter, +/// portrait width/height, units, and whether AutoCAD 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 AutoCAD'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 { + 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> = 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 AutoCAD name, which is reconstructed from its +/// embedded dimensions. `None` only for names that carry no size at all. +pub fn resolve(name: &str) -> Option { + 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(); + } + 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_autocad_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_autocad_names_are_reconstructed_and_preserved() { + // A custom size created in AutoCAD's 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(), (420.0, 297.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)); + } + + #[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 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}"); + } +} diff --git a/src/io/paper_sizes.rs b/src/io/paper_sizes.rs deleted file mode 100644 index 916a961e3..000000000 --- a/src/io/paper_sizes.rs +++ /dev/null @@ -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}"); - } -} diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index f4d744990..45f992c30 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -5,7 +5,7 @@ //! PDF. Styled to match the other OCS dialogs (dark pills + fields). use crate::app::Message; -use crate::io::paper_sizes::PaperSize; +use crate::io::paper_catalog::{self, PaperSize}; use iced::widget::{ button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, Space, @@ -28,25 +28,39 @@ pub const STYLE_NONE: &str = ""; /// A plot dropdown value keeps its persisted/raw value separate from the /// localized label shown by Iced. Printer names, scale names, paper sizes, and /// style-table file names remain verbatim; built-in choices use the catalog. +/// A paper choice keeps the canonical media name as its value and shows the +/// sheet's human name with its dimensions. #[derive(Clone, Debug, Eq, PartialEq)] struct PlotChoice { raw: String, localized: bool, + /// Display text when it differs from `raw` (paper sizes). + display: Option, } impl PlotChoice { fn raw(value: impl Into) -> Self { - Self { raw: value.into(), localized: false } + Self { raw: value.into(), localized: false, display: None } } fn localized(value: impl Into) -> Self { - Self { raw: value.into(), localized: true } + Self { raw: value.into(), localized: true, display: None } + } + + fn paper(paper: &PaperSize) -> Self { + Self { + raw: paper.canonical.to_string(), + localized: false, + display: Some(paper.display()), + } } } impl fmt::Display for PlotChoice { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.localized { + if let Some(display) = &self.display { + formatter.write_str(display) + } else if self.localized { if let Some(name) = self.raw.strip_prefix("View: ") { formatter.write_str(crate::tf!("View: {name}").as_ref()) } else { @@ -212,7 +226,7 @@ impl Default for PlotDialogState { printers: Vec::new(), printer: None, to_file: false, - paper: "A4".into(), + paper: paper_catalog::default_paper().canonical.to_string(), paper_width_mm: 297.0, paper_height_mm: 210.0, orientation: "Landscape".into(), @@ -659,9 +673,17 @@ pub fn view_window( None => PlotChoice::localized(OUT_DEFAULT), }) }; - let mut paper_opts: Vec = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect(); - if !paper_opts.iter().any(|name| name == &s.paper) { - paper_opts.push(s.paper.clone()); + // The picker lists the catalogue by series; a sheet that only exists in + // the drawing (a driver's custom size, an old config's bare name) is added + // at the top so the current selection is always visible and re-selectable. + let selected_paper = paper_catalog::from_drawing(&s.paper, s.paper_width_mm, s.paper_height_mm); + let mut paper_opts: Vec = paper_catalog::catalog() + .iter() + .map(PlotChoice::paper) + .collect(); + let paper_sel = PlotChoice::paper(&selected_paper); + if !paper_opts.contains(&paper_sel) { + paper_opts.insert(0, paper_sel.clone()); } let paper_note: Element<'_, Message> = if s.area == "Layout" { text(t!("Layout plots the current sheet using the selected paper size.")) @@ -712,13 +734,7 @@ pub fn view_window( // ── Paper, area, offset, scale ──────────────────────────────────────── let paper_panel = panel(column![ section_label(t!("Paper")), - drop_row( - t!("Size"), - paper_opts.into_iter().map(PlotChoice::raw).collect(), - Some(PlotChoice::raw(s.paper.clone())), - PlotDlgMsg::Paper, - width, - ), + drop_row(t!("Size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, width), paper_note, ].spacing(7));