forked from 1b-agent/opencadstudio-upstream
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:
commit
8648225ac5
309 files changed
+81166
-4157
No files matched your search
@@ -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
@@ -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]
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user