Merge remote-tracking branch 'origin/main' into plugin/host-model-api

# Conflicts:
#	crates/ocs_plugin_api/src/process/v4.rs
#	src/app/update/mod.rs
This commit is contained in:
Felix Riestra committed 2026-09-19 23:58:01 +02:00
commit 8648225ac5
309 files changed
+81166 -4157

No files matched your search

+291
View File
@@ -0,0 +1,291 @@
// Community font repository — detect missing SHX fonts and fetch them from
// the project's `fonts/` folder on GitHub.
//
// DWG files reference SHX fonts by bare name (`vnarial.shx`, `romans.shx`, …).
// When the file doesn't resolve on disk the renderer substitutes an embedded
// LFF stroke font, which mangles drawings authored with fonts it cannot stand
// in for. This module detects those references and downloads the real files
// from the community folder, where anyone can contribute (see
// `fonts/README.md` in the repository root).
use std::path::{Path, PathBuf};
use acadrust::CadDocument;
/// The community folder's GitHub contents API (lists name + download URL).
#[cfg(not(target_arch = "wasm32"))]
const REPO_API_URL: &str =
"https://api.github.com/repos/HakanSeven12/OpenCADStudio/contents/fonts";
#[cfg(not(target_arch = "wasm32"))]
const MAX_FONT_BYTES: u64 = 16 * 1024 * 1024;
/// Local store for fonts downloaded from the community repository. Text
/// resolution searches here after the drawing folder.
#[cfg(not(target_arch = "wasm32"))]
pub fn fonts_dir() -> Option<PathBuf> {
crate::config::config_dir().map(|dir| dir.join("fonts"))
}
/// Web builds have no filesystem to cache fonts into.
#[cfg(target_arch = "wasm32")]
pub fn fonts_dir() -> Option<PathBuf> {
None
}
/// Where missing fonts are fetched from. The community repository is the
/// default; a custom source is any HTTP folder - a company file server, a
/// private GitHub repo's raw folder - where each font lives as
/// `{base}/{file_name}`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FontSource {
Community,
Custom(String),
}
impl FontSource {
/// Empty / whitespace input selects the community repository.
pub fn from_url(url: &str) -> Self {
match url.trim() {
"" => FontSource::Community,
trimmed => FontSource::Custom(trimmed.trim_end_matches('/').to_string()),
}
}
}
/// One file listed in the community folder.
#[derive(Debug, Clone, PartialEq)]
pub struct RepoFont {
pub name: String,
pub download_url: String,
}
/// Referenced-but-unresolvable `.shx` font files across the document's text
/// styles. Names are deduplicated case-insensitively and reduced to the bare
/// file name (`C:\Fonts\VNArial.shx` → `VNArial.shx`).
pub fn missing_shx_fonts(doc: &CadDocument) -> Vec<String> {
let mut missing: Vec<String> = Vec::new();
let base = doc
.source_path
.as_deref()
.map(Path::new)
.and_then(|p| p.parent());
for style in doc.text_styles.iter() {
let file = style.font_file.trim();
if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") {
continue;
}
let resolved = crate::io::resolve_image_file(file, base).is_some()
|| local_font_file(file).is_some();
if resolved {
continue;
}
let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string();
if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) {
missing.push(name);
}
}
missing.sort_by_key(|name| name.to_ascii_lowercase());
missing
}
/// A font file previously downloaded into the local fonts directory, matched
/// case-insensitively by file name.
pub fn local_font_file(name: &str) -> Option<PathBuf> {
let file = name.rsplit(['/', '\\']).next()?;
let dir = fonts_dir()?;
let direct = dir.join(file);
if direct.is_file() {
return Some(direct);
}
let lower = file.to_ascii_lowercase();
std::fs::read_dir(&dir)
.ok()?
.filter_map(|entry| entry.ok())
.find(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase() == lower)
.map(|entry| entry.path())
}
/// List the community folder through the GitHub contents API.
#[cfg(not(target_arch = "wasm32"))]
fn fetch_repo_fonts() -> Result<Vec<RepoFont>, String> {
let agent = crate::network::agent(std::time::Duration::from_secs(15));
let mut response = agent
.get(REPO_API_URL)
.header("User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION")))
.header("Accept", "application/vnd.github+json")
.call()
.map_err(|e| format!("Could not reach the font repository: {e}"))?;
let body = response
.body_mut()
.read_to_string()
.map_err(|e| format!("Could not read the font repository listing: {e}"))?;
parse_contents(&body)
}
/// Parse the GitHub contents-API response:
/// `[{"name":"romans.shx","download_url":"https://…"}, …]`.
#[cfg(not(target_arch = "wasm32"))]
fn parse_contents(body: &str) -> Result<Vec<RepoFont>, String> {
let value: serde_json::Value =
serde_json::from_str(body).map_err(|e| format!("Unexpected font listing: {e}"))?;
let items = value
.as_array()
.ok_or_else(|| "Unexpected font-repository response.".to_string())?;
Ok(items
.iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let url = item.get("download_url")?.as_str()?.to_string();
(!name.is_empty() && !url.is_empty()).then_some(RepoFont {
name,
download_url: url,
})
})
.collect())
}
/// Download every `missing` font that exists in the community folder into the
/// local fonts directory. Returns the (requested, saved-path) pairs that were
/// actually fetched; names absent from the repository are skipped.
#[cfg(not(target_arch = "wasm32"))]
pub fn download_fonts(
missing: &[String],
source: &FontSource,
) -> Result<Vec<(String, PathBuf)>, String> {
if missing.is_empty() {
return Ok(Vec::new());
}
let repo = match source {
FontSource::Community => Some(fetch_repo_fonts()?),
FontSource::Custom(_) => None,
};
let dir = fonts_dir().ok_or_else(|| "Could not resolve the fonts folder.".to_string())?;
std::fs::create_dir_all(&dir).map_err(|e| format!("Could not create {}: {e}", dir.display()))?;
let mut downloaded = Vec::new();
for want in missing {
let file = want.rsplit(['/', '\\']).next().unwrap_or(want);
let url = match (source, repo.as_ref()) {
// Community: download URLs come from the listing, matched by name.
(FontSource::Community, Some(repo)) => {
let lower = file.to_ascii_lowercase();
repo.iter()
.find(|f| f.name.to_ascii_lowercase() == lower)
.map(|f| f.download_url.clone())
}
// Custom folder: the font is simply `{base}/{file_name}`; a miss
// (404) is skipped without failing the batch.
(FontSource::Custom(base), _) => {
Some(format!("{base}/{}", percent_encode_path(file)))
}
(FontSource::Community, None) => None,
};
let Some(url) = url else { continue };
let agent = crate::network::agent(std::time::Duration::from_secs(30));
let mut response = agent
.get(&url)
.header("User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION")))
.call()
.map_err(|e| format!("Could not download {file}: {e}"))?;
if response.status() != 200 {
continue;
}
let bytes = response
.body_mut()
.with_config()
.limit(MAX_FONT_BYTES)
.read_to_vec()
.map_err(|e| format!("Could not download {file}: {e}"))?;
if bytes.is_empty() {
continue;
}
let dest = dir.join(file);
std::fs::write(&dest, &bytes)
.map_err(|e| format!("Could not save {}: {e}", dest.display()))?;
downloaded.push((file.to_string(), dest));
}
Ok(downloaded)
}
/// The web build has no network client, and no fonts folder to save into.
#[cfg(target_arch = "wasm32")]
pub fn download_fonts(
missing: &[String],
_source: &FontSource,
) -> Result<Vec<(String, PathBuf)>, String> {
if missing.is_empty() {
return Ok(Vec::new());
}
Err("Downloading fonts is not available in the web app.".to_string())
}
/// Escape path characters that would break a URL (spaces and non-ASCII file
/// names are common in font sets).
#[cfg(not(target_arch = "wasm32"))]
fn percent_encode_path(file: &str) -> String {
let mut out = String::with_capacity(file.len());
for byte in file.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_github_contents_listing() {
let body = r#"[
{"name":"romans.shx","path":"fonts/romans.shx",
"download_url":"https://raw.githubusercontent.com/x/y/main/fonts/romans.shx","type":"file"},
{"name":"subset","path":"fonts/subset","type":"dir"},
{"name":"no-url.shx","path":"fonts/no-url.shx","type":"file"}
]"#;
let fonts = parse_contents(body).unwrap();
assert_eq!(fonts.len(), 1);
assert_eq!(fonts[0].name, "romans.shx");
assert!(fonts[0].download_url.ends_with("/fonts/romans.shx"));
}
#[test]
fn custom_source_urls_are_trimmed_and_encoded() {
assert_eq!(FontSource::from_url(""), FontSource::Community);
assert_eq!(
FontSource::from_url(" https://intra/fonts/ "),
FontSource::Custom("https://intra/fonts".into())
);
assert_eq!(percent_encode_path("vn 30f.shx"), "vn%2030f.shx");
}
#[test]
fn rejects_a_non_listing_response() {
assert!(parse_contents("{\"message\":\"Not Found\"}").is_err());
}
#[test]
fn missing_fonts_deduplicate_by_name_case_insensitively() {
// Two styles referencing the same missing font with different casing
// and one whose file resolves next to the drawing must yield one hit.
let dir = std::env::temp_dir().join("ocs_font_repo_tests");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("exists.shx"), b"stub").unwrap();
let mut doc = CadDocument::new();
let mk = |name: &str, file: &str| {
let mut style = acadrust::TextStyle::new(name);
style.font_file = file.into();
style
};
let _ = doc.text_styles.add(mk("A", "MISSING1.shx"));
let _ = doc.text_styles.add(mk("B", "missing1.SHX"));
let _ = doc.text_styles.add(mk("C", "exists.shx"));
doc.source_path = Some(dir.join("drawing.dwg").to_string_lossy().into_owned());
let missing = missing_shx_fonts(&doc);
assert_eq!(missing, vec!["MISSING1.shx".to_string()]);
let _ = std::fs::remove_file(dir.join("exists.shx"));
}
}
+308 -27
View File
@@ -4,9 +4,11 @@
// Default save format: DWG (AC1032 / R2018+).
pub mod file_association;
pub mod font_repo;
#[cfg(not(target_arch = "wasm32"))]
pub mod edit_lock;
pub mod obj;
pub mod ole_embed;
#[cfg(not(target_arch = "wasm32"))]
pub mod single_instance;
pub mod pdf_export;
@@ -16,10 +18,13 @@ pub mod recovery;
pub mod step;
pub mod stl;
pub mod xref;
pub mod xref_model;
pub mod linetypes;
pub mod patterns;
pub mod update_check;
pub mod paper_sizes;
pub mod paper_catalog;
pub mod plot_device;
pub mod windows_media;
pub mod thumbnail;
#[cfg(target_arch = "wasm32")]
mod web_worker;
@@ -314,10 +319,24 @@ async fn open_path_with_phase_attempt(
});
callback
};
std::fs::File::open(&path2).map_err(|error| OpenAttemptFailure {
message: format!("failed to open drawing: {error}"),
read_stats: None,
recoverable: false,
std::fs::File::open(&path2).map_err(|error| {
// 32 = ERROR_SHARING_VIOLATION, 33 = ERROR_LOCK_VIOLATION:
// another program holds the drawing without read sharing,
// so nothing on the machine can read the bytes until it
// closes the file — not even read-only.
let message = match error.raw_os_error() {
Some(32) | Some(33) => format!(
"\"{}\" is in use by another program. Close the file there and \
reopen it here, or open a copy of the file.",
path2.display()
),
_ => format!("failed to open drawing: {error}"),
};
OpenAttemptFailure {
message,
read_stats: None,
recoverable: false,
}
})?;
let outcome = load_file_for_open(&path2, Some(parser_progress), &attempt)?;
let read_stats = outcome.stats;
@@ -522,6 +541,18 @@ pub async fn pick_and_load_web(
let name = handle.file_name();
progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2);
let bytes: Arc<[u8]> = Arc::from(handle.read().await);
open_bytes_web(name, bytes, progress).await
}
/// Web file open from bytes the caller already holds (the control channel's
/// `open` with `data_base64`). Same parse, caching and recovery handling as a
/// file chosen in the browser picker.
#[cfg(target_arch = "wasm32")]
pub async fn open_bytes_web(
name: String,
bytes: Arc<[u8]>,
progress: Arc<OpenProgressState>,
) -> WebOpenOutcome {
let size_bytes = bytes.len() as u64;
let result = load_web_bytes(&name, &bytes, progress.clone(), false, "", None).await;
let keep_for_recovery = result
@@ -996,6 +1027,7 @@ fn finalize_loaded_outcome(
) -> Result<acadrust::ReadOutcome, String> {
let doc = &mut outcome.document;
normalize_block_origins(doc);
normalize_knotless_splines(doc);
if outcome.stats.source_format == Some(acadrust::SourceFormat::Dxf) {
fix_dxf_dimension_rotations(doc);
fix_dxf_layout_plot_settings(doc);
@@ -1018,20 +1050,117 @@ fn read_dwg_path(
DwgReadOptions::default()
};
#[cfg(not(target_arch = "wasm32"))]
let mut reader = {
let mut reader = DwgReader::from_mmap(path).map_err(ReaderFailure::from_reader)?;
reader.options = options;
{
// The memory-mapped read is the fast path, but a mapped file is read
// through the page-fault handler: a file that shrinks while mapped
// (a writer saving over it) or a cloud placeholder that cannot page
// in raises an in-page exception the process cannot catch. Both are
// ordinary I/O errors through plain reads, so anything that can
// fault — or refuses to open under a third-party lock — falls back
// to one in-memory snapshot read below.
if !cloud_placeholder(path) {
match DwgReader::from_mmap(path) {
Ok(mut reader) => {
reader.options = options.clone();
if let Some(progress) = &progress {
reader.set_progress_callback(progress.clone());
}
match reader.read_with_stats() {
Ok(outcome) => return Ok(outcome),
// The file shrank or became unreadable mid-parse;
// retry from a snapshot instead of reporting a bare
// I/O error.
Err(acadrust::DxfError::Io(_)) => {}
Err(error) => return Err(ReaderFailure::from_reader(error)),
}
}
// Locked or unmappable: the snapshot read surfaces the real
// error when the file genuinely cannot be read.
Err(_) => {}
}
}
let bytes = read_drawing_snapshot(path).map_err(|error| {
ReaderFailure::terminal(format!(
"failed to open drawing (is it in use by another program?): {error}"
))
})?;
let mut reader = DwgReader::from_stream_with_options(std::io::Cursor::new(bytes), options);
if let Some(progress) = progress {
reader.set_progress_callback(progress);
}
reader
};
.read_with_stats()
.map_err(ReaderFailure::from_reader)
}
#[cfg(target_arch = "wasm32")]
let mut reader = DwgReader::from_file_with_options(path, options)
.map_err(ReaderFailure::from_reader)?;
if let Some(progress) = progress {
reader.set_progress_callback(progress);
{
let mut reader = DwgReader::from_file_with_options(path, options)
.map_err(ReaderFailure::from_reader)?;
if let Some(progress) = progress {
reader.set_progress_callback(progress);
}
reader
.read_with_stats()
.map_err(ReaderFailure::from_reader)
}
}
/// Windows cloud-placeholder files (OneDrive "Files On Demand" and similar)
/// page their bytes in on demand; faulting a placeholder through a memory map
/// can raise an in-page exception that kills the process. Plain reads hydrate
/// the file instead. Checks `FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS`,
/// `FILE_ATTRIBUTE_RECALL_ON_OPEN` and `FILE_ATTRIBUTE_OFFLINE`.
#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))]
fn cloud_placeholder(path: &Path) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_OFFLINE: u32 = 0x1000;
const FILE_ATTRIBUTE_RECALL_ON_OPEN: u32 = 0x0004_0000;
const FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS: u32 = 0x0040_0000;
std::fs::metadata(path)
.map(|meta| {
let attrs = meta.file_attributes();
attrs
& (FILE_ATTRIBUTE_OFFLINE
| FILE_ATTRIBUTE_RECALL_ON_OPEN
| FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS)
!= 0
})
.unwrap_or(false)
}
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
fn cloud_placeholder(_path: &Path) -> bool {
false
}
/// Read the whole drawing through the most permissive share mode the platform
/// offers. On Windows `std::fs::read` opens without `FILE_SHARE_DELETE`, so a
/// concurrent atomic-save replace makes the read fail even though every byte
/// was readable. A snapshot can never fault: a writer shrinking the file
/// mid-read just ends the read early.
#[cfg(not(target_arch = "wasm32"))]
fn read_drawing_snapshot(path: &Path) -> std::io::Result<Vec<u8>> {
#[cfg(target_os = "windows")]
{
use std::io::Read;
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
};
let mut file = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.open(path)?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Ok(bytes)
}
#[cfg(not(target_os = "windows"))]
{
std::fs::read(path)
}
reader
.read_with_stats()
.map_err(ReaderFailure::from_reader)
}
fn read_dxf_path(path: &Path, failsafe: bool) -> Result<acadrust::ReadOutcome, ReaderFailure> {
@@ -1518,7 +1647,12 @@ mod save_failure_tests {
// ── Plot Style Table ──────────────────────────────────────────────────────
/// Show a file-open dialog and load the selected CTB or STB file.
pub async fn pick_plot_style() -> Option<plot_style::PlotStyleTable> {
/// Pick a plot style table file and load it. `Ok(None)` when the picker was
/// cancelled; `Err` says why the chosen file could not be read. A table
/// picked from outside the plot styles folder is copied into it (unless a
/// file of that name is already there), so the dialog lists it from now on
/// and page setups naming it resolve after a restart.
pub async fn pick_plot_style() -> Result<Option<plot_style::PlotStyleTable>, String> {
let dialog = crate::sys::file_dialog()
.set_title(crate::t!("Load Plot Style Table").as_ref())
.add_filter(crate::t!("Plot Style Tables").as_ref(), &["ctb", "CTB"])
@@ -1529,8 +1663,26 @@ pub async fn pick_plot_style() -> Option<plot_style::PlotStyleTable> {
Ok(dir) => dialog.set_directory(dir),
Err(_) => dialog,
};
let handle = dialog.pick_file().await?;
plot_style::PlotStyleTable::load(&crate::sys::handle_path(&handle)).ok()
let Some(handle) = dialog.pick_file().await else {
return Ok(None);
};
let path = crate::sys::handle_path(&handle);
let table = plot_style::PlotStyleTable::load(&path)?;
#[cfg(not(target_arch = "wasm32"))]
if let (Ok(dir), Some(file_name)) = (plot_style::ensure_plot_styles_dir(), path.file_name()) {
let destination = dir.join(file_name);
let same_place = path.parent().is_some_and(|parent| {
parent
.canonicalize()
.ok()
.zip(dir.canonicalize().ok())
.is_some_and(|(a, b)| a == b)
});
if !same_place && !destination.exists() {
let _ = std::fs::copy(&path, &destination);
}
}
Ok(Some(table))
}
// ── Image file picker ─────────────────────────────────────────────────────
@@ -1553,6 +1705,23 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> {
Ok((path, w, h))
}
/// Pick an image file and prepare it for embedding (IMAGEEMBED). The bytes
/// are read before returning so the OLE2FRAME payload is ready to place.
pub async fn pick_embedded_image_file() -> Result<ole_embed::EmbeddedImage, String> {
let handle = crate::sys::file_dialog()
.set_title(crate::t!("Select Image File").as_ref())
.add_filter(crate::t!("Images").as_ref(), &["png", "jpg", "jpeg", "bmp", "tiff", "tif"])
.add_filter(crate::t!("PNG").as_ref(), &["png"])
.add_filter(crate::t!("JPEG").as_ref(), &["jpg", "jpeg"])
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
.ok_or_else(|| "Cancelled".to_string())?;
let name = handle.file_name();
let bytes = handle.read().await;
ole_embed::EmbeddedImage::from_bytes(name, bytes)
}
/// Save `doc` to `path` with the given DXF version, overriding `doc.version`.
/// Format is auto-detected from the extension (dwg / dxf).
pub fn save_as_version(
@@ -2022,6 +2191,8 @@ fn sync_current_styles_on_save(doc: &mut CadDocument) {
// `purge_corrupt_entities` scans the document and removes any entity that
// fails a cheap sanity check, returning the number dropped so the caller can
// surface it to the UI / log.
//
// Keep valid degenerate geometry: dropping it would trigger strict-open recovery.
fn finite_unit_normal(n: &acadrust::types::Vector3) -> bool {
let (x, y, z) = (n.x, n.y, n.z);
@@ -2079,20 +2250,15 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool {
|| p.vertices.iter().any(|v| !finite_vec3(&v.location))
}
E::Line(l) => !finite_vec3(&l.start) || !finite_vec3(&l.end),
// Zero-radius curves are valid and have bounded kernel tessellation.
E::Circle(c) => {
!finite_vec3(&c.center)
|| !finite_coord(c.radius)
// Reject zero- or near-zero circles: they tessellate into a
// degenerate curve the tessellator cannot sample.
|| c.radius.abs() < 1.0e-10
|| c.radius.abs() > 1.0e10
!finite_vec3(&c.center) || !finite_coord(c.radius) || c.radius.abs() > 1.0e10
}
E::Arc(a) => {
!finite_vec3(&a.center)
|| !finite_coord(a.radius)
|| !a.start_angle.is_finite()
|| !a.end_angle.is_finite()
|| a.radius.abs() < 1.0e-10
|| a.radius.abs() > 1.0e10
|| !finite_unit_normal(&a.normal)
}
@@ -2128,8 +2294,12 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool {
&& (s.knots.iter().any(|k| !k.is_finite())
|| s.knots.windows(2).any(|w| w[1] < w[0])
|| (!compact_periodic_knots && s.knots.len() != n + deg + 1));
// A single control point with no knots and no fit points cannot
// form a curve, and no knot vector exists for it.
let lone_point = n == 1 && s.knots.is_empty() && s.fit_points.is_empty();
n >= MAX_VERTS
|| degree_bad
|| lone_point
|| s.control_points.iter().any(|p| !finite_vec3(p))
|| knots_bad
}
@@ -2137,8 +2307,32 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool {
}
}
/// Give control-point splines stored without knots a degree their control
/// points can carry, and the matching clamped knot vector.
///
/// A degree-p curve needs at least p + 1 control points. Knot generation
/// (here, and in the DWG writer for knot-less splines) subtracts in `usize`
/// and underflows when there are fewer; in release builds the wrapped count
/// drives an unbounded allocation. Lowering the degree keeps the geometry the
/// points describe: two points are a straight segment at any degree.
fn normalize_knotless_splines(doc: &mut CadDocument) {
for entity in doc.entities_mut() {
let EntityType::Spline(spline) = entity else {
continue;
};
let n = spline.control_points.len();
if !spline.knots.is_empty() || n < 2 || spline.degree < 1 {
continue;
}
let degree = (spline.degree as usize).min(n - 1);
spline.degree = degree as i32;
spline.knots = acadrust::entities::Spline::generate_clamped_knots(degree, n);
}
}
pub fn purge_corrupt_entities(doc: &mut CadDocument) -> usize {
use crate::par::prelude::*;
normalize_knotless_splines(doc);
// Detection is pure and read-only; the per-vertex finite/extent checks on
// large polylines dominate, so fan the scan out across cores. Gather
// entity references in one pass, test in parallel, then remove serially
@@ -2337,9 +2531,70 @@ mod layer_roundtrip_tests {
#[cfg(test)]
mod corrupt_guard_tests {
use super::*;
use acadrust::entities::{Arc, EntityType, Spline};
use acadrust::entities::{Arc, Circle, EntityType, Spline};
use acadrust::types::Vector3;
fn knotless_spline(degree: i32, points: usize) -> Spline {
let mut spline = Spline::new();
spline.degree = degree;
spline.control_points = (0..points)
.map(|index| Vector3::new(index as f64, index as f64, 0.0))
.collect();
spline
}
// Degree 3 over two control points with no knot vector: generating knots
// for it underflows, and writing it to DWG used to panic (debug) or
// allocate without bound (release).
#[test]
fn knotless_spline_with_too_few_points_is_lowered_and_saves_as_dwg() {
let mut doc = CadDocument::new();
doc.add_entity(EntityType::Spline(knotless_spline(3, 2))).unwrap();
doc.add_entity(EntityType::Spline(knotless_spline(3, 1))).unwrap();
assert_eq!(purge_corrupt_entities(&mut doc), 1, "the lone point is dropped");
let spline = doc
.entities()
.find_map(|entity| match entity {
EntityType::Spline(spline) => Some(spline.clone()),
_ => None,
})
.expect("the two-point spline is kept");
assert_eq!(spline.degree, 1);
assert_eq!(spline.knots, vec![0.0, 0.0, 1.0, 1.0]);
let path = std::env::temp_dir().join(format!(
"ocs_knotless_spline_{}.dwg",
std::process::id()
));
save_as_version(&doc, &path, acadrust::DxfVersion::AC1032).expect("save");
let _ = std::fs::remove_file(&path);
}
// `load_file` (headless export, xrefs, block import) does not purge, so
// the spline repair has to happen when any load is finalized.
#[test]
fn knotless_spline_is_repaired_by_every_load_path() {
const DXF: &str = "0\nSECTION\n2\nENTITIES\n0\nSPLINE\n8\n0\n70\n0\n71\n3\n72\n0\n73\n2\n74\n0\n\
10\n0\n20\n0\n30\n0\n10\n1\n20\n1\n30\n0\n0\nENDSEC\n0\nEOF\n";
let dir = std::env::temp_dir();
let source = dir.join(format!("ocs_knotless_load_{}.dxf", std::process::id()));
let target = dir.join(format!("ocs_knotless_load_{}.dwg", std::process::id()));
std::fs::write(&source, DXF).unwrap();
let doc = load_file(&source).expect("load");
let _ = std::fs::remove_file(&source);
let spline = doc
.entities()
.find_map(|entity| match entity {
EntityType::Spline(spline) => Some(spline),
_ => None,
})
.expect("spline");
assert_eq!(spline.degree, 1);
assert_eq!(spline.knots.len(), 4);
save_as_version(&doc, &target, acadrust::DxfVersion::AC1032).expect("save");
let _ = std::fs::remove_file(&target);
}
// Small but finite arcs are valid records. Kernel tessellation is bounded,
// so opening must retain them instead of treating their size as corruption.
#[test]
@@ -2377,6 +2632,32 @@ mod corrupt_guard_tests {
assert!(!is_entity_corrupt(&EntityType::Arc(a)));
}
#[test]
fn keeps_zero_radius_circle_and_arc() {
let mut c = Circle::new();
c.center = Vector3::new(206.2, 150.7, 0.0);
c.radius = 0.0;
assert!(!is_entity_corrupt(&EntityType::Circle(c)));
let mut a = Arc::new();
a.center = Vector3::new(223.5, 174.5, 0.0);
a.radius = 0.0;
a.start_angle = 0.0;
a.end_angle = 0.0;
a.normal = Vector3::new(0.0, 0.0, 1.0);
assert!(!is_entity_corrupt(&EntityType::Arc(a)));
}
#[test]
fn drops_absurd_radius_circle() {
let mut c = Circle::new();
c.radius = 1.0e11;
assert!(is_entity_corrupt(&EntityType::Circle(c)));
let mut c = Circle::new();
c.radius = f64::NAN;
assert!(is_entity_corrupt(&EntityType::Circle(c)));
}
// Parser desync emits 100_000-control-point splines; building a kernel
// NURBS from one and tessellating it OOMs. The control-point cap rejects it.
#[test]
+261
View File
@@ -0,0 +1,261 @@
// Embed a raster image inside the drawing itself as an OLE2FRAME entity.
//
// The IMAGE mechanism (RasterImage + ImageDefinition) stores only a file path,
// so a drawing copied to another machine loses its pictures. An OLE2FRAME
// instead carries the encoded raster inside an OLE compound file embedded in
// the entity data, so
// the .dwg is self-contained. cadcodec both builds the compound storage and
// reads it back, and the renderer already paints OLE2FRAMEs via
// ImageModel::from_ole2frame, so this module only packs and unpacks.
use std::io::Write as _;
use std::path::Path;
use acadrust::compound_file::{
BinaryRecord, CompoundEntry, CompoundFile, CompoundStorage, CompoundStream,
CompoundStreamContent, StructuredStoragePayload,
};
use acadrust::entities::{Ole2Frame, OleFrameEnvelope, OleObjectType};
use acadrust::types::Vector3;
/// OLE compound-file stream holding OLE 1.0 native data. cadcodec's
/// presentation extractor looks here first; the body is the encoded raster
/// behind a 4-byte little-endian length.
const OLE10NATIVE: &str = "\u{1}Ole10Native";
/// Leading header marker carried verbatim by the geometry envelope; readers
/// use it to recognise the envelope around the compound file.
const GEOMETRY_MARKER: u16 = 1;
/// Encoded formats the extractor recognises by magic and every DWG consumer
/// can decode. Anything else is re-encoded as PNG before embedding.
const PORTABLE_FORMATS: [image::ImageFormat; 3] = [
image::ImageFormat::Png,
image::ImageFormat::Jpeg,
image::ImageFormat::Bmp,
];
/// A raster image prepared for embedding: encoded bytes plus the metadata the
/// placement prompts need.
#[derive(Clone, Debug, PartialEq)]
pub struct EmbeddedImage {
/// Encoded raster bytes (PNG / JPEG / BMP as stored or re-encoded).
pub bytes: Vec<u8>,
pub pixel_width: u32,
pub pixel_height: u32,
/// File name the picture came from — surfaces in prompts and properties.
pub name: String,
}
impl EmbeddedImage {
/// Prepare an image file for embedding. Recognised raster formats are
/// stored as-is; anything else is decoded and re-encoded as PNG so the
/// payload always stays renderable.
pub fn from_file(path: &Path) -> Result<Self, String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("image")
.to_string();
Self::from_bytes(name, bytes)
}
/// Prepare already-read image bytes for embedding (web file handles).
pub fn from_bytes(name: String, bytes: Vec<u8>) -> Result<Self, String> {
let format = image::guess_format(&bytes).map_err(|e| e.to_string())?;
let encoded = if PORTABLE_FORMATS.contains(&format) {
bytes
} else {
encode_png(&bytes)?
};
let img = image::load_from_memory(&encoded).map_err(|e| e.to_string())?;
let (pixel_width, pixel_height) = image::GenericImageView::dimensions(&img);
Ok(Self {
bytes: encoded,
pixel_width,
pixel_height,
name,
})
}
/// Width : height aspect ratio; a degenerate height falls back to square.
pub fn aspect(&self) -> f64 {
if self.pixel_height == 0 {
1.0
} else {
self.pixel_width as f64 / self.pixel_height as f64
}
}
}
fn encode_png(bytes: &[u8]) -> Result<Vec<u8>, String> {
let img = image::load_from_memory(bytes).map_err(|e| e.to_string())?;
let mut png = std::io::Cursor::new(Vec::new());
img.write_to(&mut png, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
Ok(png.into_inner())
}
/// World-space corners for a picture placed with its bottom-left corner at
/// `origin`, extending `width` right and `width / aspect` up — the same
/// rectangle the IMAGE command previews.
pub fn corners_from_placement(origin: Vector3, width: f64, aspect: f64) -> (Vector3, Vector3) {
let width = width.max(1e-6);
let height = width / aspect.max(1e-6);
(
Vector3::new(origin.x, origin.y + height, origin.z),
Vector3::new(origin.x + width, origin.y, origin.z),
)
}
/// Pack `image` into an OLE2FRAME spanning `upper_left` → `lower_right` in
/// world space. The picture rides in an `\x01Ole10Native` stream as OLE 1.0
/// native data behind the geometry envelope cadcodec writes around it.
pub fn build_embedded_ole(
image: &EmbeddedImage,
upper_left: Vector3,
lower_right: Vector3,
) -> Ole2Frame {
// OLE 1.0 native: 4-byte little-endian payload length, then the raster.
let mut native = Vec::with_capacity(4 + image.bytes.len());
let _ = native.write(&(image.bytes.len() as u32).to_le_bytes());
native.extend_from_slice(&image.bytes);
let file = CompoundFile {
minor_version: 0x003E,
major_version: 3,
transaction_signature: 0,
root: CompoundStorage {
name: "Root Entry".to_string(),
class_id: [0; 16],
state_bits: 0,
creation_time: 0,
modified_time: 0,
entries: vec![CompoundEntry::Stream(CompoundStream {
name: OLE10NATIVE.to_string(),
class_id: [0; 16],
state_bits: 0,
creation_time: 0,
modified_time: 0,
content: CompoundStreamContent::Binary(BinaryRecord::split(&native, 4096)),
})],
},
};
let mut ole = Ole2Frame::new();
ole.ole_object_type = OleObjectType::Embedded;
ole.source_application = "OpenCADStudio".to_string();
ole.upper_left_corner = upper_left;
ole.lower_right_corner = lower_right;
ole.envelope = OleFrameEnvelope::Geometry {
marker: GEOMETRY_MARKER,
extension_records: Vec::new(),
};
ole.storage = StructuredStoragePayload {
leading_records: Vec::new(),
compound_file: Some(file),
trailing_records: Vec::new(),
};
ole
}
/// Non-interactive embed used by the control API: add the entity to `doc` and
/// return its handle. The undo snapshot is the caller's responsibility.
pub fn add_embedded_image(
doc: &mut acadrust::CadDocument,
image: &EmbeddedImage,
origin: Vector3,
width: f64,
) -> Result<acadrust::Handle, String> {
let (upper_left, lower_right) = corners_from_placement(origin, width, image.aspect());
let ole = build_embedded_ole(image, upper_left, lower_right);
doc.add_entity(acadrust::EntityType::Ole2Frame(ole))
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
/// Minimal valid PNG: a solid-colour 4×3 image through the `image` crate.
fn png_bytes(width: u32, height: u32) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(width, height, image::Rgba([10, 20, 30, 255]));
let mut png = std::io::Cursor::new(Vec::new());
img.write_to(&mut png, image::ImageFormat::Png).unwrap();
png.into_inner()
}
#[test]
fn from_bytes_keeps_png_and_measures_pixels() {
let embedded = EmbeddedImage::from_bytes("t.png".into(), png_bytes(4, 3)).unwrap();
assert_eq!(embedded.pixel_width, 4);
assert_eq!(embedded.pixel_height, 3);
assert_eq!(embedded.bytes, png_bytes(4, 3));
}
#[test]
fn from_bytes_reencodes_foreign_formats_as_png() {
// TIFF starts with II*/MM* and is not in the portable set, so it must
// come back re-encoded as PNG.
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 3, 255]));
let mut tiff = std::io::Cursor::new(Vec::new());
img.write_to(&mut tiff, image::ImageFormat::Tiff).unwrap();
let embedded = EmbeddedImage::from_bytes("t.tif".into(), tiff.into_inner()).unwrap();
assert!(embedded.bytes.starts_with(&[0x89, b'P', b'N', b'G']));
}
#[test]
fn packed_payload_roundtrips_through_the_extractor() {
let png = png_bytes(4, 3);
let image = EmbeddedImage {
bytes: png.clone(),
pixel_width: 4,
pixel_height: 3,
name: "t.png".into(),
};
let ole = build_embedded_ole(
&image,
Vector3::new(0.0, 3.0, 0.0),
Vector3::new(4.0, 0.0, 0.0),
);
let payload = ole.encoded_payload();
match acadrust::entities::extract_presentation(&payload) {
Some(acadrust::entities::OlePresentation::Raster(bytes)) => assert_eq!(bytes, png),
other => panic!("expected the embedded raster back, got {other:?}"),
}
}
#[test]
fn dwg_roundtrip_preserves_the_embedded_picture() {
let png = png_bytes(4, 3);
let image = EmbeddedImage {
bytes: png.clone(),
pixel_width: 4,
pixel_height: 3,
name: "t.png".into(),
};
let mut doc = acadrust::CadDocument::new();
let handle =
add_embedded_image(&mut doc, &image, Vector3::new(10.0, 10.0, 0.0), 20.0).unwrap();
let dir = std::env::temp_dir().join("ocs_ole_embed_tests");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("embedded_roundtrip.dwg");
acadrust::DwgWriter::write_to_file(&path, &doc).unwrap();
let mut reader = acadrust::io::dwg::DwgReader::from_file(&path).unwrap();
let reopened = reader.read().unwrap();
std::fs::remove_file(&path).ok();
let ole = match reopened.get_entity(handle) {
Some(acadrust::EntityType::Ole2Frame(ole)) => ole,
other => panic!("expected the Ole2Frame back, got {other:?}"),
};
// Corners survive: 20 wide at aspect 4:3 → 15 tall, base at (10,10).
assert_eq!(ole.lower_right_corner, Vector3::new(30.0, 10.0, 0.0));
assert_eq!(ole.upper_left_corner, Vector3::new(10.0, 25.0, 0.0));
match acadrust::entities::extract_presentation(&ole.encoded_payload()) {
Some(acadrust::entities::OlePresentation::Raster(bytes)) => assert_eq!(bytes, png),
other => panic!("expected the embedded raster back, got {other:?}"),
}
}
}
+867
View File
@@ -0,0 +1,867 @@
//! Paper catalogue with the source application-compatible media names.
//!
//! the source application stores a layout's paper as the plotter driver's *canonical media
//! name* — `ISO_A4_(210.00_x_297.00_MM)`, `ANSI_B_(11.00_x_17.00_Inches)`,
//! `ARCH_full_bleed_D_(24.00_x_36.00_Inches)` — always with the portrait
//! dimensions; landscape is expressed through the plot rotation. A drawing
//! saved here has to carry those exact names so the source application finds the media in its
//! `DWG To PDF.pc3` list instead of silently falling back to a default sheet,
//! and a name that arrives from the source application (including a custom size this catalogue
//! has never seen) must still resolve to its dimensions and go back out
//! untouched. The name itself encodes the dimensions, which is what makes the
//! second half possible: [`PaperSize::parse_canonical`] never needs a table.
use std::borrow::Cow;
use std::sync::OnceLock;
/// Unit the media is defined in. the source application keeps ISO/JIS sheets in millimetres
/// and ANSI/ARCH sheets in inches, and the unit is part of the media name.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, serde::Serialize, serde::Deserialize)]
pub enum PaperUnits {
Millimeters,
Inches,
}
impl PaperUnits {
pub const MM_PER_INCH: f64 = 25.4;
pub fn to_mm(self, value: f64) -> f64 {
match self {
PaperUnits::Millimeters => value,
PaperUnits::Inches => value * Self::MM_PER_INCH,
}
}
pub fn from_mm(self, value: f64) -> f64 {
match self {
PaperUnits::Millimeters => value,
PaperUnits::Inches => value / Self::MM_PER_INCH,
}
}
/// The unit token the source application writes inside the media name.
fn canonical_suffix(self) -> &'static str {
match self {
PaperUnits::Millimeters => "MM",
PaperUnits::Inches => "Inches",
}
}
fn from_canonical_suffix(token: &str) -> Option<Self> {
if token.eq_ignore_ascii_case("MM") {
Some(PaperUnits::Millimeters)
} else if token.eq_ignore_ascii_case("Inches") || token.eq_ignore_ascii_case("Inch") {
Some(PaperUnits::Inches)
} else {
None
}
}
/// Short unit label for display (`mm` / `in`).
pub fn short_label(self) -> &'static str {
match self {
PaperUnits::Millimeters => "mm",
PaperUnits::Inches => "in",
}
}
}
/// The sheet family, used to group the picker and to prefer a standard sheet
/// over its full-bleed twin when matching by dimensions.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
pub enum PaperSeries {
IsoA,
IsoB,
Ansi,
Arch,
Jis,
/// Letter / Legal / Tabloid under their US names, and any user-defined sheet.
Other,
}
/// the source application offers three printable-area variants of most sheets: the standard
/// one with driver margins, *full bleed* with zero margins, and *expand*
/// (the driver's maximum printable area). The sheet dimensions are identical;
/// only the media name and the margins differ.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
pub enum PaperVariant {
Standard,
FullBleed,
Expand,
}
impl PaperVariant {
/// The infix the source application places between the series and the sheet letter.
fn canonical_infix(self) -> &'static str {
match self {
PaperVariant::Standard => "",
PaperVariant::FullBleed => "full_bleed_",
PaperVariant::Expand => "expand_",
}
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum Orientation {
Portrait,
Landscape,
}
/// Unprintable margins of a sheet, in the order the source application stores them. The unit
/// is whatever the context says — millimetres in a drawing, the sheet's own
/// unit in a user-defined size.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Margins {
pub left: f64,
pub bottom: f64,
pub right: f64,
pub top: f64,
}
impl Margins {
pub const ZERO: Margins = Margins {
left: 0.0,
bottom: 0.0,
right: 0.0,
top: 0.0,
};
pub const fn uniform(value: f64) -> Self {
Margins {
left: value,
bottom: value,
right: value,
top: value,
}
}
/// The same margins expressed in millimetres when they were given in
/// `units`.
pub fn to_mm(self, units: PaperUnits) -> Self {
Margins {
left: units.to_mm(self.left),
bottom: units.to_mm(self.bottom),
right: units.to_mm(self.right),
top: units.to_mm(self.top),
}
}
}
/// A sheet the user defined, kept in the application settings the way
/// the source application keeps custom sizes in a plotter configuration: a name, the
/// dimensions in the sheet's own unit, and the printable margins.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct CustomPaper {
/// Human name, e.g. `Roll 24`; becomes `Roll_24_(…)` in the media name.
pub name: String,
pub width: f64,
pub height: f64,
pub units: PaperUnits,
/// Unprintable margins in `units`.
pub margins: Margins,
}
impl Default for CustomPaper {
fn default() -> Self {
Self {
name: String::new(),
width: 0.0,
height: 0.0,
units: PaperUnits::Millimeters,
margins: Margins::ZERO,
}
}
}
impl CustomPaper {
/// The catalogue sheet this definition describes.
pub fn paper(&self) -> PaperSize {
PaperSize::custom_named(&self.name, self.width, self.height, self.units)
}
pub fn canonical(&self) -> String {
self.paper().canonical.into_owned()
}
pub fn margins_mm(&self) -> Margins {
self.margins.to_mm(self.units)
}
}
/// One sheet of the catalogue, or a sheet reconstructed from a media name.
#[derive(Clone, PartialEq, Debug)]
pub struct PaperSize {
/// the source application canonical media name, e.g. `ISO_A4_(210.00_x_297.00_MM)`. This is
/// the value written to `PlotSettings::paper_size` and the key the plot
/// dialog stores.
pub canonical: Cow<'static, str>,
/// Human name without dimensions: `ISO A4`, `ANSI full bleed B`.
pub label: Cow<'static, str>,
pub series: PaperSeries,
pub variant: PaperVariant,
/// Portrait width in `units` (width ≤ height).
pub width: f64,
/// Portrait height in `units`.
pub height: f64,
pub units: PaperUnits,
}
impl PaperSize {
/// Portrait dimensions in millimetres.
pub fn portrait_mm(&self) -> (f64, f64) {
(self.units.to_mm(self.width), self.units.to_mm(self.height))
}
/// Sheet dimensions in millimetres for the given orientation.
pub fn sheet_mm(&self, orientation: Orientation) -> (f64, f64) {
let (w, h) = self.portrait_mm();
match orientation {
Orientation::Portrait => (w, h),
Orientation::Landscape => (h, w),
}
}
/// Picker text: the human name followed by the dimensions in the sheet's
/// own unit — `ISO A4 (210 × 297 mm)`, `ANSI B (11.00 × 17.00 in)`.
pub fn display(&self) -> String {
format!(
"{} ({} × {} {})",
self.label,
format_dimension(self.width, self.units),
format_dimension(self.height, self.units),
self.units.short_label()
)
}
/// A user-defined sheet, named the way the source application names custom media created
/// in the plot dialog (`UserDefinedMetric_(420.00_x_297.00_MM)`). The
/// dimensions are normalised to portrait so the name stays canonical.
pub fn custom(width: f64, height: f64, units: PaperUnits) -> Self {
let label = match units {
PaperUnits::Millimeters => "UserDefinedMetric",
PaperUnits::Inches => "UserDefinedInches",
};
Self::custom_named(label, width, height, units)
}
/// A user-defined sheet under its own name, the way a plotter
/// configuration's custom sizes are named (`Roll_24_(609.60_x_1500.00_MM)`).
/// A blank name falls back to the source application's generic one; spaces become
/// underscores in the media name and parentheses are dropped so the name
/// stays parseable.
pub fn custom_named(name: &str, width: f64, height: f64, units: PaperUnits) -> Self {
let (width, height) = if width <= height {
(width, height)
} else {
(height, width)
};
let cleaned: String = name
.trim()
.chars()
.filter(|c| !matches!(c, '(' | ')'))
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if cleaned.is_empty() {
return Self::custom(width, height, units);
}
Self {
canonical: Cow::Owned(canonical_name(
&cleaned.replace(' ', "_"),
width,
height,
units,
)),
label: Cow::Owned(cleaned),
series: PaperSeries::Other,
variant: PaperVariant::Standard,
width,
height,
units,
}
}
/// Rebuild a sheet from any media name that follows the source application's
/// `<name>_(<width>_x_<height>_<MM|Inches>)` convention, whether or not the
/// catalogue knows it. The dialog form with spaces (`ISO A4 (210.00 x
/// 297.00 MM)`) is accepted too. Returns `None` for names without an
/// embedded dimension group (a bare printer paper name such as `A4`, or a
/// raster `Pixels` size).
pub fn parse_canonical(name: &str) -> Option<Self> {
let name = name.trim();
let open = name.rfind('(')?;
let close = name[open..].find(')')? + open;
let dims = name[open + 1..close].trim_matches(['_', ' ']);
let raw_label = name[..open].trim_matches(['_', ' ']);
if raw_label.is_empty() {
return None;
}
// `210.00_x_297.00_MM`, or `210.00 x 297.00 MM` from a dialog string.
let normalized: String = dims.replace(' ', "_");
let mut parts = normalized.split('_').filter(|part| !part.is_empty());
let width: f64 = parts.next()?.parse().ok()?;
if !parts.next()?.eq_ignore_ascii_case("x") {
return None;
}
let height: f64 = parts.next()?.parse().ok()?;
let units = PaperUnits::from_canonical_suffix(parts.next()?)?;
if parts.next().is_some()
|| width <= 0.0
|| height <= 0.0
|| !width.is_finite()
|| !height.is_finite()
{
return None;
}
let label = raw_label.replace('_', " ");
let (series, variant) = classify_label(&label);
let (portrait_width, portrait_height) = if width <= height {
(width, height)
} else {
(height, width)
};
Some(Self {
canonical: Cow::Owned(canonical_name(
&raw_label.replace(' ', "_"),
width,
height,
units,
)),
label: Cow::Owned(label),
series,
variant,
width: portrait_width,
height: portrait_height,
units,
})
}
}
fn format_dimension(value: f64, units: PaperUnits) -> String {
match units {
// Millimetre sheets are whole numbers in every standard; keep the
// fraction only when a custom size needs it.
PaperUnits::Millimeters if (value - value.round()).abs() < 1e-6 => {
format!("{}", value.round() as i64)
}
PaperUnits::Millimeters => format!("{value:.1}"),
PaperUnits::Inches => format!("{value:.2}"),
}
}
/// `<prefix>_(<w>_x_<h>_<units>)` exactly as the source application spells it: two decimals,
/// portrait order.
fn canonical_name(prefix: &str, width: f64, height: f64, units: PaperUnits) -> String {
format!(
"{prefix}_({width:.2}_x_{height:.2}_{})",
units.canonical_suffix()
)
}
/// Series and variant from a human label (`ISO full bleed A4`, `ARCH expand D`).
fn classify_label(label: &str) -> (PaperSeries, PaperVariant) {
let lower = label.to_ascii_lowercase();
let variant = if lower.contains("full bleed") {
PaperVariant::FullBleed
} else if lower.contains("expand") {
PaperVariant::Expand
} else {
PaperVariant::Standard
};
let series = if lower.starts_with("iso b") {
PaperSeries::IsoB
} else if lower.starts_with("iso") {
PaperSeries::IsoA
} else if lower.starts_with("ansi") {
PaperSeries::Ansi
} else if lower.starts_with("arch") {
PaperSeries::Arch
} else if lower.starts_with("jis") {
PaperSeries::Jis
} else {
PaperSeries::Other
};
(series, variant)
}
// ── Catalogue ─────────────────────────────────────────────────────────────
/// Compact source row: series prefix as the source application writes it, sheet letter,
/// portrait width/height, units, and whether the source application ships full-bleed /
/// expand twins of it in `DWG To PDF.pc3`.
struct Row {
series: PaperSeries,
prefix: &'static str,
sheet: &'static str,
width: f64,
height: f64,
units: PaperUnits,
twins: bool,
}
const fn mm(
series: PaperSeries,
prefix: &'static str,
sheet: &'static str,
w: f64,
h: f64,
twins: bool,
) -> Row {
Row {
series,
prefix,
sheet,
width: w,
height: h,
units: PaperUnits::Millimeters,
twins,
}
}
const fn inch(
series: PaperSeries,
prefix: &'static str,
sheet: &'static str,
w: f64,
h: f64,
twins: bool,
) -> Row {
Row {
series,
prefix,
sheet,
width: w,
height: h,
units: PaperUnits::Inches,
twins,
}
}
/// The sheets the source application's `DWG To PDF.pc3` offers, in its display order, plus
/// the ISO B / JIS B series that other drivers add and the US names system
/// printers report.
const ROWS: &[Row] = &[
mm(PaperSeries::IsoA, "ISO", "A0", 841.0, 1189.0, true),
mm(PaperSeries::IsoA, "ISO", "A1", 594.0, 841.0, true),
mm(PaperSeries::IsoA, "ISO", "A2", 420.0, 594.0, true),
mm(PaperSeries::IsoA, "ISO", "A3", 297.0, 420.0, true),
mm(PaperSeries::IsoA, "ISO", "A4", 210.0, 297.0, true),
mm(PaperSeries::IsoA, "ISO", "A5", 148.0, 210.0, false),
mm(PaperSeries::IsoB, "ISO", "B0", 1000.0, 1414.0, false),
mm(PaperSeries::IsoB, "ISO", "B1", 707.0, 1000.0, false),
mm(PaperSeries::IsoB, "ISO", "B2", 500.0, 707.0, false),
mm(PaperSeries::IsoB, "ISO", "B3", 353.0, 500.0, false),
mm(PaperSeries::IsoB, "ISO", "B4", 250.0, 353.0, false),
mm(PaperSeries::IsoB, "ISO", "B5", 176.0, 250.0, false),
inch(PaperSeries::Ansi, "ANSI", "A", 8.5, 11.0, true),
inch(PaperSeries::Ansi, "ANSI", "B", 11.0, 17.0, true),
inch(PaperSeries::Ansi, "ANSI", "C", 17.0, 22.0, true),
inch(PaperSeries::Ansi, "ANSI", "D", 22.0, 34.0, true),
inch(PaperSeries::Ansi, "ANSI", "E", 34.0, 44.0, true),
inch(PaperSeries::Arch, "ARCH", "A", 9.0, 12.0, true),
inch(PaperSeries::Arch, "ARCH", "B", 12.0, 18.0, true),
inch(PaperSeries::Arch, "ARCH", "C", 18.0, 24.0, true),
inch(PaperSeries::Arch, "ARCH", "D", 24.0, 36.0, true),
inch(PaperSeries::Arch, "ARCH", "E", 36.0, 48.0, true),
inch(PaperSeries::Arch, "ARCH", "E1", 30.0, 42.0, true),
mm(PaperSeries::Jis, "JIS", "B0", 1030.0, 1456.0, false),
mm(PaperSeries::Jis, "JIS", "B1", 728.0, 1030.0, false),
mm(PaperSeries::Jis, "JIS", "B2", 515.0, 728.0, false),
mm(PaperSeries::Jis, "JIS", "B3", 364.0, 515.0, false),
mm(PaperSeries::Jis, "JIS", "B4", 257.0, 364.0, false),
mm(PaperSeries::Jis, "JIS", "B5", 182.0, 257.0, false),
inch(PaperSeries::Other, "Letter", "", 8.5, 11.0, false),
inch(PaperSeries::Other, "Legal", "", 8.5, 14.0, false),
inch(PaperSeries::Other, "Tabloid", "", 11.0, 17.0, false),
];
fn build_catalog() -> Vec<PaperSize> {
let mut sizes = Vec::with_capacity(ROWS.len() * 3);
for row in ROWS {
let variants: &[PaperVariant] = if row.twins {
&[
PaperVariant::Standard,
PaperVariant::FullBleed,
PaperVariant::Expand,
]
} else {
&[PaperVariant::Standard]
};
for &variant in variants {
// `ISO_full_bleed_A4`, `ANSI_expand_B`, `Letter`.
let prefix = if row.sheet.is_empty() {
row.prefix.to_string()
} else {
format!("{}_{}{}", row.prefix, variant.canonical_infix(), row.sheet)
};
sizes.push(PaperSize {
canonical: Cow::Owned(canonical_name(&prefix, row.width, row.height, row.units)),
label: Cow::Owned(prefix.replace('_', " ")),
series: row.series,
variant,
width: row.width,
height: row.height,
units: row.units,
});
}
}
sizes
}
/// Every catalogued sheet in picker order.
pub fn catalog() -> &'static [PaperSize] {
static CATALOG: OnceLock<Vec<PaperSize>> = OnceLock::new();
CATALOG.get_or_init(build_catalog)
}
/// The sheet a fresh drawing or a page setup without one falls back to.
pub fn default_paper() -> &'static PaperSize {
catalog()
.iter()
.find(|paper| paper.canonical == "ISO_A4_(210.00_x_297.00_MM)")
.expect("catalogue always carries ISO A4")
}
/// Short names a system printer driver, an older Open CAD Studio config or a
/// user at the command line may spell a sheet with, mapped onto the canonical
/// sheet. `Ledger` is the landscape name of Tabloid; the sheet is the same.
const ALIASES: &[(&str, &str)] = &[
("A0", "ISO_A0_(841.00_x_1189.00_MM)"),
("A1", "ISO_A1_(594.00_x_841.00_MM)"),
("A2", "ISO_A2_(420.00_x_594.00_MM)"),
("A3", "ISO_A3_(297.00_x_420.00_MM)"),
("A4", "ISO_A4_(210.00_x_297.00_MM)"),
("A5", "ISO_A5_(148.00_x_210.00_MM)"),
("B4", "ISO_B4_(250.00_x_353.00_MM)"),
("B5", "ISO_B5_(176.00_x_250.00_MM)"),
("Letter", "Letter_(8.50_x_11.00_Inches)"),
("US Letter", "Letter_(8.50_x_11.00_Inches)"),
("Legal", "Legal_(8.50_x_14.00_Inches)"),
("US Legal", "Legal_(8.50_x_14.00_Inches)"),
("Tabloid", "Tabloid_(11.00_x_17.00_Inches)"),
("Ledger", "Tabloid_(11.00_x_17.00_Inches)"),
];
/// Resolve any spelling of a sheet: an exact canonical name (case-insensitive,
/// underscores or spaces), a driver/config alias such as `A4` or `Letter`, or
/// an unknown but well-formed the source application name, which is reconstructed from its
/// embedded dimensions. `None` only for names that carry no size at all.
pub fn resolve(name: &str) -> Option<PaperSize> {
let name = name.trim();
if name.is_empty() {
return None;
}
let spaced = name.replace('_', " ");
if let Some(paper) = catalog().iter().find(|paper| {
paper
.canonical
.replace('_', " ")
.eq_ignore_ascii_case(&spaced)
}) {
return Some(paper.clone());
}
if let Some((_, canonical)) = ALIASES
.iter()
.find(|(alias, _)| alias.eq_ignore_ascii_case(name))
{
return catalog()
.iter()
.find(|paper| paper.canonical == *canonical)
.cloned();
}
PaperSize::parse_canonical(name)
}
/// The catalogued sheet whose portrait size matches `(w, h)` in millimetres
/// (either order) within half a millimetre. Standard variants win over their
/// full-bleed / expand twins so a size read from a drawing without a usable
/// name lands on the plain sheet.
pub fn match_dimensions_mm(w: f64, h: f64) -> Option<&'static PaperSize> {
const TOLERANCE_MM: f64 = 0.5;
let (short, long) = if w <= h { (w, h) } else { (h, w) };
catalog()
.iter()
.filter(|paper| paper.variant == PaperVariant::Standard)
.filter(|paper| {
let (pw, ph) = paper.portrait_mm();
(pw - short).abs() <= TOLERANCE_MM && (ph - long).abs() <= TOLERANCE_MM
})
// ANSI A and Letter share a size; prefer the named series.
.min_by_key(|paper| paper.series)
}
/// Sheet for a name as read from a drawing: the catalogued or reconstructed
/// sheet when the name carries one, otherwise the catalogued sheet with the
/// given dimensions, otherwise a custom sheet built from the dimensions alone.
pub fn from_drawing(name: &str, width_mm: f64, height_mm: f64) -> PaperSize {
if let Some(paper) = resolve(name) {
return paper;
}
if let Some(paper) = match_dimensions_mm(width_mm, height_mm) {
return paper.clone();
}
if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
return default_paper().clone();
}
PaperSize::custom(width_mm, height_mm, PaperUnits::Millimeters)
}
// ── Plot geometry helpers ─────────────────────────────────────────────────
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum PlotScale {
/// Scale so the window fills the sheet minus a 5% margin.
Fit,
/// Exact scale factor (mm per drawing unit).
Ratio(f64),
}
/// Map a world-space window onto a sheet. Returns (scale, offset_x, offset_y),
/// where the scaled window is centered on the sheet and (offset_x, offset_y) is
/// the sheet-mm position of the window's min corner. The caller turns that into
/// its own coordinate offset.
pub fn window_to_sheet(
window_wh: (f64, f64),
sheet_mm: (f64, f64),
scale: PlotScale,
) -> (f64, f64, f64) {
let (ww, wh) = (window_wh.0.max(1e-9), window_wh.1.max(1e-9));
let scale = match scale {
PlotScale::Ratio(r) => r.max(1e-9),
PlotScale::Fit => {
const MARGIN: f64 = 1.05;
let sx = (sheet_mm.0 / MARGIN) / ww;
let sy = (sheet_mm.1 / MARGIN) / wh;
sx.min(sy)
}
};
let scaled_w = ww * scale;
let scaled_h = wh * scale;
let offset_x = (sheet_mm.0 - scaled_w) / 2.0;
let offset_y = (sheet_mm.1 - scaled_h) / 2.0;
(scale, offset_x, offset_y)
}
#[cfg(test)]
mod tests {
use super::*;
fn a4() -> PaperSize {
resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap()
}
#[test]
fn catalogue_names_follow_source_app_spelling() {
let names: Vec<&str> = catalog().iter().map(|p| p.canonical.as_ref()).collect();
for expected in [
"ISO_A4_(210.00_x_297.00_MM)",
"ISO_full_bleed_A4_(210.00_x_297.00_MM)",
"ISO_expand_A0_(841.00_x_1189.00_MM)",
"ISO_B4_(250.00_x_353.00_MM)",
"ANSI_A_(8.50_x_11.00_Inches)",
"ANSI_full_bleed_B_(11.00_x_17.00_Inches)",
"ARCH_D_(24.00_x_36.00_Inches)",
"ARCH_expand_E1_(30.00_x_42.00_Inches)",
"JIS_B4_(257.00_x_364.00_MM)",
"Letter_(8.50_x_11.00_Inches)",
"Legal_(8.50_x_14.00_Inches)",
"Tabloid_(11.00_x_17.00_Inches)",
] {
assert!(names.contains(&expected), "missing {expected}");
}
// Every name is portrait and unique.
let mut seen = std::collections::HashSet::new();
for paper in catalog() {
assert!(
paper.width <= paper.height,
"{} is not portrait",
paper.canonical
);
assert!(
seen.insert(paper.canonical.clone()),
"duplicate {}",
paper.canonical
);
}
}
#[test]
fn every_catalogue_name_round_trips_through_the_parser() {
for paper in catalog() {
let parsed = PaperSize::parse_canonical(&paper.canonical)
.unwrap_or_else(|| panic!("cannot parse {}", paper.canonical));
assert_eq!(parsed.canonical, paper.canonical);
assert_eq!(parsed.label, paper.label);
assert_eq!(parsed.series, paper.series);
assert_eq!(parsed.variant, paper.variant);
assert_eq!(parsed.units, paper.units);
assert!((parsed.width - paper.width).abs() < 1e-9);
assert!((parsed.height - paper.height).abs() < 1e-9);
}
}
#[test]
fn unknown_canonical_names_are_reconstructed_and_preserved() {
// A custom size created in a plot dialog.
let custom = resolve("UserDefinedMetric_(420.00_x_297.00_MM)").unwrap();
assert_eq!(custom.canonical, "UserDefinedMetric_(420.00_x_297.00_MM)");
assert_eq!(custom.label, "UserDefinedMetric");
assert_eq!(custom.series, PaperSeries::Other);
assert_eq!(custom.portrait_mm(), (297.0, 420.0));
// A driver sheet the catalogue does not list, in inches.
let oversize = resolve("Oversize_A1_(625.00_x_880.00_MM)").unwrap();
assert_eq!(oversize.portrait_mm(), (625.0, 880.0));
// The dialog spelling with spaces resolves to the same canonical name.
let spaced = resolve("ISO full bleed A3 (297.00 x 420.00 MM)").unwrap();
assert_eq!(spaced.canonical, "ISO_full_bleed_A3_(297.00_x_420.00_MM)");
assert_eq!(spaced.variant, PaperVariant::FullBleed);
assert_eq!(spaced.series, PaperSeries::IsoA);
// A raster size or a bare driver name carries no usable dimensions.
assert!(resolve("Sun_Hi-Res_(1600.00_x_1280.00_Pixels)").is_none());
assert!(resolve("A4 Plain").is_none());
assert!(resolve("").is_none());
}
#[test]
fn aliases_and_case_insensitive_names_resolve() {
assert_eq!(resolve("A4").unwrap().canonical, a4().canonical);
assert_eq!(
resolve("a3").unwrap().canonical,
"ISO_A3_(297.00_x_420.00_MM)"
);
assert_eq!(
resolve("Letter").unwrap().canonical,
"Letter_(8.50_x_11.00_Inches)"
);
assert_eq!(
resolve("Ledger").unwrap().canonical,
"Tabloid_(11.00_x_17.00_Inches)"
);
assert_eq!(
resolve("iso_a4_(210.00_x_297.00_mm)").unwrap().canonical,
a4().canonical
);
}
#[test]
fn dimensions_match_the_plain_sheet_in_either_orientation() {
assert_eq!(
match_dimensions_mm(297.0, 210.0).unwrap().canonical,
a4().canonical
);
assert_eq!(
match_dimensions_mm(210.0, 297.0).unwrap().canonical,
a4().canonical
);
assert_eq!(
match_dimensions_mm(210.3, 297.4).unwrap().canonical,
a4().canonical
);
assert!(match_dimensions_mm(215.0, 297.0).is_none());
// 8.5 × 11 in prefers ANSI A over the Letter alias sheet.
assert_eq!(
match_dimensions_mm(215.9, 279.4).unwrap().canonical,
"ANSI_A_(8.50_x_11.00_Inches)"
);
assert_eq!(
match_dimensions_mm(609.6, 914.4).unwrap().canonical,
"ARCH_D_(24.00_x_36.00_Inches)"
);
}
#[test]
fn from_drawing_falls_back_from_name_to_size_to_custom() {
assert_eq!(
from_drawing("ISO_A3_(297.00_x_420.00_MM)", 0.0, 0.0).label,
"ISO A3"
);
// A legacy drawing with a bare driver name and A4 dimensions.
assert_eq!(
from_drawing("Plain paper", 297.0, 210.0).canonical,
a4().canonical
);
let custom = from_drawing("", 300.0, 500.0);
assert_eq!(custom.canonical, "UserDefinedMetric_(300.00_x_500.00_MM)");
assert_eq!(custom.portrait_mm(), (300.0, 500.0));
assert_eq!(from_drawing("", 0.0, f64::INFINITY), *default_paper());
}
#[test]
fn parser_rejects_non_finite_dimensions() {
assert!(PaperSize::parse_canonical("Custom_(inf_x_297.00_MM)").is_none());
assert!(PaperSize::parse_canonical("Custom_(210.00_x_NaN_MM)").is_none());
}
#[test]
fn sheet_and_display_formatting() {
assert_eq!(a4().sheet_mm(Orientation::Portrait), (210.0, 297.0));
assert_eq!(a4().sheet_mm(Orientation::Landscape), (297.0, 210.0));
assert_eq!(a4().display(), "ISO A4 (210 × 297 mm)");
let ansi_b = resolve("ANSI_B_(11.00_x_17.00_Inches)").unwrap();
assert_eq!(ansi_b.display(), "ANSI B (11.00 × 17.00 in)");
let (w, h) = ansi_b.portrait_mm();
assert!((w - 279.4).abs() < 1e-9 && (h - 431.8).abs() < 1e-9);
assert_eq!(
PaperSize::custom(297.5, 210.0, PaperUnits::Millimeters).display(),
"UserDefinedMetric (210 × 297.5 mm)"
);
assert_eq!(default_paper().canonical, a4().canonical);
}
#[test]
fn user_defined_sheets_carry_their_name_and_margins() {
let roll = CustomPaper {
name: "Roll 24 (long)".into(),
width: 1500.0,
height: 609.6,
units: PaperUnits::Millimeters,
margins: Margins {
left: 5.0,
bottom: 17.0,
right: 6.0,
top: 18.0,
},
};
assert_eq!(roll.canonical(), "Roll_24_long_(609.60_x_1500.00_MM)");
assert_eq!(roll.paper().label, "Roll 24 long");
assert_eq!(roll.paper().display(), "Roll 24 long (609.6 × 1500 mm)");
// Round-trips through the parser like any the source application name.
assert_eq!(
resolve(&roll.canonical()).unwrap().portrait_mm(),
(609.6, 1500.0)
);
let arch = CustomPaper {
name: String::new(),
width: 30.0,
height: 42.0,
units: PaperUnits::Inches,
margins: Margins::uniform(0.25),
};
assert_eq!(arch.canonical(), "UserDefinedInches_(30.00_x_42.00_Inches)");
assert_eq!(arch.margins_mm(), Margins::uniform(6.35));
}
#[test]
fn fit_centers_and_scales_within_margin() {
// 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width:
// usable = 210/1.05 = 200; scale = 200/100 = 2.0.
let (s, ox, oy) = window_to_sheet((100.0, 100.0), (210.0, 297.0), PlotScale::Fit);
assert!((s - 2.0).abs() < 1e-9, "scale {s}");
// Window is 100*2 = 200 wide/tall; centered on 210×297.
assert!((ox - (210.0 - 200.0) / 2.0).abs() < 1e-9, "ox {ox}");
assert!((oy - (297.0 - 200.0) / 2.0).abs() < 1e-9, "oy {oy}");
}
#[test]
fn ratio_applies_exact_scale_and_centers() {
// Ratio(0.5): scale is exactly 0.5; the scaled window is centered and
// (ox, oy) is the sheet-mm position of its min corner.
let (s, ox, oy) = window_to_sheet((100.0, 80.0), (210.0, 297.0), PlotScale::Ratio(0.5));
assert!((s - 0.5).abs() < 1e-9);
// scaled window = 50×40; centered box origin = ((210-50)/2,(297-40)/2).
assert!((ox - (210.0 - 50.0) / 2.0).abs() < 1e-9, "ox {ox}");
assert!((oy - (297.0 - 40.0) / 2.0).abs() < 1e-9, "oy {oy}");
}
}
-143
View File
@@ -1,143 +0,0 @@
//! Standard paper sizes and sheet orientation for window plotting.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum PaperSize {
A4,
A3,
A2,
A1,
A0,
Letter,
Legal,
Tabloid,
}
impl PaperSize {
pub const ALL: [PaperSize; 8] = [
PaperSize::A4,
PaperSize::A3,
PaperSize::A2,
PaperSize::A1,
PaperSize::A0,
PaperSize::Letter,
PaperSize::Legal,
PaperSize::Tabloid,
];
pub fn label(self) -> &'static str {
match self {
PaperSize::A4 => "A4",
PaperSize::A3 => "A3",
PaperSize::A2 => "A2",
PaperSize::A1 => "A1",
PaperSize::A0 => "A0",
PaperSize::Letter => "Letter",
PaperSize::Legal => "Legal",
PaperSize::Tabloid => "Tabloid",
}
}
/// Portrait dimensions in mm (width, height); width < height.
pub fn dimensions_mm(self) -> (f64, f64) {
match self {
PaperSize::A4 => (210.0, 297.0),
PaperSize::A3 => (297.0, 420.0),
PaperSize::A2 => (420.0, 594.0),
PaperSize::A1 => (594.0, 841.0),
PaperSize::A0 => (841.0, 1189.0),
PaperSize::Letter => (215.9, 279.4),
PaperSize::Legal => (215.9, 355.6),
PaperSize::Tabloid => (279.4, 431.8),
}
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum Orientation {
Portrait,
Landscape,
}
/// Sheet dimensions in mm for the given size and orientation.
pub fn sheet_mm(size: PaperSize, o: Orientation) -> (f64, f64) {
let (w, h) = size.dimensions_mm();
match o {
Orientation::Portrait => (w, h),
Orientation::Landscape => (h, w),
}
}
#[derive(Copy, Clone, PartialEq, Debug)]
pub enum PlotScale {
/// Scale so the window fills the sheet minus a 5% margin.
Fit,
/// Exact scale factor (mm per drawing unit).
Ratio(f64),
}
/// Map a world-space window onto a sheet. Returns (scale, offset_x, offset_y),
/// where the scaled window is centered on the sheet and (offset_x, offset_y) is
/// the sheet-mm position of the window's min corner. The caller turns that into
/// its own coordinate offset.
pub fn window_to_sheet(
window_wh: (f64, f64),
sheet_mm: (f64, f64),
scale: PlotScale,
) -> (f64, f64, f64) {
let (ww, wh) = (window_wh.0.max(1e-9), window_wh.1.max(1e-9));
let scale = match scale {
PlotScale::Ratio(r) => r.max(1e-9),
PlotScale::Fit => {
const MARGIN: f64 = 1.05;
let sx = (sheet_mm.0 / MARGIN) / ww;
let sy = (sheet_mm.1 / MARGIN) / wh;
sx.min(sy)
}
};
let scaled_w = ww * scale;
let scaled_h = wh * scale;
let offset_x = (sheet_mm.0 - scaled_w) / 2.0;
let offset_y = (sheet_mm.1 - scaled_h) / 2.0;
(scale, offset_x, offset_y)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paper_dimensions_and_orientation() {
assert_eq!(PaperSize::A4.dimensions_mm(), (210.0, 297.0));
assert_eq!(PaperSize::A0.dimensions_mm(), (841.0, 1189.0));
assert_eq!(PaperSize::Letter.dimensions_mm(), (215.9, 279.4));
assert_eq!(PaperSize::Legal.dimensions_mm(), (215.9, 355.6));
assert_eq!(PaperSize::Tabloid.dimensions_mm(), (279.4, 431.8));
assert_eq!(PaperSize::ALL.len(), 8);
assert_eq!(PaperSize::A3.label(), "A3");
// Portrait keeps (w,h); landscape swaps.
assert_eq!(sheet_mm(PaperSize::A4, Orientation::Portrait), (210.0, 297.0));
assert_eq!(sheet_mm(PaperSize::A4, Orientation::Landscape), (297.0, 210.0));
}
#[test]
fn fit_centers_and_scales_within_margin() {
// 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width:
// usable = 210/1.05 = 200; scale = 200/100 = 2.0.
let (s, ox, oy) = window_to_sheet((100.0, 100.0), (210.0, 297.0), PlotScale::Fit);
assert!((s - 2.0).abs() < 1e-9, "scale {s}");
// Window is 100*2 = 200 wide/tall; centered on 210×297.
assert!((ox - (210.0 - 200.0) / 2.0).abs() < 1e-9, "ox {ox}");
assert!((oy - (297.0 - 200.0) / 2.0).abs() < 1e-9, "oy {oy}");
}
#[test]
fn ratio_applies_exact_scale_and_centers() {
// Ratio(0.5): scale is exactly 0.5; the scaled window is centered and
// (ox, oy) is the sheet-mm position of its min corner.
let (s, ox, oy) = window_to_sheet((100.0, 80.0), (210.0, 297.0), PlotScale::Ratio(0.5));
assert!((s - 0.5).abs() < 1e-9);
// scaled window = 50×40; centered box origin = ((210-50)/2,(297-40)/2).
assert!((ox - (210.0 - 50.0) / 2.0).abs() < 1e-9, "ox {ox}");
assert!((oy - (297.0 - 40.0) / 2.0).abs() < 1e-9, "oy {oy}");
}
}
+27 -2
View File
@@ -375,7 +375,7 @@ fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6]
/// The parent comes from `iced::window::run`, keeping the portal request tied
/// to the visible app window on Wayland instead of silently resolving to
/// `None` on desktops that reject a parentless save dialog (#537).
#[cfg(not(target_arch = "wasm32"))]
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
pub fn pick_pdf_path_owned(
stem: String,
parent: &dyn iced::window::Window,
@@ -392,6 +392,25 @@ pub fn pick_pdf_path_owned(
Some(path)
}
/// Windows: pick the PDF destination with the async dialog on a worker
/// thread. The parented blocking dialog ran `IFileDialog::Show` on the UI
/// thread inside the window callback, and when the target name already
/// existed the overwrite-confirmation popup is a second nested modal that
/// never gets pumped there — the app froze instead of asking. The async
/// backend runs the dialog off-thread; it is the same pattern the DWG
/// Save As flow uses, whose confirm popup works.
#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))]
pub async fn pick_pdf_path_async(stem: String) -> Option<std::path::PathBuf> {
let handle = crate::sys::file_dialog()
.set_title(crate::t!("Export as PDF").as_ref())
.set_file_name(format!("{stem}.pdf"))
.add_filter(crate::t!("PDF Files").as_ref(), &["pdf"])
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.save_file()
.await?;
Some(crate::sys::handle_path(&handle))
}
// ── PDF builder ───────────────────────────────────────────────────────────
#[cfg(not(target_arch = "wasm32"))]
@@ -586,6 +605,7 @@ fn append_pdf_page(
emit_wire_fills(
&mut ops,
std::slice::from_ref(&wire.wire),
wire.draw_depth,
ox,
oy,
plot_style,
@@ -1017,6 +1037,7 @@ fn plotted_color(
fn emit_wire_fills(
ops: &mut Vec<Op>,
wires: &[WireModel],
wire_depth: f32,
ox: f64,
oy: f64,
plot_style: Option<&PlotStyleTable>,
@@ -1071,7 +1092,11 @@ fn emit_wire_fills(
line_weight_px: wire.line_weight_px,
angle_offset: 0.0,
scale: 1.0 / scale.max(1.0e-6),
draw_depth: wire.depth_override.unwrap_or(0.0),
// The host wire's composed draw depth (PlotWire carries
// wire_draw_depth). depth_override alone is a per-block
// child label and would sort the fill outside its block's
// band; keep the pattern fill co-sorted with its wire.
draw_depth: wire_depth,
};
emit_hatch(
ops,
+631
View File
@@ -0,0 +1,631 @@
//! Plot devices: what a page setup names as its plotter, and what that device
//! can print.
//!
//! the source application stores the device in `PlotSettings::printer_name` as the plotter
//! configuration's name — `DWG To PDF.pc3` for its PDF driver, the Windows
//! printer's name for a system printer, `None` when nothing is assigned. A
//! drawing saved here has to use the same spelling so the source application attaches the
//! layout to its own PDF driver instead of reporting a missing device; and a
//! device name that arrives from the source application (an HP plotter's `.pc3`, `DWF6
//! ePlot.pc3`) must go back out untouched even though we cannot drive it.
//!
//! The device also decides the *printable area*: the sheet inset by the
//! driver's unprintable margins, which the source application writes into the layout and
//! draws as the dashed rectangle in paper space. The PDF driver's margins are
//! a fixed table; a CUPS printer reports its media and margins over IPP
//! (`media-col-database`), which is queried once per printer and cached.
pub use crate::io::paper_catalog::Margins;
use crate::io::paper_catalog::{self, CustomPaper, PaperSize, PaperUnits, PaperVariant};
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
/// the source application's plotter configuration name for PDF output.
pub const PDF_DEVICE_NAME: &str = "DWG To PDF.pc3";
/// the source application's spelling for "no plotter assigned" in the plot dialog…
pub const NO_DEVICE_NAME: &str = "None";
/// …and the name it actually stores in the drawing for that choice.
const NO_DEVICE_STORED_NAME: &str = "none_device";
/// The label older Open CAD Studio builds wrote into drawings for PDF output.
/// Recognised on read so those drawings keep plotting to PDF, and always
/// rewritten with [`PDF_DEVICE_NAME`] on save.
const LEGACY_PDF_LABEL: &str = "Save to PDF file…";
const WINDOWS_PDF_PRINTER: &str = "Microsoft Print to PDF";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlotDevice {
/// No plotter assigned (the source application `None`). We plot to the system default
/// printer in that case, which is what the plot dialog offers.
None,
/// PDF file output, the source application's `DWG To PDF.pc3`.
Pdf,
/// A system printer by its queue / spooler name.
Printer(String),
}
impl PlotDevice {
/// The name the source application would store for this device.
pub fn canonical_name(&self) -> String {
match self {
PlotDevice::None => NO_DEVICE_STORED_NAME.to_string(),
PlotDevice::Pdf => PDF_DEVICE_NAME.to_string(),
PlotDevice::Printer(name) => name.clone(),
}
}
/// Interpret a stored `printer_name`. Returns the device and whether the
/// stored spelling should be preserved on save: an the source application plotter
/// configuration we cannot drive (`DWF6 ePlot.pc3`, an HP `.pc3`) is
/// treated as PDF output but keeps its name, so the drawing still attaches
/// to that plotter in the source application; our own legacy label and an empty name are
/// not worth preserving.
pub fn from_stored_name(name: &str) -> (Self, bool) {
let name = name.trim();
if name.is_empty() || name.eq_ignore_ascii_case(NO_DEVICE_NAME) {
return (PlotDevice::None, false);
}
if name.eq_ignore_ascii_case(NO_DEVICE_STORED_NAME) {
// the source application's own spelling: keep it rather than rewriting it.
return (PlotDevice::None, true);
}
if name == LEGACY_PDF_LABEL || name.eq_ignore_ascii_case(WINDOWS_PDF_PRINTER) {
return (PlotDevice::Pdf, false);
}
let lower = name.to_ascii_lowercase();
if lower.ends_with(".pc3") {
// Any the source application plotter configuration: PDF ones are ours to drive,
// the rest plot to PDF here but keep their name for the source application.
return (PlotDevice::Pdf, true);
}
(PlotDevice::Printer(name.to_string()), true)
}
/// Unprintable margins the device imposes on `paper`, in millimetres.
/// A sheet the user defined carries its own margins for every device,
/// the way a custom size in a plotter configuration does; a printer that
/// reports the same size still wins, since it knows its hardware.
pub fn margins_mm(&self, paper: &PaperSize, custom: &[CustomPaper]) -> Margins {
let user_defined = custom
.iter()
.find(|c| c.canonical() == paper.canonical)
.map(CustomPaper::margins_mm);
match self {
PlotDevice::Pdf => user_defined.unwrap_or_else(|| pdf_margins_mm(paper)),
PlotDevice::Printer(name) => cached_printer_capabilities(name)
.and_then(|caps| caps.margins_for(paper))
.or(user_defined)
.unwrap_or(Margins::ZERO),
// No device: the source application keeps the margins of the last device or its
// defaults; without one we show the full sheet as printable.
PlotDevice::None => user_defined.unwrap_or(Margins::ZERO),
}
}
}
// ── PDF driver ────────────────────────────────────────────────────────────
/// Unprintable margins of the source application's `DWG To PDF.pc3` sheets, as its Plotter
/// Configuration Editor lists them (Modify Standard Paper Sizes). They depend
/// on the sheet family and variant, not on the individual size: every ISO
/// sheet shares one set in millimetres, every ANSI / ARCH sheet one set in
/// inches. Order inside each set is left, bottom, right, top.
const PDF_MM_STANDARD: Margins = Margins {
left: 5.0,
bottom: 17.0,
right: 6.0,
top: 18.0,
};
const PDF_MM_EXPAND: Margins = Margins {
left: 5.0,
bottom: 10.0,
right: 6.0,
top: 11.0,
};
const PDF_MM_FULL_BLEED: Margins = Margins {
left: 0.0,
bottom: 1.0,
right: 0.0,
top: 1.0,
};
const PDF_IN_STANDARD: Margins = Margins {
left: 0.23,
bottom: 0.70,
right: 0.23,
top: 0.70,
};
const PDF_IN_EXPAND: Margins = Margins {
left: 0.23,
bottom: 0.42,
right: 0.23,
top: 0.42,
};
const PDF_IN_FULL_BLEED: Margins = Margins::uniform(0.03);
fn pdf_margins_mm(paper: &PaperSize) -> Margins {
let set = match (paper.units, paper.variant) {
(PaperUnits::Millimeters, PaperVariant::Standard) => PDF_MM_STANDARD,
(PaperUnits::Millimeters, PaperVariant::Expand) => PDF_MM_EXPAND,
(PaperUnits::Millimeters, PaperVariant::FullBleed) => PDF_MM_FULL_BLEED,
(PaperUnits::Inches, PaperVariant::Standard) => PDF_IN_STANDARD,
(PaperUnits::Inches, PaperVariant::Expand) => PDF_IN_EXPAND,
(PaperUnits::Inches, PaperVariant::FullBleed) => PDF_IN_FULL_BLEED,
};
set.to_mm(paper.units)
}
// ── System printers ───────────────────────────────────────────────────────
/// One sheet a printer reports it can load, with its unprintable margins.
#[derive(Clone, Debug, PartialEq)]
pub struct PrinterMedia {
/// The sheet as our catalogue knows it (matched by size), or a custom
/// sheet built from the reported dimensions.
pub paper: PaperSize,
pub margins: Margins,
/// `true` when the printer also reports a borderless variant of this size.
pub borderless: bool,
}
/// What a printer reported about its media, cached for the session.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PrinterCapabilities {
/// Sheets in the printer's own order, one entry per distinct size.
pub media: Vec<PrinterMedia>,
/// The printer's default sheet, when reported.
pub default_paper: Option<PaperSize>,
}
impl PrinterCapabilities {
/// Margins the printer reports for `paper`, matched by size. `None` when
/// the printer never mentioned a sheet of that size.
pub fn margins_for(&self, paper: &PaperSize) -> Option<Margins> {
let (w, h) = paper.portrait_mm();
self.media
.iter()
.find(|media| {
let (mw, mh) = media.paper.portrait_mm();
(mw - w).abs() <= 0.5 && (mh - h).abs() <= 0.5
})
.map(|media| media.margins)
}
}
/// Answers per printer for the session; a printer that could not be asked
/// is remembered as `None` too, so a slow failing query (an offline network
/// queue) runs once, not on every dialog open.
type CapabilityCache = HashMap<String, Option<Arc<PrinterCapabilities>>>;
fn capability_cache() -> &'static Mutex<CapabilityCache> {
static CACHE: OnceLock<Mutex<CapabilityCache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// The cached capabilities of `printer`, if they were queried already.
pub fn cached_printer_capabilities(printer: &str) -> Option<Arc<PrinterCapabilities>> {
capability_cache().lock().ok()?.get(printer).cloned().flatten()
}
/// Query `printer` for its media (blocking: a process spawn or a driver
/// round trip) and cache the answer. Returns `None` when the platform or
/// the printer cannot answer — the plot dialog then falls back to the paper
/// catalogue with unverified margins.
pub fn printer_capabilities(printer: &str) -> Option<Arc<PrinterCapabilities>> {
if let Ok(cache) = capability_cache().lock() {
if let Some(answer) = cache.get(printer) {
return answer.clone();
}
}
let caps = query_printer_capabilities(printer).map(Arc::new);
if let Ok(mut cache) = capability_cache().lock() {
cache.insert(printer.to_string(), caps.clone());
}
caps
}
/// Name of the system default printer, when the platform reports one.
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
pub fn default_printer_name() -> Option<String> {
// `lpstat -d` → "system default destination: NAME" (the prefix is
// localized, the name follows the last colon).
let output = std::process::Command::new("lpstat")
.arg("-d")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let line = text.lines().find(|line| line.contains(':'))?;
let name = line.rsplit(':').next()?.trim();
(!name.is_empty()).then(|| name.to_string())
}
/// The spooler's default printer, by name — the same lookup every direct
/// print job resolves "Default" through, so the dialog names what will print.
#[cfg(target_os = "windows")]
pub fn default_printer_name() -> Option<String> {
crate::io::print_to_printer::windows_default_printer().ok()
}
#[cfg(target_arch = "wasm32")]
pub fn default_printer_name() -> Option<String> {
None
}
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
fn query_printer_capabilities(printer: &str) -> Option<PrinterCapabilities> {
// cupsd answers for the queue itself, so this is quick even when the
// printer is off; `ipptool` is part of every CUPS install that has
// `lpstat`. The standard test file asks for every attribute; the two we
// read are `media-col-database` and `media-default`.
let uri = format!("ipp://localhost/printers/{}", printer.replace(' ', "%20"));
let output = std::process::Command::new("ipptool")
.args(["-tv", &uri, "get-printer-attributes.test"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
parse_ipp_media(&String::from_utf8_lossy(&output.stdout))
}
/// The driver's sheet tables and a printer DC's margins (see
/// `windows_media`); `None` for a queue that lists no sheet.
#[cfg(target_os = "windows")]
fn query_printer_capabilities(printer: &str) -> Option<PrinterCapabilities> {
let raw = crate::io::print_to_printer::windows_printer_media(printer)?;
crate::io::windows_media::printer_media(&raw)
}
#[cfg(target_arch = "wasm32")]
fn query_printer_capabilities(_printer: &str) -> Option<PrinterCapabilities> {
None
}
/// Read the media a printer reports from `ipptool -tv` output.
///
/// The relevant lines look like
/// `media-col-database (1setOf collection) = {media-size={x-dimension=21000
/// y-dimension=29700} media-bottom-margin=300 media-left-margin=300 …},{…}`
/// with sizes and margins in hundredths of a millimetre, and
/// `media-default (keyword) = iso_a4_210x297mm`. Borderless variants repeat a
/// size with zero margins; they collapse onto the bordered entry.
#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))]
fn parse_ipp_media(text: &str) -> Option<PrinterCapabilities> {
let database = attribute_value(text, "media-col-database")?;
let mut media: Vec<PrinterMedia> = Vec::new();
for entry in database.split("},{") {
let Some((w, h)) = collection_size_mm(entry) else {
continue;
};
let margins = Margins {
left: hundredths_mm(entry, "media-left-margin"),
bottom: hundredths_mm(entry, "media-bottom-margin"),
right: hundredths_mm(entry, "media-right-margin"),
top: hundredths_mm(entry, "media-top-margin"),
};
let borderless = margins == Margins::ZERO;
if let Some(existing) = media.iter_mut().find(|m| {
let (mw, mh) = m.paper.portrait_mm();
let (pw, ph) = portrait(w, h);
(mw - pw).abs() <= 0.5 && (mh - ph).abs() <= 0.5
}) {
// Keep the bordered margins as the sheet's printable area and
// remember that edge-to-edge is possible too.
if borderless {
existing.borderless = true;
} else if existing.margins == Margins::ZERO {
existing.margins = margins;
existing.borderless = true;
}
continue;
}
let (pw, ph) = portrait(w, h);
let paper = paper_catalog::match_dimensions_mm(pw, ph)
.cloned()
.unwrap_or_else(|| PaperSize::custom(pw, ph, PaperUnits::Millimeters));
media.push(PrinterMedia {
paper,
margins,
borderless,
});
}
if media.is_empty() {
return None;
}
let default_paper = attribute_value(text, "media-default")
.and_then(|name| pwg_size_mm(name.trim()))
.and_then(|(w, h)| {
let (w, h) = portrait(w, h);
media
.iter()
.find(|m| {
let (mw, mh) = m.paper.portrait_mm();
(mw - w).abs() <= 0.5 && (mh - h).abs() <= 0.5
})
.map(|m| m.paper.clone())
});
Some(PrinterCapabilities {
media,
default_paper,
})
}
/// The text after `name (…) = ` on the line that declares `name`.
#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))]
fn attribute_value<'a>(text: &'a str, name: &str) -> Option<&'a str> {
text.lines().find_map(|line| {
let line = line.trim_start();
let rest = line.strip_prefix(name)?;
// The attribute name is followed by its syntax in parentheses.
if !rest.starts_with(' ') && !rest.starts_with('(') {
return None;
}
let (_, value) = rest.split_once('=')?;
Some(value.trim())
})
}
#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))]
fn collection_size_mm(entry: &str) -> Option<(f64, f64)> {
let x = hundredths(entry, "x-dimension")?;
let y = hundredths(entry, "y-dimension")?;
(x > 0.0 && y > 0.0).then_some((x, y))
}
#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))]
fn hundredths(entry: &str, key: &str) -> Option<f64> {
let start = entry.find(key)? + key.len();
let rest = entry[start..].trim_start_matches('=');
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse::<f64>().ok().map(|v| v / 100.0)
}
#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))]
fn hundredths_mm(entry: &str, key: &str) -> f64 {
hundredths(entry, key).unwrap_or(0.0)
}
#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))]
fn portrait(w: f64, h: f64) -> (f64, f64) {
if w <= h {
(w, h)
} else {
(h, w)
}
}
/// Dimensions from a PWG self-describing media name such as
/// `iso_a4_210x297mm` or `na_letter_8.5x11in`.
#[cfg_attr(any(target_arch = "wasm32", target_os = "windows"), allow(dead_code))]
fn pwg_size_mm(name: &str) -> Option<(f64, f64)> {
let dims = name.rsplit('_').next()?;
let (dims, units) = dims
.strip_suffix("mm")
.map(|d| (d, PaperUnits::Millimeters))
.or_else(|| dims.strip_suffix("in").map(|d| (d, PaperUnits::Inches)))?;
let (w, h) = dims.split_once('x')?;
let w: f64 = w.parse().ok()?;
let h: f64 = h.parse().ok()?;
Some((units.to_mm(w), units.to_mm(h)))
}
#[cfg(test)]
mod tests {
use super::*;
const IPP_SAMPLE: &str = " media-default (keyword) = iso_a4_210x297mm\n\
media-supported (1setOf keyword) = iso_a4_210x297mm,om_a-4.borderless_210x297mm,na_letter_8.5x11in,custom_215x345mm_215x345mm\n\
media-col-database (1setOf collection) = {media-size={x-dimension=21000 y-dimension=29700} media-bottom-margin=300 media-left-margin=300 media-right-margin=300 media-top-margin=300},{media-size={x-dimension=21000 y-dimension=29700} media-bottom-margin=0 media-left-margin=0 media-right-margin=0 media-top-margin=0},{media-size={x-dimension=21590 y-dimension=27940} media-bottom-margin=635 media-left-margin=635 media-right-margin=635 media-top-margin=635},{media-size={x-dimension=21500 y-dimension=34500} media-bottom-margin=300 media-left-margin=300 media-right-margin=300 media-top-margin=300}\n";
#[test]
fn canonical_names_follow_source_app() {
assert_eq!(PlotDevice::Pdf.canonical_name(), "DWG To PDF.pc3");
// What the source application writes for "None" in its plot dialog.
assert_eq!(PlotDevice::None.canonical_name(), "none_device");
assert_eq!(
PlotDevice::Printer("L3270".into()).canonical_name(),
"L3270"
);
}
#[test]
fn stored_names_resolve_and_say_whether_to_keep_them() {
assert_eq!(PlotDevice::from_stored_name(""), (PlotDevice::None, false));
assert_eq!(
PlotDevice::from_stored_name("None"),
(PlotDevice::None, false)
);
assert_eq!(
PlotDevice::from_stored_name("none_device"),
(PlotDevice::None, true)
);
assert_eq!(
PlotDevice::from_stored_name("NONE_DEVICE"),
(PlotDevice::None, true)
);
assert_eq!(
PlotDevice::from_stored_name("Save to PDF file…"),
(PlotDevice::Pdf, false)
);
assert_eq!(
PlotDevice::from_stored_name("Microsoft Print to PDF"),
(PlotDevice::Pdf, false)
);
assert_eq!(
PlotDevice::from_stored_name("DWG To PDF.pc3"),
(PlotDevice::Pdf, true)
);
assert_eq!(
PlotDevice::from_stored_name("the source application PDF (High Quality Print).pc3"),
(PlotDevice::Pdf, true)
);
// A plotter we cannot drive plots to PDF here but keeps its name.
assert_eq!(
PlotDevice::from_stored_name("DWF6 ePlot.pc3"),
(PlotDevice::Pdf, true)
);
assert_eq!(
PlotDevice::from_stored_name("HP LaserJet 4"),
(PlotDevice::Printer("HP LaserJet 4".into()), true)
);
}
#[test]
fn pdf_margins_follow_source_app_per_family_and_variant() {
let m =
|name: &str| PlotDevice::Pdf.margins_mm(&paper_catalog::resolve(name).unwrap(), &[]);
// ISO sheets share one millimetre set regardless of size.
let iso_standard = Margins {
left: 5.0,
bottom: 17.0,
right: 6.0,
top: 18.0,
};
assert_eq!(m("ISO_A4_(210.00_x_297.00_MM)"), iso_standard);
assert_eq!(m("ISO_A0_(841.00_x_1189.00_MM)"), iso_standard);
assert_eq!(
m("ISO_expand_A4_(210.00_x_297.00_MM)"),
Margins {
left: 5.0,
bottom: 10.0,
right: 6.0,
top: 11.0
}
);
assert_eq!(
m("ISO_full_bleed_A4_(210.00_x_297.00_MM)"),
Margins {
left: 0.0,
bottom: 1.0,
right: 0.0,
top: 1.0
}
);
// Inch sheets are listed in inches and stored in millimetres.
let close = |a: Margins, b: Margins| {
(a.left - b.left).abs() < 1e-9
&& (a.bottom - b.bottom).abs() < 1e-9
&& (a.right - b.right).abs() < 1e-9
&& (a.top - b.top).abs() < 1e-9
};
let inch = |l: f64, b: f64, r: f64, t: f64| Margins {
left: l * 25.4,
bottom: b * 25.4,
right: r * 25.4,
top: t * 25.4,
};
assert!(close(
m("ANSI_A_(8.50_x_11.00_Inches)"),
inch(0.23, 0.70, 0.23, 0.70)
));
assert!(close(
m("ARCH_D_(24.00_x_36.00_Inches)"),
inch(0.23, 0.70, 0.23, 0.70)
));
assert!(close(
m("ARCH_expand_D_(24.00_x_36.00_Inches)"),
inch(0.23, 0.42, 0.23, 0.42)
));
assert!(close(
m("ANSI_full_bleed_B_(11.00_x_17.00_Inches)"),
inch(0.03, 0.03, 0.03, 0.03)
));
assert_eq!(
PlotDevice::None.margins_mm(
&paper_catalog::resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(),
&[]
),
Margins::ZERO
);
}
#[test]
fn user_defined_sheets_bring_their_own_margins() {
let roll = CustomPaper {
name: "Roll".into(),
width: 900.0,
height: 1200.0,
units: PaperUnits::Millimeters,
margins: Margins::uniform(3.0),
};
let paper = roll.paper();
assert_eq!(
PlotDevice::Pdf.margins_mm(&paper, &[roll.clone()]),
Margins::uniform(3.0)
);
assert_eq!(
PlotDevice::None.margins_mm(&paper, &[roll.clone()]),
Margins::uniform(3.0)
);
// An unknown printer falls back to the user's margins too.
assert_eq!(
PlotDevice::Printer("Nowhere".into()).margins_mm(&paper, &[roll]),
Margins::uniform(3.0)
);
// Without a definition a custom-looking sheet gets the family set.
assert_eq!(
PlotDevice::Pdf.margins_mm(&paper, &[]),
Margins {
left: 5.0,
bottom: 17.0,
right: 6.0,
top: 18.0
}
);
}
#[test]
fn ipp_media_parses_sizes_margins_borderless_and_default() {
let caps = parse_ipp_media(IPP_SAMPLE).expect("media parsed");
assert_eq!(
caps.media.len(),
3,
"borderless A4 folds into the bordered entry"
);
let a4 = &caps.media[0];
assert_eq!(a4.paper.canonical, "ISO_A4_(210.00_x_297.00_MM)");
assert_eq!(a4.margins, Margins::uniform(3.0));
assert!(a4.borderless);
let letter = &caps.media[1];
assert_eq!(letter.paper.canonical, "ANSI_A_(8.50_x_11.00_Inches)");
assert_eq!(letter.margins, Margins::uniform(6.35));
assert!(!letter.borderless);
let custom = &caps.media[2];
assert_eq!(
custom.paper.canonical,
"UserDefinedMetric_(215.00_x_345.00_MM)"
);
assert_eq!(
caps.default_paper.as_ref().map(|p| p.canonical.as_ref()),
Some("ISO_A4_(210.00_x_297.00_MM)")
);
// Margin lookup by sheet size, either orientation, tolerant of the
// catalogue's inch → mm rounding.
let ansi_a = paper_catalog::resolve("Letter_(8.50_x_11.00_Inches)").unwrap();
assert_eq!(caps.margins_for(&ansi_a), Some(Margins::uniform(6.35)));
let a3 = paper_catalog::resolve("ISO_A3_(297.00_x_420.00_MM)").unwrap();
assert_eq!(caps.margins_for(&a3), None);
}
#[test]
fn ipp_output_without_media_is_rejected() {
assert!(parse_ipp_media("printer-state (enum) = 3\n").is_none());
assert!(parse_ipp_media(
"media-col-database (1setOf collection) = {media-size={x-dimension=0 y-dimension=0}}\n"
)
.is_none());
}
#[test]
fn pwg_names_carry_their_size() {
assert_eq!(pwg_size_mm("iso_a4_210x297mm"), Some((210.0, 297.0)));
let (w, h) = pwg_size_mm("na_letter_8.5x11in").unwrap();
assert!((w - 215.9).abs() < 1e-9 && (h - 279.4).abs() < 1e-9);
assert_eq!(
pwg_size_mm("custom_215x345mm_215x345mm"),
Some((215.0, 345.0))
);
assert_eq!(pwg_size_mm("iso_a4"), None);
}
}
+212 -25
View File
@@ -175,6 +175,10 @@ pub struct PlotStyleTable {
pub aci_entries: Vec<PlotStyleEntry>, // 256 entries, index = ACI
/// For STB: named style entries.
pub named_entries: HashMap<String, PlotStyleEntry>,
/// What had to be tolerated to read the file (a bad checksum, a length
/// that did not add up, descriptions in a Windows code page): the file
/// loaded, but the user deserves to know it was not quite as written.
pub load_warnings: Vec<String>,
}
impl PlotStyleTable {
@@ -190,6 +194,7 @@ impl PlotStyleTable {
lineweights: LW_TABLE.to_vec(),
aci_entries: (0..=255).map(|_| PlotStyleEntry::default()).collect(),
named_entries: HashMap::default(),
load_warnings: Vec::new(),
}
}
@@ -207,8 +212,10 @@ impl PlotStyleTable {
pub fn from_bytes(name: impl Into<String>, raw: &[u8]) -> Result<Self, String> {
let name = name.into();
let is_stb = name.to_ascii_lowercase().ends_with(".stb");
let text = decompress_ctb(raw)?;
parse_plot_style_text(&text, name, is_stb)
let (text, warnings) = decompress_ctb(raw)?;
let mut table = parse_plot_style_text(&text, name, is_stb)?;
table.load_warnings = warnings;
Ok(table)
}
pub fn builtin(name: &str) -> Result<Self, String> {
@@ -264,12 +271,18 @@ impl PlotStyleTable {
let path = Path::new(name);
if path.components().count() != 1
|| !matches!(path.components().next(), Some(Component::Normal(_)))
|| !path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ctb"))
{
return Err(format!("Invalid plot style name: {name}"));
}
if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("stb")) {
return Err(crate::tf!(
"Named plot style tables (.stb) are not supported yet: {name}"
)
.into_owned());
}
if !path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("ctb")) {
return Err(format!("Invalid plot style name: {name}"));
}
#[cfg(not(target_arch = "wasm32"))]
{
@@ -286,7 +299,12 @@ impl PlotStyleTable {
.map(|entry| entry.path());
return match matched {
Some(path) => Self::load(&path),
None => Self::builtin(name),
None => Self::builtin(name).map_err(|_| {
// Not a built-in either: say where a copy would be found.
let folder = dir.display();
crate::tf!("{name} was not found in the plot styles folder ({folder}).")
.into_owned()
}),
};
}
@@ -424,10 +442,18 @@ impl PlotStyleTable {
// ── Deflate helpers ───────────────────────────────────────────────────────────
/// Decompress a CTB/STB file's raw bytes into the text content.
/// Decompress a CTB/STB file's raw bytes into the text content, with the
/// things that had to be tolerated on the way.
///
fn decompress_ctb(data: &[u8]) -> Result<String, String> {
/// Files written by other applications are read as they are, not as the
/// container says they should be: a checksum or a length that does not add
/// up is reported and read past (the text is either there or the zlib
/// stream fails on its own), a zlib stream with a bad trailer is retried as
/// raw deflate, and text that is not UTF-8 is read as Windows-1252 — the
/// code page those applications write descriptions in.
fn decompress_ctb(data: &[u8]) -> Result<(String, Vec<String>), String> {
const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
let mut warnings = Vec::new();
let mut decoded = Vec::new();
if data.starts_with(PREFIX) {
if data.len() < 60 {
@@ -436,20 +462,34 @@ fn decompress_ctb(data: &[u8]) -> Result<String, String> {
let checksum = u32::from_le_bytes(data[48..52].try_into().unwrap());
let text_len = u32::from_le_bytes(data[52..56].try_into().unwrap()) as usize;
let compressed_len = u32::from_le_bytes(data[56..60].try_into().unwrap()) as usize;
if compressed_len > data.len() - 60 {
return Err("CTB compressed payload is truncated".into());
}
let payload = &data[60..60 + compressed_len];
let available = data.len() - 60;
let payload = if compressed_len > available {
warnings.push(format!(
"the header announces {compressed_len} compressed bytes \
but the file holds {available}"
));
&data[60..]
} else {
&data[60..60 + compressed_len]
};
if adler32(payload) != checksum {
return Err("CTB compressed payload checksum mismatch".into());
warnings.push("the compressed payload's checksum does not match".into());
}
use flate2::read::ZlibDecoder;
ZlibDecoder::new(payload)
.read_to_end(&mut decoded)
.map_err(|e| format!("CTB zlib decompress: {e}"))?;
if decoded.len() != text_len {
decoded = inflate_lenient(payload, &mut warnings)?;
// A byte or a few short is a header that counted its trailing NUL
// differently; far short of the announced text is a cut-off file,
// which the inflater does not always notice on its own.
const TOLERATED_SHORTFALL: usize = 16;
if decoded.len() + TOLERATED_SHORTFALL < text_len {
return Err(format!(
"CTB content length mismatch: expected {text_len}, got {}",
"CTB text is incomplete: the header announces {text_len} bytes \
but only {} could be read",
decoded.len()
));
}
if decoded.len() != text_len {
warnings.push(format!(
"the header announces {text_len} bytes of text but {} were read",
decoded.len()
));
}
@@ -461,10 +501,7 @@ fn decompress_ctb(data: &[u8]) -> Result<String, String> {
.unwrap_or(0);
let payload = &data[split_at..];
if payload.starts_with(&[0x78]) {
use flate2::read::ZlibDecoder;
ZlibDecoder::new(payload)
.read_to_end(&mut decoded)
.map_err(|e| format!("legacy CTB zlib decompress: {e}"))?;
decoded = inflate_lenient(payload, &mut warnings)?;
} else {
use flate2::read::DeflateDecoder;
DeflateDecoder::new(payload)
@@ -472,10 +509,72 @@ fn decompress_ctb(data: &[u8]) -> Result<String, String> {
.map_err(|e| format!("legacy CTB deflate decompress: {e}"))?;
}
}
if decoded.last() == Some(&0) {
while decoded.last() == Some(&0) {
decoded.pop();
}
String::from_utf8(decoded).map_err(|e| format!("CTB text is not UTF-8: {e}"))
let (text, code_page) = decode_plot_style_text(decoded);
if let Some(note) = code_page {
warnings.push(note);
}
Ok((text, warnings))
}
/// Inflate a zlib stream, falling back to the raw deflate data behind its
/// two-byte header when the stream's own trailer is wrong.
fn inflate_lenient(payload: &[u8], warnings: &mut Vec<String>) -> Result<Vec<u8>, String> {
use flate2::read::{DeflateDecoder, ZlibDecoder};
let mut decoded = Vec::new();
match ZlibDecoder::new(payload).read_to_end(&mut decoded) {
Ok(_) => Ok(decoded),
Err(zlib_error) if payload.len() > 2 => {
let mut raw = Vec::new();
DeflateDecoder::new(&payload[2..])
.read_to_end(&mut raw)
.map_err(|_| format!("CTB zlib decompress: {zlib_error}"))?;
warnings.push("the zlib stream is damaged; its deflate data was read directly".into());
Ok(raw)
}
Err(zlib_error) => Err(format!("CTB zlib decompress: {zlib_error}")),
}
}
/// The table's text out of its bytes: UTF-8 when it is, else Windows-1252
/// (Latin-1 with the 0x80–0x9F block the code page defines), which is what
/// applications on Western systems write into descriptions. Other code
/// pages come out as readable but wrong characters in descriptions only —
/// pens and colours are numbers and unaffected. The second value says when
/// the fallback was taken.
pub fn decode_plot_style_text(bytes: Vec<u8>) -> (String, Option<String>) {
match String::from_utf8(bytes) {
Ok(text) => (text, None),
Err(error) => {
let text = error
.into_bytes()
.into_iter()
.map(windows_1252_char)
.collect();
(
text,
Some("descriptions were read as Windows-1252 text (the file is not UTF-8)".into()),
)
}
}
}
/// One Windows-1252 byte as a character: Latin-1 except for the 0x80–0x9F
/// block, which the code page fills with punctuation and accented letters.
fn windows_1252_char(byte: u8) -> char {
const HIGH: [char; 32] = [
'\u{20AC}', '\u{FFFD}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}',
'\u{2021}', '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{FFFD}',
'\u{017D}', '\u{FFFD}', '\u{FFFD}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}',
'\u{2022}', '\u{2013}', '\u{2014}', '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}',
'\u{0153}', '\u{FFFD}', '\u{017E}', '\u{0178}',
];
match byte {
0x80..=0x9F => HIGH[usize::from(byte - 0x80)],
_ => char::from(byte),
}
}
/// Compress plot-style text content as a CTB/STB file.
@@ -686,6 +785,7 @@ fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result<PlotS
lineweights,
aci_entries,
named_entries,
load_warnings: Vec::new(),
})
}
@@ -797,3 +897,90 @@ mod round_trip_tests {
assert_eq!(reloaded.resolve_lineweight(1), Some(0.035));
}
}
#[cfg(test)]
mod lenient_loading_tests {
use super::{compress_ctb, decode_plot_style_text, PlotStyleTable};
fn table_with_description(description: &str) -> PlotStyleTable {
let mut table = PlotStyleTable::identity("foreign.ctb");
table.description = description.to_string();
table.aci_entries[1].lineweight = 13;
table
}
fn compressed(table: &PlotStyleTable) -> Vec<u8> {
compress_ctb(table.to_text().as_bytes()).expect("compress")
}
/// The pen data must survive every tolerated defect.
fn assert_pens_kept(table: &PlotStyleTable) {
assert_eq!(table.aci_entries[1].lineweight, 13);
}
#[test]
fn a_wrong_checksum_is_reported_not_fatal() {
let mut bytes = compressed(&table_with_description("ok"));
bytes[48] ^= 0xFF;
let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads");
assert_pens_kept(&table);
assert_eq!(table.load_warnings.len(), 1, "{:?}", table.load_warnings);
assert!(table.load_warnings[0].contains("checksum"));
}
#[test]
fn a_text_length_off_by_one_and_a_short_payload_field_are_tolerated() {
let mut bytes = compressed(&table_with_description("ok"));
let text_len = u32::from_le_bytes(bytes[52..56].try_into().unwrap());
bytes[52..56].copy_from_slice(&(text_len - 1).to_le_bytes());
let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads");
assert_pens_kept(&table);
assert!(table.load_warnings.iter().any(|w| w.contains("bytes of text")));
let mut bytes = compressed(&table_with_description("ok"));
let compressed_len = u32::from_le_bytes(bytes[56..60].try_into().unwrap());
bytes[56..60].copy_from_slice(&(compressed_len + 100).to_le_bytes());
let table = PlotStyleTable::from_bytes("foreign.ctb", &bytes).expect("loads");
assert_pens_kept(&table);
assert!(table.load_warnings.iter().any(|w| w.contains("compressed bytes")));
}
#[test]
fn windows_1252_descriptions_are_read_with_a_note() {
// "Descripción" and "Ελληνικά" are not what a Western file holds; a
// Western one holds e.g. "Plumas – señal" in Windows-1252.
let text = table_with_description("marker").to_text();
let mut bytes = text.into_bytes();
let marker = bytes.windows(6).position(|w| w == b"marker").unwrap();
let cp1252 = [
0x50, 0x6C, 0x75, 0x6D, 0x61, 0x73, 0x20, 0x96, 0x20, 0x73, 0x65, 0xF1, 0x61, 0x6C,
];
bytes.splice(marker..marker + 6, cp1252);
let compressed = compress_ctb(&bytes).unwrap();
let table = PlotStyleTable::from_bytes("foreign.ctb", &compressed).expect("loads");
assert_pens_kept(&table);
assert_eq!(table.description, "Plumas – señal");
assert!(table.load_warnings.iter().any(|w| w.contains("Windows-1252")));
// Clean UTF-8 gets no note.
let (text, note) = decode_plot_style_text("Ελληνικά".as_bytes().to_vec());
assert_eq!(text, "Ελληνικά");
assert!(note.is_none());
}
#[test]
fn a_truncated_stream_is_still_an_error() {
let bytes = compressed(&table_with_description("ok"));
let cut = &bytes[..bytes.len() / 2];
let error = PlotStyleTable::from_bytes("foreign.ctb", cut).unwrap_err();
assert!(error.contains("incomplete"), "{error}");
}
#[test]
fn named_tables_and_missing_files_get_a_reason() {
let error = PlotStyleTable::load_named("styles.stb").unwrap_err();
assert!(error.contains(".stb"), "{error}");
let error = PlotStyleTable::load_named("nowhere-to-be-found.ctb").unwrap_err();
assert!(error.contains("nowhere-to-be-found.ctb"), "{error}");
assert!(PlotStyleTable::load_named("../escape.ctb").is_err());
}
}
+1365 -64
View File
File diff suppressed because it is too large. Load diff
+312
View File
@@ -0,0 +1,312 @@
//! What a Windows printer driver says about its sheets, turned into the plot
//! dialog's [`PrinterCapabilities`] — the counterpart of the CUPS media
//! parser in `plot_device.rs`.
//!
//! The driver answers three `DeviceCapabilities` questions with parallel
//! arrays (a paper id, a size in tenths of a millimetre and a 64-character
//! name per sheet) and, through its default DEVMODE, which sheet it starts
//! with; the unprintable margins come from a printer DC. Everything here
//! works on those raw arrays so it compiles and is tested on every platform;
//! the calls that fill them live in `print_to_printer.rs`.
use crate::io::paper_catalog::{self, Margins, PaperSize, PaperUnits};
use crate::io::plot_device::{PrinterCapabilities, PrinterMedia};
/// `DC_PAPERNAMES` returns names in fixed-width slots of this many UTF-16
/// units, NUL-padded (and not NUL-terminated when exactly full).
pub const PAPER_NAME_CHARS: usize = 64;
/// `dmPaperSize` of a user-defined sheet: its size is in `dmPaperWidth` /
/// `dmPaperLength` rather than in the driver's table.
pub const DMPAPER_USER: u16 = 256;
/// Sheets a driver would list but cannot mean: a "user defined" slot is
/// reported as 0 × 0, and some drivers pad with absurd extents.
const MAX_SHEET_MM: f64 = 10_000.0;
/// The driver's answers, as the Win32 calls hand them over.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RawPrinterMedia {
/// `DC_PAPERS`: one `DMPAPER_*` id per sheet.
pub ids: Vec<u16>,
/// `DC_PAPERSIZE`: one `(width, height)` per sheet, tenths of a millimetre.
pub sizes_tenths_mm: Vec<(i32, i32)>,
/// `DC_PAPERNAMES`: `count × PAPER_NAME_CHARS` UTF-16 units, flat.
pub names_utf16: Vec<u16>,
/// The default DEVMODE's `dmPaperSize` when `DM_PAPERSIZE` is set.
pub default_id: Option<u16>,
/// The default DEVMODE's `dmPaperWidth` / `dmPaperLength` (tenths of a
/// millimetre) for a `DMPAPER_USER` default.
pub default_tenths_mm: Option<(i32, i32)>,
/// Unprintable margins measured on the printer DC, portrait, mm.
pub margins: Option<Margins>,
}
/// The `GetDeviceCaps` values a printable-area calculation needs, taken
/// from one printer DC in portrait.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DeviceCapsSample {
/// `PHYSICALWIDTH` / `PHYSICALHEIGHT`: the sheet, in device pixels.
pub physical_width: i32,
pub physical_height: i32,
/// `PHYSICALOFFSETX` / `PHYSICALOFFSETY`: the printable area's corner.
pub offset_x: i32,
pub offset_y: i32,
/// `HORZRES` / `VERTRES`: the printable area, in device pixels.
pub horz_res: i32,
pub vert_res: i32,
/// `LOGPIXELSX` / `LOGPIXELSY`: device pixels per inch.
pub dpi_x: i32,
pub dpi_y: i32,
}
/// The sheet names out of a `DC_PAPERNAMES` buffer: each slot is trimmed at
/// its first NUL (a name that fills its slot has none) and decoded lossily.
pub fn paper_names(names_utf16: &[u16], count: usize) -> Vec<String> {
names_utf16
.chunks(PAPER_NAME_CHARS)
.take(count)
.map(|slot| {
let end = slot.iter().position(|&unit| unit == 0).unwrap_or(slot.len());
String::from_utf16_lossy(&slot[..end]).trim().to_string()
})
.collect()
}
/// The unprintable margins a DC sample describes, in millimetres — `None`
/// when the driver reported no usable resolution or extents.
pub fn device_margins_mm(sample: &DeviceCapsSample) -> Option<Margins> {
if sample.dpi_x <= 0
|| sample.dpi_y <= 0
|| sample.physical_width <= 0
|| sample.physical_height <= 0
|| sample.horz_res <= 0
|| sample.vert_res <= 0
{
return None;
}
let mm_x = |pixels: i32| f64::from(pixels) / f64::from(sample.dpi_x) * 25.4;
let mm_y = |pixels: i32| f64::from(pixels) / f64::from(sample.dpi_y) * 25.4;
let margins = Margins {
left: mm_x(sample.offset_x),
top: mm_y(sample.offset_y),
right: mm_x(sample.physical_width - sample.offset_x - sample.horz_res),
bottom: mm_y(sample.physical_height - sample.offset_y - sample.vert_res),
};
let sane = |value: f64| value.is_finite() && value >= 0.0;
(sane(margins.left) && sane(margins.top) && sane(margins.right) && sane(margins.bottom))
.then_some(margins)
}
/// The driver's sheets as the plot dialog lists them: one entry per distinct
/// size (a rotated or borderless twin folds onto its sheet), catalogue
/// sheets under their own name, unknown sizes under the driver's name, the
/// device's margins on every entry, and the driver's default sheet when it
/// is one of them. `None` when the driver listed nothing usable, so the
/// dialog falls back to the catalogue.
pub fn printer_media(raw: &RawPrinterMedia) -> Option<PrinterCapabilities> {
let count = raw.ids.len().min(raw.sizes_tenths_mm.len());
let names = paper_names(&raw.names_utf16, count);
let margins = raw.margins.unwrap_or(Margins::ZERO);
let mut media: Vec<PrinterMedia> = Vec::new();
// Which listed sheet each id resolves to, so the default can be found by
// id even when its own entry folded onto an earlier twin.
let mut sheet_of_id: Vec<(u16, usize)> = Vec::new();
for index in 0..count {
let (w, h) = raw.sizes_tenths_mm[index];
let (w, h) = (f64::from(w) / 10.0, f64::from(h) / 10.0);
if !(w > 0.0 && h > 0.0 && w <= MAX_SHEET_MM && h <= MAX_SHEET_MM) {
continue;
}
let (pw, ph) = if w <= h { (w, h) } else { (h, w) };
let name = names.get(index).cloned().unwrap_or_default();
if let Some(position) = media.iter().position(|m| same_size(&m.paper, pw, ph)) {
if name.to_ascii_lowercase().contains("borderless") {
media[position].borderless = true;
}
sheet_of_id.push((raw.ids[index], position));
continue;
}
let paper = paper_catalog::match_dimensions_mm(pw, ph).cloned().unwrap_or_else(|| {
if name.is_empty() {
PaperSize::custom(pw, ph, PaperUnits::Millimeters)
} else {
PaperSize::custom_named(&name, pw, ph, PaperUnits::Millimeters)
}
});
sheet_of_id.push((raw.ids[index], media.len()));
media.push(PrinterMedia {
paper,
margins,
borderless: name.to_ascii_lowercase().contains("borderless"),
});
}
if media.is_empty() {
return None;
}
let default_paper = match raw.default_id {
Some(DMPAPER_USER) => raw.default_tenths_mm.and_then(|(w, h)| {
let (w, h) = (f64::from(w) / 10.0, f64::from(h) / 10.0);
let (pw, ph) = if w <= h { (w, h) } else { (h, w) };
media
.iter()
.find(|m| same_size(&m.paper, pw, ph))
.map(|m| m.paper.clone())
}),
Some(id) => sheet_of_id
.iter()
.find(|(candidate, _)| *candidate == id)
.map(|(_, position)| media[*position].paper.clone()),
None => None,
};
Some(PrinterCapabilities {
media,
default_paper,
})
}
/// The driver's paper id for a sheet of this size in either orientation —
/// what a print job's DEVMODE needs so the sheet is printed at its own size
/// rather than on the driver's default.
pub fn paper_id_for_sheet(raw: &RawPrinterMedia, width_mm: f64, height_mm: f64) -> Option<u16> {
let (pw, ph) = if width_mm <= height_mm {
(width_mm, height_mm)
} else {
(height_mm, width_mm)
};
raw.ids
.iter()
.zip(&raw.sizes_tenths_mm)
.find(|(_, (w, h))| {
let (w, h) = (f64::from(*w) / 10.0, f64::from(*h) / 10.0);
let (sw, sh) = if w <= h { (w, h) } else { (h, w) };
(sw - pw).abs() <= 0.5 && (sh - ph).abs() <= 0.5
})
.map(|(id, _)| *id)
}
fn same_size(paper: &PaperSize, portrait_w: f64, portrait_h: f64) -> bool {
let (w, h) = paper.portrait_mm();
(w - portrait_w).abs() <= 0.5 && (h - portrait_h).abs() <= 0.5
}
#[cfg(test)]
mod tests {
use super::*;
fn slot(name: &str) -> Vec<u16> {
let mut units: Vec<u16> = name.encode_utf16().collect();
units.resize(PAPER_NAME_CHARS, 0);
units
}
fn raw(entries: &[(u16, (i32, i32), &str)]) -> RawPrinterMedia {
RawPrinterMedia {
ids: entries.iter().map(|(id, _, _)| *id).collect(),
sizes_tenths_mm: entries.iter().map(|(_, size, _)| *size).collect(),
names_utf16: entries.iter().flat_map(|(_, _, name)| slot(name)).collect(),
default_id: None,
default_tenths_mm: None,
margins: Some(Margins {
left: 3.0,
bottom: 3.0,
right: 3.0,
top: 3.0,
}),
}
}
#[test]
fn names_are_trimmed_at_the_first_nul_and_survive_a_full_slot() {
let mut buffer = slot("A4");
buffer.extend(std::iter::repeat_n(b'x' as u16, PAPER_NAME_CHARS));
// A lone surrogate decodes lossily instead of failing.
let mut broken = slot("Letter");
broken[6] = 0xD800;
broken[7] = 0;
buffer.extend(broken);
let names = paper_names(&buffer, 3);
assert_eq!(names[0], "A4");
assert_eq!(names[1].len(), PAPER_NAME_CHARS);
assert!(names[2].starts_with("Letter"));
assert_eq!(paper_names(&buffer, 1).len(), 1, "count caps the slots read");
}
#[test]
fn twins_fold_onto_their_sheet_and_nonsense_sizes_are_dropped() {
let caps = printer_media(&raw(&[
(9, (2100, 2970), "A4"),
(77, (2970, 2100), "A4 Rotated"),
(1, (2159, 2794), "Letter"),
(256, (0, 0), "User Defined"),
(300, (2100, 2970), "A4 Borderless"),
(301, (1000, 1500), "Photo 10x15"),
]))
.expect("usable sheets");
assert_eq!(caps.media.len(), 3);
assert_eq!(caps.media[0].paper.canonical, "ISO_A4_(210.00_x_297.00_MM)");
assert!(caps.media[0].borderless, "the borderless twin marks the sheet");
assert_eq!(caps.media[1].paper.canonical, "ANSI_A_(8.50_x_11.00_Inches)");
assert_eq!(caps.media[2].paper.label, "Photo 10x15", "the driver's name is kept");
assert_eq!(caps.media[2].margins.left, 3.0, "the device margins go on every sheet");
assert!(caps.default_paper.is_none());
}
#[test]
fn the_default_sheet_is_found_by_id_or_by_user_size() {
let mut media = raw(&[
(9, (2100, 2970), "A4"),
(77, (2970, 2100), "A4 Rotated"),
(8, (2970, 4200), "A3"),
]);
media.default_id = Some(77);
let caps = printer_media(&media).unwrap();
assert_eq!(
caps.default_paper.as_ref().map(|p| p.canonical.as_ref()),
Some("ISO_A4_(210.00_x_297.00_MM)"),
"the rotated twin's id still names A4"
);
media.default_id = Some(DMPAPER_USER);
media.default_tenths_mm = Some((4200, 2970));
let caps = printer_media(&media).unwrap();
assert_eq!(
caps.default_paper.as_ref().map(|p| p.canonical.as_ref()),
Some("ISO_A3_(297.00_x_420.00_MM)")
);
media.default_id = Some(999);
assert!(printer_media(&media).unwrap().default_paper.is_none());
assert!(printer_media(&RawPrinterMedia::default()).is_none(), "nothing listed → catalogue");
}
#[test]
fn device_margins_come_from_the_dc_geometry() {
let sample = DeviceCapsSample {
physical_width: 4960,
physical_height: 7016,
offset_x: 100,
offset_y: 100,
horz_res: 4760,
vert_res: 6716,
dpi_x: 600,
dpi_y: 600,
};
let margins = device_margins_mm(&sample).unwrap();
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
assert!(close(margins.left, 100.0 / 600.0 * 25.4));
assert!(close(margins.right, 100.0 / 600.0 * 25.4));
assert!(close(margins.top, 100.0 / 600.0 * 25.4));
assert!(close(margins.bottom, 200.0 / 600.0 * 25.4));
assert!(device_margins_mm(&DeviceCapsSample::default()).is_none());
let inverted = DeviceCapsSample { horz_res: 9000, ..sample };
assert!(device_margins_mm(&inverted).is_none(), "printable area wider than the sheet");
}
#[test]
fn the_paper_id_matches_either_orientation_within_half_a_millimetre() {
let media = raw(&[(9, (2100, 2970), "A4"), (8, (2970, 4200), "A3")]);
assert_eq!(paper_id_for_sheet(&media, 297.0, 210.0), Some(9));
assert_eq!(paper_id_for_sheet(&media, 210.3, 297.4), Some(9));
assert_eq!(paper_id_for_sheet(&media, 420.0, 297.0), Some(8));
assert_eq!(paper_id_for_sheet(&media, 216.0, 279.0), None);
}
}
+2467 -158
View File
File diff suppressed because it is too large. Load diff
+878
View File
@@ -0,0 +1,878 @@
// XREF path model — lexical identity for external references (Task 2).
/// Shared lexical walk for [`normalize_lexical`] (identity: case-folded on
/// Windows) and [`normalize_display`] (display: case-preserving). One
/// implementation so the two can never drift apart.
///
/// `fold_case` lowercases the drive prefix and the joined remainder.
/// UNC shares (`//server/share/…`) additionally clamp `..` at the share root
/// (`floor == 2`): climbing above the share is deterministic garbage
/// otherwise, and a share root behaves like a filesystem root for this
/// purpose. Drive-absolute paths clamp at `/`; relative paths retain
/// surplus `..` (it still means "up").
fn normalize_core(raw: &str, fold_case: bool) -> String {
// 1. Separator unification. Pure string op — no I/O, so missing files
// (the common NotFound case) normalize exactly like present ones.
let slashed = raw.replace('\\', "/");
// 2. Prefix split: drive (`C:`), UNC (`//`), or none.
let bytes = slashed.as_bytes();
let (prefix, mut rest, unc) = if bytes.len() >= 2
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
{
let drive = &slashed[..2];
(
if fold_case {
drive.to_ascii_lowercase()
} else {
drive.to_string()
},
slashed[2..].to_string(),
false,
)
} else if let Some(stripped) = slashed.strip_prefix("//") {
("//".to_string(), stripped.to_string(), true)
} else {
(String::new(), slashed, false)
};
if unc {
// Collapse `///share` → `//share` so the rejoin can't triple-slash.
rest = rest.trim_start_matches('/').to_string();
}
let absolute = rest.starts_with('/');
// Components below this stack depth survive `..`: 2 for UNC
// (server + share), 0 otherwise (absolute roots clamp via `absolute`).
let floor = if unc { 2 } else { 0 };
let climb = !(absolute || unc);
// 3. Lexical component cleanup: drop empties/`.`, resolve `..` by
// popping subject to the floor above.
let mut stack: Vec<&str> = Vec::new();
for comp in rest.split('/') {
if comp.is_empty() || comp == "." {
continue;
} else if comp == ".." {
if stack.len() > floor {
stack.pop();
} else if climb {
stack.push("..");
}
} else {
stack.push(comp);
}
}
// 4. Case fold: Windows filesystems are case-insensitive, so identity
// must be too. Native non-Windows paths remain case-sensitive.
let mut joined = stack.join("/");
// A drive/UNC path keeps Windows identity semantics even when the host
// application runs elsewhere and is inspecting a drawing created there.
if fold_case && (cfg!(windows) || !prefix.is_empty()) {
joined = joined.to_lowercase();
}
// 5. Rejoin as `{prefix}{joined}`, preserving rootedness.
if absolute {
joined = format!("/{joined}");
}
format!("{prefix}{joined}")
}
/// Lexical path normalization for reference identity. Never touches the
/// filesystem (canonicalize fails on missing files; NotFound is common).
/// Backslash→slash, `.`/`..` component cleanup, case-fold on Windows.
pub fn normalize_lexical(raw: &str) -> String {
// `fold_case` is passed as true unconditionally: `normalize_core` still
// gates the actual fold on `cfg!(windows)`, so non-Windows builds keep
// case-sensitive identity (folding there would conflate distinct files).
// `root_key` lowercases its own input separately for root comparison, so
// identity regimes stay aligned across targets without touching this.
normalize_core(raw, true)
}
/// How an XREF path is stored relative to its host drawing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pathtype {
Full,
Relative,
None,
}
/// Why a [`Pathtype::Relative`] conversion cannot be expressed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathtypeError {
AcrossDrives,
UnsavedHost,
}
/// Case-preserving sibling of [`normalize_lexical`] for stored/display paths.
/// Same walk, no case folding.
fn normalize_display(raw: &str) -> String {
normalize_core(raw, false)
}
fn root_key(normalized: &str) -> String {
let lower = normalized.to_ascii_lowercase();
let b = lower.as_bytes();
if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
return lower[..2].to_string();
}
if lower.starts_with("//") {
let mut parts = lower[2..].split('/').filter(|s| !s.is_empty());
if let (Some(server), Some(share)) = (parts.next(), parts.next()) {
return format!("//{server}/{share}");
}
return lower;
}
if lower.starts_with('/') {
return "/".to_string();
}
String::new()
}
fn is_relative_path(normalized: &str) -> bool {
let b = normalized.as_bytes();
if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
return false;
}
if normalized.starts_with("//") || normalized.starts_with('/') {
return false;
}
true
}
fn comps_after_root(path_norm: &str) -> Vec<&str> {
let b = path_norm.as_bytes();
let is_drive = b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':';
let is_unc = path_norm.starts_with("//");
if is_drive {
path_norm[2..].split('/').filter(|s| !s.is_empty()).collect()
} else if is_unc {
let all: Vec<&str> = path_norm[2..].split('/').filter(|s| !s.is_empty()).collect();
if all.len() >= 2 {
all[2..].to_vec()
} else {
Vec::new()
}
} else {
path_norm.split('/').filter(|s| !s.is_empty()).collect()
}
}
fn file_name_only(path: &str) -> String {
let disp = normalize_display(path);
if disp.is_empty() {
return String::new();
}
let mut base = disp.rsplit('/').next().unwrap_or("");
let bb = base.as_bytes();
if bb.len() >= 2 && bb[0].is_ascii_alphabetic() && bb[1] == b':' {
base = &base[2..];
}
base.to_string()
}
/// Fallible pathtype conversion. `Full` and `None` always succeed;
/// `Relative` fails with [`PathtypeError::AcrossDrives`] when drive letters
/// or UNC hosts differ (UNC vs drive-letter counts as different), and with
/// [`PathtypeError::UnsavedHost`] when `host` has no parent directory.
/// Drive comparison is case-insensitive; all comparisons use
/// [`normalize_lexical`].
pub fn to_pathtype_result(
path: &str,
host: &std::path::Path,
pathtype: Pathtype,
) -> Result<String, PathtypeError> {
match pathtype {
// Absolute output: a stored relative path is resolved against the
// host folder first, so `Full` is never a silent no-op on one.
Pathtype::Full => {
if is_relative_path(&normalize_lexical(path)) && !normalize_lexical(path).is_empty() {
if let Some(dir) = host.parent() {
if !dir.as_os_str().is_empty() {
let joined = dir.join(normalize_display(path)).to_string_lossy().into_owned();
return Ok(normalize_display(&joined));
}
}
}
Ok(normalize_display(path))
}
Pathtype::None => Ok(file_name_only(path)),
Pathtype::Relative => {
let parent = host.parent();
let parent = match parent {
None => return Err(PathtypeError::UnsavedHost),
Some(p) if p.as_os_str().is_empty() => return Err(PathtypeError::UnsavedHost),
Some(p) => p,
};
let host_dir_norm = normalize_lexical(&parent.to_string_lossy());
if host_dir_norm.is_empty() {
return Err(PathtypeError::UnsavedHost);
}
let target_norm = normalize_lexical(path);
if target_norm.is_empty() {
return Ok(String::new());
}
let target_root = root_key(&target_norm);
let host_root = root_key(&host_dir_norm);
if target_root != host_root {
if is_relative_path(&target_norm) {
return Ok(normalize_display(path));
}
return Err(PathtypeError::AcrossDrives);
}
let target_comps_norm = comps_after_root(&target_norm);
let host_comps_norm = comps_after_root(&host_dir_norm);
let mut k = 0usize;
while k < target_comps_norm.len()
&& k < host_comps_norm.len()
&& target_comps_norm[k] == host_comps_norm[k]
{
k += 1;
}
// Display components mirror normalized ones 1:1 (case folding
// cannot change component counts), so the display tail is reused
// to preserve the stored casing in the output.
let target_disp = normalize_display(path);
let target_comps_disp = comps_after_root(&target_disp);
debug_assert_eq!(target_comps_disp.len(), target_comps_norm.len());
let mut parts: Vec<String> = Vec::new();
for _ in 0..host_comps_norm.len().saturating_sub(k) {
parts.push("..".to_string());
}
for c in target_comps_disp.iter().skip(k) {
parts.push(c.to_string());
}
if parts.is_empty() {
return Ok(file_name_only(path));
}
Ok(parts.join("/"))
}
}
}
/// Panic-free pathtype conversion. [`PathtypeError::AcrossDrives`] and
/// [`PathtypeError::UnsavedHost`] fall back to [`Pathtype::Full`]
/// (absolute, separator-normalized) so callers always get a usable path.
pub fn to_pathtype(path: &str, host: &std::path::Path, pathtype: Pathtype) -> String {
match to_pathtype_result(path, host, pathtype) {
Ok(s) => s,
Err(_) => normalize_display(path),
}
}
/// Case-insensitive name match: `*` matches any run (including empty),
/// `?` matches exactly one char.
pub fn wildcard_match(name: &str, pattern: &str) -> bool {
let n: Vec<char> = name.chars().flat_map(|c| c.to_lowercase()).collect();
let p: Vec<char> = pattern.chars().flat_map(|c| c.to_lowercase()).collect();
let mut si = 0usize;
let mut pi = 0usize;
let mut star: Option<usize> = None;
let mut match_idx = 0usize;
while si < n.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == n[si]) {
si += 1;
pi += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
match_idx = si;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
match_idx += 1;
si = match_idx;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
/// What kind of external file a [`ReferenceEntry`] points at.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefKind {
DwgXref,
Image,
Pdf,
}
/// Lifecycle state of a [`ReferenceEntry`].
///
/// `Stale` (file changed since load) and `Orphaned` (definition with no
/// referencing entity) are detected in Task 8 — [`collect_entries`] never
/// produces them yet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefStatus {
Loaded,
Unloaded,
NotFound,
Failed,
Stale,
/// A nested entry whose host failed (distinct from [`RefStatus::Unreferenced`).
Orphaned,
/// A definition with no placed instances (e.g. an image definition no
/// entity references). Listed so it can be managed, never merged.
Unreferenced,
}
/// How a DWG xref attaches: full re-export (`Attach`) vs local-only (`Overlay`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefType {
Attach,
Overlay,
}
/// One external reference in the XREF manager's unified list.
///
/// `key` is the block-record handle (DWG xref) or definition-object handle
/// (image / PDF) for top-level entries — stable across renames, so
/// [`renamed`](Self::renamed) keeps it. Nested child entries (enumerated from
/// a host file, never merged) use [`child_key`] — an FNV-1a namespaced hash
/// of `(parent_key, name, saved_path)` — so a foreign handle that collides
/// with a host handle can never alias a host row. `saved_path` is the raw
/// stored string verbatim, never synthesized; `found_at` is where it actually
/// resolved, if anywhere.
///
/// `parent_key` is `None` for roots and `Some(host_key)` for nested children.
/// The palette's refresh path keys rows off `(key, saved_path)` — both sides
/// go through `collect_entries`, so the same derivation applies everywhere.
#[derive(Debug, Clone, PartialEq)]
pub struct ReferenceEntry {
pub key: u64,
pub name: String,
pub kind: RefKind,
pub ref_type: RefType,
pub status: RefStatus,
pub size_bytes: Option<u64>,
pub modified: Option<std::time::SystemTime>,
pub saved_path: String,
pub found_at: Option<String>,
pub parent_key: Option<u64>,
}
impl ReferenceEntry {
pub fn new(key: u64, name: impl Into<String>, kind: RefKind) -> Self {
Self {
key,
name: name.into(),
kind,
ref_type: RefType::Attach,
status: RefStatus::NotFound,
size_bytes: None,
modified: None,
saved_path: String::new(),
found_at: None,
parent_key: None,
}
}
/// Same entry under a new name — the key is untouched.
pub fn renamed(&self, name: impl Into<String>) -> Self {
Self {
key: self.key,
name: name.into(),
..self.clone()
}
}
}
/// Bind-style symbol name for a nested xref block: the industry-standard
/// CAD bind scheme inserts `$0$` separators, so `PLAN` → `DETAIL` → `WALLS`
/// reads transitively.
pub fn bind_symbol(parent: &str, child: &str, sym: &str) -> String {
format!("{parent}$0${child}$0${sym}")
}
/// [`bind_symbol`] for the two-level case, bumping the `$N$` counter past
/// every collision in `taken` (`PLAN$0$WALLS` taken → `PLAN$1$WALLS`).
///
/// Always terminates: `taken` holds N names, so one of the N+1 candidates
/// `0..=N` is necessarily free (pigeonhole) — no counter cap needed.
pub fn bind_symbol_taken(parent: &str, sym: &str, taken: &[impl AsRef<str>]) -> String {
for n in 0..=taken.len() as u32 {
let candidate = format!("{parent}${n}${sym}");
if !taken.iter().any(|t| t.as_ref() == candidate) {
return candidate;
}
}
// Unreachable by the argument above; kept total instead of panicking.
format!("{parent}$4294967295${sym}")
}
/// Stable key for a nested child entry.
///
/// Top-level entries keep the host handle value unchanged. Nested children
/// come from foreign files whose handle values can collide with host handles,
/// so they are namespaced via FNV-1a over `(parent_key, name, saved_path)`
/// with the top bit forced set (`0x8000_0000_0000_0000`). The high bit is the
/// namespace tag: host handles (sequentially allocated, nowhere near 2^63)
/// never have it set, so a nested key can never alias a host key — in the
/// stat cache, the unload set's raw views, or anywhere else a bare `u64`
/// travels. FNV-1a is chosen over `DefaultHasher` deliberately: the algorithm
/// is fixed, so keys are stable across runs and toolchain versions (a
/// `DefaultHasher` key would become a persistence-format hazard the moment
/// unload state is ever persisted). Same derivation is used everywhere
/// `collect_entries` output is consumed, so palette `(key, saved_path)` rows
/// keep resolving.
pub fn child_key(parent_key: u64, name: &str, saved_path: &str) -> u64 {
// FNV-1a/64 (offset 14695981039346656037, prime 1099511628211).
let mut h: u64 = 14695981039346656037;
for b in parent_key
.to_le_bytes()
.iter()
.chain(name.as_bytes())
.chain([0u8].iter())
.chain(saved_path.as_bytes())
{
h ^= *b as u64;
h = h.wrapping_mul(1099511628211);
}
h | 0x8000_0000_0000_0000
}
/// Pure status decision for one entry (no filesystem).
///
/// - `resolved` is the stat outcome (`Loaded`/`NotFound`/`Failed`; `Unloaded`
/// never reaches here — it is decided before stat).
/// - `live` is the current mtime, `cached` the load-time mtime from the
/// previous refresh (`None` on first call → no `Stale` on bootstrap).
/// The comparison is absolute in both directions: a file restored from
/// backup (live < cached) changed content just as much as an edited one.
/// - `parent_failed` is true for nested children whose host entry is
/// `NotFound`/`Failed` → `Loaded` is overridden to `Orphaned`.
///
/// The 1s slack absorbs filesystem timestamp granularity so a file that did
/// not actually change is not flagged `Stale`. Conversely, changes landing
/// between document open and the first refresh are invisible that session —
/// the baseline only exists from the first stat on (no file watcher in v1).
pub fn decide_status(
resolved: RefStatus,
live: Option<std::time::SystemTime>,
cached: Option<std::time::SystemTime>,
parent_failed: bool,
) -> RefStatus {
if parent_failed && resolved == RefStatus::Loaded {
return RefStatus::Orphaned;
}
if resolved == RefStatus::Loaded {
if let (Some(l), Some(c)) = (live, cached) {
let delta = l.duration_since(c).ok().or_else(|| c.duration_since(l).ok());
if delta.is_some_and(|d| d > std::time::Duration::from_secs(1)) {
return RefStatus::Stale;
}
}
}
resolved
}
/// Session set of unloaded reference keys.
///
/// Owns the `HashSet<UnloadKey>` that `collect_entries` takes as `unloaded`.
/// Task 8b wires session state; the set itself lives here so both sides
/// share one owner.
///
/// Keys are domain-tagged: [`UnloadKey::Direct`] wraps a top-level handle
/// value (nested children are never unloadable on their own) while
/// [`UnloadKey::Nested`] wraps a nested [`child_key`] hash. The tag keeps a
/// nested hash from ever aliasing a host handle with the same numeric value.
/// NOTE (nested-key invariant): only [`add_key`](UnloadSet::add_key) may
/// insert `Nested` keys and only [`contains_key`](UnloadSet::contains_key) /
/// [`is_unloaded_key`](UnloadSet::is_unloaded_key) may query them — every op
/// site guards nested rows before touching session state, so the untagged
/// `add`/`contains`/`is_unloaded`/`remove` helpers below only ever see
/// direct keys. New code must use the `_key` variants for nested entries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UnloadKey {
Direct(u64),
Nested(u64),
}
#[derive(Debug, Clone, Default)]
pub struct UnloadSet(pub std::collections::HashSet<UnloadKey>);
impl UnloadSet {
pub fn add(&mut self, key: u64) {
self.0.insert(UnloadKey::Direct(key));
}
pub fn add_key(&mut self, key: UnloadKey) {
self.0.insert(key);
}
pub fn remove(&mut self, key: &u64) {
self.0.remove(&UnloadKey::Direct(*key));
}
pub fn remove_key(&mut self, key: &UnloadKey) {
self.0.remove(key);
}
pub fn contains(&self, key: &u64) -> bool {
self.0.contains(&UnloadKey::Direct(*key))
}
pub fn contains_key(&self, key: &UnloadKey) -> bool {
self.0.contains(key)
}
pub fn is_unloaded(&self, key: u64) -> bool {
self.0.contains(&UnloadKey::Direct(key))
}
pub fn is_unloaded_key(&self, key: UnloadKey) -> bool {
self.0.contains(&key)
}
pub fn as_set(&self) -> &std::collections::HashSet<UnloadKey> {
&self.0
}
}
/// Load-time mtimes per reference key, populated on first refresh.
///
/// `Stale` is detectable only thereafter: an empty cache means bootstrap, so
/// `decide_status` never reports `Stale` without a cached baseline. Reload
/// clears the key (fresh baseline on next refresh).
///
/// KEY NAMESPACE: keys are host-handle values for direct entries and
/// high-bit-tagged [`child_key`] hashes for nested children (see
/// [`child_key`]: the tag bit keeps the two spaces disjoint, so sharing one
/// `u64` map is sound — the same discipline as [`UnloadKey`], encoded in the
/// key itself because every call site here already threads bare `u64`s).
/// Never insert a foreign handle that is neither.
#[derive(Debug, Clone, Default)]
pub struct RefStatCache(pub std::collections::HashMap<u64, std::time::SystemTime>);
impl RefStatCache {
pub fn insert(&mut self, key: u64, mtime: std::time::SystemTime) {
self.0.insert(key, mtime);
}
pub fn get(&self, key: &u64) -> Option<std::time::SystemTime> {
self.0.get(key).copied()
}
pub fn remove(&mut self, key: &u64) {
self.0.remove(key);
}
}
#[cfg(test)]
mod tests {
use super::{normalize_display, normalize_lexical, to_pathtype, to_pathtype_result, wildcard_match, Pathtype, PathtypeError};
use super::{bind_symbol, bind_symbol_taken, RefKind, ReferenceEntry};
use super::{child_key, decide_status, RefStatCache, RefStatus, UnloadSet};
#[test]
fn child_key_differs_from_colliding_host_key() {
// Crafted collision: nested foreign handle 42 equals a host handle 42.
// Namespaced derivation must not alias the host row.
let host_key = 42u64;
let nested = child_key(100, "DETAIL", "refs/detail.dwg");
assert_ne!(nested, host_key);
// High-bit namespace tag: host handles never carry it.
assert_ne!(nested & 0x8000_0000_0000_0000, 0);
// Stable: same inputs → same key; different parent → different key.
assert_eq!(nested, child_key(100, "DETAIL", "refs/detail.dwg"));
assert_ne!(nested, child_key(101, "DETAIL", "refs/detail.dwg"));
}
#[test]
fn normalize_is_idempotent() {
// normalize(normalize(x)) == normalize(x) across drives, UNC,
// relative, dot-segments, and empty inputs.
let cases = [
"C:\\Host\\PLAN.dwg",
"c:/host/./plan.dwg",
"//SERVER/Share/../Share/f.dwg",
"//s/sh/../../x.dwg",
"rel/../../x.dwg",
"c:/a/../../b.dwg",
"a//b\\\\c.dwg",
"../up/ref.dwg",
"",
".",
];
for c in cases {
let once = normalize_lexical(c);
assert_eq!(normalize_lexical(&once), once, "idempotence for {c:?}");
assert_eq!(normalize_display(&once), once, "display-stable for {c:?}");
}
// UNC climbs clamp at the share root instead of producing garbage.
assert_eq!(normalize_lexical("//s/sh/../../x.dwg"), "//s/sh/x.dwg");
}
#[test]
fn relative_roundtrip_invariant() {
// The Relative branch's real contract: converting an absolute path to
// Relative and resolving it against the same host must recover the
// normalized absolute path.
let host = std::path::Path::new("C:/Drawings/host.dwg");
for target in [
"C:/Drawings/refs/plan.dwg",
"C:/Drawings/plan.dwg",
"C:/Lib/detail.dwg",
] {
let rel = to_pathtype(target, host, Pathtype::Relative);
let back = std::path::Path::new("C:/Drawings").join(&rel);
assert_eq!(
normalize_lexical(&back.to_string_lossy()),
normalize_lexical(target),
"roundtrip for {target:?} via {rel:?}"
);
}
}
#[test]
fn decide_status_backwards_mtime_is_stale() {
// A file restored from backup (live < cached) changed content just
// as much as an edited one: absolute difference, both directions.
use std::time::{Duration, UNIX_EPOCH};
let cached = UNIX_EPOCH + Duration::from_secs(1_000);
let older = cached - Duration::from_secs(30);
assert_eq!(
decide_status(RefStatus::Loaded, Some(older), Some(cached), false),
RefStatus::Stale
);
}
#[test]
fn decide_status_stale_needs_slack() {
use std::time::{Duration, UNIX_EPOCH};
let cached = UNIX_EPOCH + Duration::from_secs(1_000);
// +0.5s: within 1s slack → stays Loaded.
let live = cached + Duration::from_millis(500);
assert_eq!(
decide_status(RefStatus::Loaded, Some(live), Some(cached), false),
RefStatus::Loaded
);
// +2s: beyond slack → Stale.
let live2 = cached + Duration::from_secs(2);
assert_eq!(
decide_status(RefStatus::Loaded, Some(live2), Some(cached), false),
RefStatus::Stale
);
// No cached baseline (first refresh) → never Stale on bootstrap.
assert_eq!(
decide_status(RefStatus::Loaded, Some(live2), None, false),
RefStatus::Loaded
);
}
#[test]
fn decide_status_orphaned_overrides_loaded_only() {
use std::time::{Duration, UNIX_EPOCH};
let t = Some(UNIX_EPOCH + Duration::from_secs(1_000));
assert_eq!(
decide_status(RefStatus::Loaded, t, t, true),
RefStatus::Orphaned
);
// Non-Loaded nested states survive the parent override.
assert_eq!(
decide_status(RefStatus::NotFound, t, t, true),
RefStatus::NotFound
);
assert_eq!(
decide_status(RefStatus::Failed, t, t, true),
RefStatus::Failed
);
}
#[test]
fn unload_set_add_remove_contains() {
let mut s = UnloadSet::default();
assert!(!s.is_unloaded(7));
s.add(7);
assert!(s.is_unloaded(7));
assert!(s.contains(&7));
s.remove(&7);
assert!(!s.is_unloaded(7));
}
#[test]
fn unload_set_namespaces_direct_vs_nested() {
use super::UnloadKey;
// Crafted collision: host handle 42 vs nested child hash 42 must not
// cross-trigger — the domain tag keeps the two spaces apart.
let mut s = UnloadSet::default();
s.add(42);
assert!(s.is_unloaded(42));
assert!(!s.contains_key(&UnloadKey::Nested(42)));
assert!(!s.is_unloaded_key(UnloadKey::Nested(42)));
let mut t = UnloadSet::default();
t.add_key(UnloadKey::Nested(42));
assert!(!t.is_unloaded(42));
assert!(!t.contains(&42));
assert!(t.is_unloaded_key(UnloadKey::Nested(42)));
}
#[test]
fn ref_stat_cache_roundtrip() {
use std::time::{Duration, UNIX_EPOCH};
let mut c = RefStatCache::default();
let t = UNIX_EPOCH + Duration::from_secs(5);
c.insert(9, t);
assert_eq!(c.get(&9), Some(t));
}
#[test]
fn lexical_normalize_missing_file_no_fs_touch() {
let a = normalize_lexical("C:\\Host\\PLAN.dwg");
let b = normalize_lexical("c:/host/./plan.dwg");
assert_eq!(a, b);
let c = normalize_lexical("C:\\NoSuchDir\\Sub\\..\\REF.DWG");
let d = normalize_lexical("c:\\nosuchdir\\ref.dwg");
assert_eq!(c, d);
}
#[test]
#[cfg(windows)]
fn lexical_normalize_exact_forms() {
assert_eq!(normalize_lexical("C:\\Host\\PLAN.dwg"), "c:/host/plan.dwg");
assert_eq!(
normalize_lexical("C:\\NoSuchDir\\Sub\\..\\REF.DWG"),
"c:/nosuchdir/ref.dwg"
);
assert_eq!(
normalize_lexical("//SERVER/Share/./File.DWG"),
"//server/share/file.dwg"
);
assert_eq!(normalize_lexical("c:/a/../../b.dwg"), "c:/b.dwg");
assert_eq!(normalize_lexical("rel/../../x.dwg"), "../x.dwg");
assert_eq!(normalize_lexical("a//b\\\\c.dwg"), "a/b/c.dwg");
assert_eq!(normalize_lexical("a/b/"), "a/b");
assert_eq!(normalize_lexical(""), "");
assert_eq!(normalize_lexical("."), "");
}
// Deferred byte-level roundtrip probe (SPIKE1 / Task 1 carryover):
// temp-dir only, nothing added to the repo. Builds an in-memory document
// carrying each reference kind, writes it out, re-reads, and asserts
// every SPIKE1 cell survived the round trip.
#[cfg(not(target_arch = "wasm32"))]
fn roundtrip_probe(ext: &str) {
use acadrust::objects::{ImageDefinition, ObjectType, UnderlayDefinition};
use acadrust::tables::BlockRecord;
use acadrust::CadDocument;
let dir = std::env::temp_dir().join(format!(
"ocs_xref_probe_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("probe dir");
let mut doc = CadDocument::new();
// Cell 1: xref block record — is_xref flag + absolute path.
let xref_abs = dir.join("PROBE_REF.dwg").to_string_lossy().into_owned();
let mut br = BlockRecord::new("PROBE_REF");
br.flags.is_xref = true;
br.xref_path = xref_abs.clone();
doc.block_records.add(br).expect("add xref block record");
// Cell 2: raster image definition with an absolute file path.
let img_handle = doc.allocate_handle();
let mut img_def =
ImageDefinition::with_dimensions(r"C:\Probe\IMG.png", 64u32, 64u32);
img_def.handle = img_handle;
img_def.is_loaded = true;
doc.objects
.insert(img_handle, ObjectType::ImageDefinition(img_def));
// Cell 3: PDF underlay definition with an absolute file path.
let und_handle = doc.allocate_handle();
let mut und_def = UnderlayDefinition::pdf(r"C:\Probe\DOC.pdf", "1");
und_def.handle = und_handle;
doc.objects
.insert(und_handle, ObjectType::UnderlayDefinition(und_def));
// Cell 4: the retain flag itself ($VISRETAIN).
doc.header.retain_xref_visibility = true;
let bytes = crate::io::save_to_bytes(&doc, ext, doc.version)
.expect("probe save");
std::fs::write(dir.join(format!("probe.{ext}")), &bytes).expect("probe write");
let back = crate::io::load_bytes(&format!("probe.{ext}"), bytes).expect("probe reload");
let br2 = back
.block_records
.iter()
.find(|b| b.name.eq_ignore_ascii_case("PROBE_REF"))
.expect("xref block record round-trips");
assert!(br2.flags.is_xref, "is_xref flag round-trips");
assert_eq!(br2.xref_path, xref_abs, "abs xref path round-trips");
let img_back = back
.objects
.values()
.find_map(|o| match o {
ObjectType::ImageDefinition(d) => Some(d),
_ => None,
})
.expect("image definition round-trips");
assert_eq!(img_back.file_name, r"C:\Probe\IMG.png");
let und_back = back
.objects
.values()
.find_map(|o| match o {
ObjectType::UnderlayDefinition(d) => Some(d),
_ => None,
})
.expect("underlay definition round-trips");
assert_eq!(und_back.file_path, r"C:\Probe\DOC.pdf");
assert!(
back.header.retain_xref_visibility,
"retain_xref_visibility round-trips as true"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn probe_roundtrip_dwg() {
roundtrip_probe("dwg");
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn probe_roundtrip_dxf() {
roundtrip_probe("dxf");
}
#[test]
fn pathtype_full_relative_none_roundtrip() {
let host = std::path::Path::new("C:/Drawings/host.dwg");
let full = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::Full);
assert_eq!(full, "C:/Drawings/refs/plan.dwg");
let rel = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::Relative);
assert_eq!(rel, "refs/plan.dwg");
let none = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::None);
assert_eq!(none, "plan.dwg");
}
#[test]
fn relative_across_drives_errors() {
let host = std::path::Path::new("C:/Drawings/host.dwg");
let err = to_pathtype_result("D:/Lib/plan.dwg", host, Pathtype::Relative);
assert_eq!(err, Err(PathtypeError::AcrossDrives));
}
#[test]
fn wildcard_name_match_case_insensitive() {
assert!(wildcard_match("PLAN-East", "plan-*"));
assert!(wildcard_match("A1", "a?"));
assert!(!wildcard_match("A12", "a?"));
}
#[test]
fn entry_key_stable_across_rename() {
let a = ReferenceEntry::new(101, "PLAN", RefKind::DwgXref);
let b = a.renamed("PLAN-EAST");
assert_eq!(a.key, b.key);
assert_eq!(b.name, "PLAN-EAST");
}
#[test]
fn bind_chain_naming_transitive() {
assert_eq!(bind_symbol("PLAN", "DETAIL", "WALLS"), "PLAN$0$DETAIL$0$WALLS");
assert_eq!(bind_symbol_taken("PLAN", "WALLS", &["PLAN$0$WALLS"]), "PLAN$1$WALLS");
}
}