forked from 1b-agent/opencadstudio-upstream
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
d894b8eb05
commit
567eb90925
8 files changed
+909
-253
No files matched your search
+6
-8
@@ -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
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -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<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,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `<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_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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<prefix>_(<w>_x_<h>_<units>)` 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<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 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<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();
|
||||
}
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
+31
-15
@@ -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 = "<none>";
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
impl PlotChoice {
|
||||
fn raw(value: impl Into<String>) -> Self {
|
||||
Self { raw: value.into(), localized: false }
|
||||
Self { raw: value.into(), localized: false, display: None }
|
||||
}
|
||||
|
||||
fn localized(value: impl Into<String>) -> 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<String> = 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<PlotChoice> = 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));
|
||||
|
||||
|
||||
Reference in new issue
Block a user