feat(plot): paper catalogue with AutoCAD canonical media names

AutoCAD stores a layout's sheet as the plotter driver's canonical media
name (`ISO_A4_(210.00_x_297.00_MM)`, `ANSI_full_bleed_B_(11.00_x_17.00_Inches)`),
so a drawing saved here has to carry the same spelling for AutoCAD to find
the sheet in its DWG To PDF list, and a name that arrives from AutoCAD must
resolve to its dimensions and go back out untouched.

- New `io::paper_catalog` replaces the eight-entry `paper_sizes`: ISO A/B,
  ANSI, ARCH (incl. E1) and JIS series with full-bleed / expand variants,
  Letter / Legal / Tabloid, millimetre and inch units, a parser that
  reconstructs any well-formed AutoCAD name (custom or driver-specific)
  from its embedded dimensions, driver / legacy-config aliases (`A4`,
  `Letter`, `Ledger`), and dimension matching that prefers the plain sheet.
- The Plot dialog stores the canonical name and shows the sheet as
  `ISO A4 (210 × 297 mm)`; a sheet that only exists in the drawing is
  listed first so it stays selectable.
- A sheet the user did not change keeps the drawing's own media name
  (`paper_name_for_settings`), so a Windows printer's `A4` is not rewritten
  to `ISO_A4_(…)` just by passing through the dialog.
- Drops the unused `PlotFormat` / `PlotOrientation` messages,
  `paper_label_from_dims`, and three duplicated `match "A4" | …` tables.

Tests: catalogue spelling, parser round-trip, aliases, dimension matching,
display formatting, plus dialog ↔ layout round-trips for driver-named,
catalogue and unknown sheets.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-16 16:05:46 +03:00
1 parent d894b8eb05
commit 567eb90925
8 files changed
+909 -253

No files matched your search

+6 -8
View File
@@ -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<std::path::PathBuf>),
/// 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,
+159 -57
View File
@@ -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<Message> {
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<PdfPageInput> {
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)");
}
}
-8
View File
@@ -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]
-21
View File
@@ -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);