feat(plot): custom paper sizes with printable margins

AutoCAD lets a plotter configuration carry user-defined sheets; a drawing
then names them like any other media (`Roll_24_(609.60_x_1500.00_MM)`).
The plot dialog now defines such sheets inline, under the Size row:
name, width/height, unit, and the printable margins in that unit. Added
sheets are selected at once, offered for every device after the device's
own list, remembered in the plot config across sessions, and written to
the layout with their own margins (a printer that reports the same size
still wins with its hardware margins). A selected user-defined sheet can
be removed; the sheet itself stays selectable in the drawing that uses it.

`CustomPaper` / `Margins` live in the paper catalogue with serde support;
`PlotDevice::margins_mm` consults the user's definitions. The Plot
dialog's form helpers (button and field styles, dividers, `label :
control` rows) move to `ui::style::form` so the next dialogs share them
instead of copying.

Tests: draft validation and seeding, catalogue naming of user sheets,
device margins with user definitions, and dialog round-trips (add,
select, persist, write, replace, remove, refuse an invalid size).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-16 16:05:47 +03:00
1 parent a7c89eca33
commit 5e271e2dbb
28 files changed
+930 -175

No files matched your search

+147 -7
View File
@@ -16,7 +16,7 @@ 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)]
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, serde::Serialize, serde::Deserialize)]
pub enum PaperUnits {
Millimeters,
Inches,
@@ -107,6 +107,88 @@ pub enum Orientation {
Landscape,
}
/// Unprintable margins of a sheet, in the order AutoCAD stores them. The unit
/// is whatever the context says — millimetres in a drawing, the sheet's own
/// unit in a user-defined size.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Margins {
pub left: f64,
pub bottom: f64,
pub right: f64,
pub top: f64,
}
impl Margins {
pub const ZERO: Margins = Margins {
left: 0.0,
bottom: 0.0,
right: 0.0,
top: 0.0,
};
pub const fn uniform(value: f64) -> Self {
Margins {
left: value,
bottom: value,
right: value,
top: value,
}
}
/// The same margins expressed in millimetres when they were given in
/// `units`.
pub fn to_mm(self, units: PaperUnits) -> Self {
Margins {
left: units.to_mm(self.left),
bottom: units.to_mm(self.bottom),
right: units.to_mm(self.right),
top: units.to_mm(self.top),
}
}
}
/// A sheet the user defined, kept in the application settings the way
/// AutoCAD keeps custom sizes in a plotter configuration: a name, the
/// dimensions in the sheet's own unit, and the printable margins.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct CustomPaper {
/// Human name, e.g. `Roll 24`; becomes `Roll_24_(…)` in the media name.
pub name: String,
pub width: f64,
pub height: f64,
pub units: PaperUnits,
/// Unprintable margins in `units`.
pub margins: Margins,
}
impl Default for CustomPaper {
fn default() -> Self {
Self {
name: String::new(),
width: 0.0,
height: 0.0,
units: PaperUnits::Millimeters,
margins: Margins::ZERO,
}
}
}
impl CustomPaper {
/// The catalogue sheet this definition describes.
pub fn paper(&self) -> PaperSize {
PaperSize::custom_named(&self.name, self.width, self.height, self.units)
}
pub fn canonical(&self) -> String {
self.paper().canonical.into_owned()
}
pub fn margins_mm(&self) -> Margins {
self.margins.to_mm(self.units)
}
}
/// One sheet of the catalogue, or a sheet reconstructed from a media name.
#[derive(Clone, PartialEq, Debug)]
pub struct PaperSize {
@@ -156,18 +238,43 @@ impl PaperSize {
/// in the plot dialog (`UserDefinedMetric_(420.00_x_297.00_MM)`). The
/// dimensions are normalised to portrait so the name stays canonical.
pub fn custom(width: f64, height: f64, units: PaperUnits) -> Self {
let label = match units {
PaperUnits::Millimeters => "UserDefinedMetric",
PaperUnits::Inches => "UserDefinedInches",
};
Self::custom_named(label, width, height, units)
}
/// A user-defined sheet under its own name, the way a plotter
/// configuration's custom sizes are named (`Roll_24_(609.60_x_1500.00_MM)`).
/// A blank name falls back to AutoCAD's generic one; spaces become
/// underscores in the media name and parentheses are dropped so the name
/// stays parseable.
pub fn custom_named(name: &str, width: f64, height: f64, units: PaperUnits) -> Self {
let (width, height) = if width <= height {
(width, height)
} else {
(height, width)
};
let label = match units {
PaperUnits::Millimeters => "UserDefinedMetric",
PaperUnits::Inches => "UserDefinedInches",
};
let cleaned: String = name
.trim()
.chars()
.filter(|c| !matches!(c, '(' | ')'))
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if cleaned.is_empty() {
return Self::custom(width, height, units);
}
Self {
canonical: Cow::Owned(canonical_name(label, width, height, units)),
label: Cow::Borrowed(label),
canonical: Cow::Owned(canonical_name(
&cleaned.replace(' ', "_"),
width,
height,
units,
)),
label: Cow::Owned(cleaned),
series: PaperSeries::Other,
variant: PaperVariant::Standard,
width,
@@ -688,6 +795,39 @@ mod tests {
assert_eq!(default_paper().canonical, a4().canonical);
}
#[test]
fn user_defined_sheets_carry_their_name_and_margins() {
let roll = CustomPaper {
name: "Roll 24 (long)".into(),
width: 1500.0,
height: 609.6,
units: PaperUnits::Millimeters,
margins: Margins {
left: 5.0,
bottom: 17.0,
right: 6.0,
top: 18.0,
},
};
assert_eq!(roll.canonical(), "Roll_24_long_(609.60_x_1500.00_MM)");
assert_eq!(roll.paper().label, "Roll 24 long");
assert_eq!(roll.paper().display(), "Roll 24 long (609.6 × 1500 mm)");
// Round-trips through the parser like any AutoCAD name.
assert_eq!(
resolve(&roll.canonical()).unwrap().portrait_mm(),
(609.6, 1500.0)
);
let arch = CustomPaper {
name: String::new(),
width: 30.0,
height: 42.0,
units: PaperUnits::Inches,
margins: Margins::uniform(0.25),
};
assert_eq!(arch.canonical(), "UserDefinedInches_(30.00_x_42.00_Inches)");
assert_eq!(arch.margins_mm(), Margins::uniform(6.35));
}
#[test]
fn fit_centers_and_scales_within_margin() {
// 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width:
+57 -52
View File
@@ -15,7 +15,8 @@
//! a fixed table; a CUPS printer reports its media and margins over IPP
//! (`media-col-database`), which is queried once per printer and cached.
use crate::io::paper_catalog::{self, PaperSize, PaperUnits, PaperVariant};
pub use crate::io::paper_catalog::Margins;
use crate::io::paper_catalog::{self, CustomPaper, PaperSize, PaperUnits, PaperVariant};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
@@ -73,51 +74,23 @@ impl PlotDevice {
}
/// Unprintable margins the device imposes on `paper`, in millimetres.
pub fn margins_mm(&self, paper: &PaperSize) -> Margins {
/// A sheet the user defined carries its own margins for every device,
/// the way a custom size in a plotter configuration does; a printer that
/// reports the same size still wins, since it knows its hardware.
pub fn margins_mm(&self, paper: &PaperSize, custom: &[CustomPaper]) -> Margins {
let user_defined = custom
.iter()
.find(|c| c.canonical() == paper.canonical)
.map(CustomPaper::margins_mm);
match self {
PlotDevice::Pdf => pdf_margins_mm(paper),
PlotDevice::Printer(name) => printer_margins_mm(name, paper),
PlotDevice::Pdf => user_defined.unwrap_or_else(|| pdf_margins_mm(paper)),
PlotDevice::Printer(name) => cached_printer_capabilities(name)
.and_then(|caps| caps.margins_for(paper))
.or(user_defined)
.unwrap_or(Margins::ZERO),
// No device: AutoCAD keeps the margins of the last device or its
// defaults; without one we show the full sheet as printable.
PlotDevice::None => Margins::ZERO,
}
}
}
/// Unprintable margins in millimetres, in the order AutoCAD stores them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Margins {
pub left: f64,
pub bottom: f64,
pub right: f64,
pub top: f64,
}
impl Margins {
pub const ZERO: Margins = Margins {
left: 0.0,
bottom: 0.0,
right: 0.0,
top: 0.0,
};
pub const fn uniform(value: f64) -> Self {
Margins {
left: value,
bottom: value,
right: value,
top: value,
}
}
/// The same margins expressed in millimetres when they were given in
/// `units`.
pub fn to_mm(self, units: PaperUnits) -> Self {
Margins {
left: units.to_mm(self.left),
bottom: units.to_mm(self.bottom),
right: units.to_mm(self.right),
top: units.to_mm(self.top),
PlotDevice::None => user_defined.unwrap_or(Margins::ZERO),
}
}
}
@@ -234,12 +207,6 @@ pub fn printer_capabilities(printer: &str) -> Option<Arc<PrinterCapabilities>> {
Some(caps)
}
fn printer_margins_mm(printer: &str, paper: &PaperSize) -> Margins {
cached_printer_capabilities(printer)
.and_then(|caps| caps.margins_for(paper))
.unwrap_or(Margins::ZERO)
}
/// Name of the system default printer, when the platform reports one.
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
pub fn default_printer_name() -> Option<String> {
@@ -459,7 +426,8 @@ mod tests {
#[test]
fn pdf_margins_follow_autocad_per_family_and_variant() {
let m = |name: &str| PlotDevice::Pdf.margins_mm(&paper_catalog::resolve(name).unwrap());
let m =
|name: &str| PlotDevice::Pdf.margins_mm(&paper_catalog::resolve(name).unwrap(), &[]);
// ISO sheets share one millimetre set regardless of size.
let iso_standard = Margins {
left: 5.0,
@@ -517,12 +485,49 @@ mod tests {
inch(0.03, 0.03, 0.03, 0.03)
));
assert_eq!(
PlotDevice::None
.margins_mm(&paper_catalog::resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap()),
PlotDevice::None.margins_mm(
&paper_catalog::resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(),
&[]
),
Margins::ZERO
);
}
#[test]
fn user_defined_sheets_bring_their_own_margins() {
let roll = CustomPaper {
name: "Roll".into(),
width: 900.0,
height: 1200.0,
units: PaperUnits::Millimeters,
margins: Margins::uniform(3.0),
};
let paper = roll.paper();
assert_eq!(
PlotDevice::Pdf.margins_mm(&paper, &[roll.clone()]),
Margins::uniform(3.0)
);
assert_eq!(
PlotDevice::None.margins_mm(&paper, &[roll.clone()]),
Margins::uniform(3.0)
);
// An unknown printer falls back to the user's margins too.
assert_eq!(
PlotDevice::Printer("Nowhere".into()).margins_mm(&paper, &[roll]),
Margins::uniform(3.0)
);
// Without a definition a custom-looking sheet gets the family set.
assert_eq!(
PlotDevice::Pdf.margins_mm(&paper, &[]),
Margins {
left: 5.0,
bottom: 17.0,
right: 6.0,
top: 18.0
}
);
}
#[test]
fn ipp_media_parses_sizes_margins_borderless_and_default() {
let caps = parse_ipp_media(IPP_SAMPLE).expect("media parsed");