web: raster images, WBLOCK/SAVEALL/SCRIPT/CTB/LAYTRANS/INSERT, filled TTF text

A fresh web-vs-desktop audit found the remaining gaps; this closes the
top ten.

Raster images: IMAGE attach read the picked file via image::open on a
name-only path (dead on wasm), and raster references inside opened
drawings decoded the same way. Picked bytes are now registered in a
name-keyed store (the PDF-underlay pattern), decoding runs from memory,
and dropped .png/.jpg/.bmp/.tif files register by name so unresolved
references in opened drawings re-resolve and render (and export to PDF).

Command gaps fixed with the bytes-at-pick / download_bytes pattern:
- WBLOCK now downloads the extracted block instead of failing on fs.
- SAVEALL saves every dirty tab instead of printing a no-op message.
- SCRIPT picks and reads the .scr file through the browser picker.
- CENTERLTYPEFILE picks the .lin/.dnl file and populates from text.
- Custom .ctb tables picked on web now parse, register for the
  session, appear in the plot dialog list, and apply (load_named
  previously returned builtin-only on wasm).
- LAYTRANS 'Load standards file' parses the picked drawing on the
  existing web worker.
- INSERT 'from file' in the block palette parses picked bytes and
  feeds the shared import path.

Text fidelity: TTF-named styles rendered as thin stroke glyphs on web
because the font database was empty. Fetched Noto faces are now
registered into it with a family alias table (Arial, Times New Roman,
Cambria, ... -> Noto Sans/Serif), so those styles render filled, shape,
export to PDF as vector fills, and appear in the style picker.
ViewCube labels request semibold again on web.

docs/native-vs-web.md: the hatch and font rows were stale (both work on
web now), the GPU row claimed WebGPU is used where available (the
backend is pinned to OpenGL), and the parallelism row missed the parse
worker — all corrected.
This commit is contained in:
Afonsop committed 2026-09-17 20:59:58 +01:00
1 parent fa7ef87f00
commit c643a10ed9
43 files changed
+3216 -1336

No files matched your search

+352 -267
View File
@@ -3,28 +3,28 @@
// All file reading/writing goes through acadrust.
// Default save format: DWG (AC1032 / R2018+).
pub mod file_association;
#[cfg(not(target_arch = "wasm32"))]
pub mod edit_lock;
pub mod file_association;
pub mod linetypes;
pub mod obj;
#[cfg(not(target_arch = "wasm32"))]
pub mod single_instance;
pub mod paper_sizes;
pub mod patterns;
pub mod pdf_export;
pub mod plot_style;
pub mod print_to_printer;
pub mod recovery;
#[cfg(not(target_arch = "wasm32"))]
pub mod single_instance;
pub mod step;
pub mod stl;
pub mod xref;
pub mod linetypes;
pub mod patterns;
pub mod update_check;
pub mod paper_sizes;
pub mod thumbnail;
#[cfg(target_arch = "wasm32")]
mod web_worker;
pub mod update_check;
#[cfg(target_arch = "wasm32")]
pub(crate) mod web_recent;
#[cfg(target_arch = "wasm32")]
mod web_worker;
pub mod xref;
// Registered on every target so the pure helpers (and their tests) compile on
// the native host; the OPFS half inside is wasm-only.
pub(crate) mod web_autosave;
@@ -97,9 +97,7 @@ pub fn open_phase_name(phase: u8) -> &'static str {
fn recovery_fingerprint_needed(caches: &DerivedCaches) -> bool {
let parser_issue = caches.read_stats.as_ref().is_some_and(|stats| {
stats.recovered()
|| stats.skipped_source_records > 0
|| !stats.stream_completed
stats.recovered() || stats.skipped_source_records > 0 || !stats.stream_completed
});
let reference_issue = caches.xrefs.iter().any(|item| {
matches!(
@@ -169,10 +167,16 @@ impl From<&str> for OpenLoadError {
pub async fn pick_open_path() -> Option<(PathBuf, u64)> {
let handle = crate::sys::file_dialog()
.set_title(crate::t!("Open CAD file").as_ref())
.add_filter(crate::t!("CAD Files").as_ref(), &["dwg", "dxf", "bak", "sv$", "DWG", "DXF", "BAK"])
.add_filter(
crate::t!("CAD Files").as_ref(),
&["dwg", "dxf", "bak", "sv$", "DWG", "DXF", "BAK"],
)
.add_filter(crate::t!("DWG Files").as_ref(), &["dwg", "DWG"])
.add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"])
.add_filter(crate::t!("Backup / Autosave").as_ref(), &["bak", "sv$", "BAK"])
.add_filter(
crate::t!("Backup / Autosave").as_ref(),
&["bak", "sv$", "BAK"],
)
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await?;
@@ -200,11 +204,14 @@ pub async fn pick_layer_standard_path() -> Option<PathBuf> {
/// Pick where a set of layer mappings is written, or read back from.
pub async fn pick_layer_mapping_path(save: bool) -> Option<PathBuf> {
let dialog = crate::sys::file_dialog()
.set_title(crate::t!(if save {
"Save layer mappings"
} else {
"Load layer mappings"
}).as_ref())
.set_title(
crate::t!(if save {
"Save layer mappings"
} else {
"Load layer mappings"
})
.as_ref(),
)
.add_filter(crate::t!("Layer mappings").as_ref(), &["ocslmap"])
.add_filter(crate::t!("All Files").as_ref(), &["*"]);
let handle = if save {
@@ -215,6 +222,42 @@ pub async fn pick_layer_mapping_path(save: bool) -> Option<PathBuf> {
Some(crate::sys::handle_path(&handle))
}
/// Web SCRIPT: pick the .scr file through the browser and read its text —
/// the byte-level stand-in for the typed path the native command reads with
/// `std::fs::read_to_string`. `None` means the user cancelled.
#[cfg(target_arch = "wasm32")]
pub async fn pick_script_web() -> Option<(String, String)> {
let Some(handle) = crate::sys::file_dialog()
.set_title(crate::t!("SCRIPT path to the .scr file:").as_ref())
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
else {
return None;
};
let name = handle.file_name();
let bytes = handle.read().await;
Some((name, String::from_utf8_lossy(&bytes).into_owned()))
}
/// Web CENTERLTYPEFILE: pick the linetype (.lin/.dnl) file through the
/// browser and read its text — the stand-in for the typed path the native
/// command reads inside `set_centerline_setting`. `None` = cancelled.
#[cfg(target_arch = "wasm32")]
pub async fn pick_linetype_file_web() -> Option<(String, String)> {
let Some(handle) = crate::sys::file_dialog()
.set_title(crate::t!("Load linetype file").as_ref())
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
else {
return None;
};
let name = handle.file_name();
let bytes = handle.read().await;
Some((name, String::from_utf8_lossy(&bytes).into_owned()))
}
/// Load a CAD file from a known path. Parsing and cache building run on a
/// dedicated OS thread so the async executor stays free for rendering during
/// the load. Writes phase markers into `phase` so the UI can show
@@ -303,116 +346,114 @@ async fn open_path_with_phase_attempt(
let initial_fingerprint = crate::io::edit_lock::FileFingerprint::capture(&path2).ok();
let attempted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
(|| -> Result<_, OpenAttemptFailure> {
use iced::time::Instant;
progress2.set(crate::app::OPEN_PHASE_PARSING, 200, 0, 1000);
let t_parse = Instant::now();
let parser_progress = {
let progress = Arc::clone(&progress2);
let callback: Arc<dyn Fn(u16) + Send + Sync> = Arc::new(move |value| {
progress.set_fraction(
crate::app::OPEN_PHASE_PARSING,
200,
5600,
value as usize,
1000,
);
});
callback
};
std::fs::File::open(&path2).map_err(|error| OpenAttemptFailure {
message: format!("failed to open drawing: {error}"),
read_stats: None,
recoverable: false,
})?;
let outcome = load_file_for_open(&path2, Some(parser_progress), &attempt)?;
let read_stats = outcome.stats;
let mut doc = outcome.document;
let parse_ms = t_parse.elapsed().as_millis() as u32;
progress2.set(crate::app::OPEN_PHASE_PARSING, 5800, 1000, 1000);
let t_purge = Instant::now();
let dropped = purge_corrupt_entities(&mut doc);
let purge_ms = t_purge.elapsed().as_millis() as u32;
if matches!(attempt, OpenAttempt::Strict) && dropped > 0 {
return Err(OpenAttemptFailure {
message: format!(
"normal read found {dropped} structurally invalid drawing records"
),
read_stats: Some(read_stats),
recoverable: true,
});
}
progress2.set(crate::app::OPEN_PHASE_XREF, 6000, 0, 1);
let t_xref = Instant::now();
let (xref_infos, xref_dropped) = if let Some(base_dir) = path2.parent() {
let xref_progress = {
use iced::time::Instant;
progress2.set(crate::app::OPEN_PHASE_PARSING, 200, 0, 1000);
let t_parse = Instant::now();
let parser_progress = {
let progress = Arc::clone(&progress2);
let callback: Arc<dyn Fn(usize, usize) + Send + Sync> =
Arc::new(move |completed, total| {
progress.set_fraction(
crate::app::OPEN_PHASE_XREF,
6000,
1400,
completed,
total,
);
});
let callback: Arc<dyn Fn(u16) + Send + Sync> = Arc::new(move |value| {
progress.set_fraction(
crate::app::OPEN_PHASE_PARSING,
200,
5600,
value as usize,
1000,
);
});
callback
};
crate::io::xref::resolve_xrefs_with_progress(
&mut doc,
base_dir,
Some(xref_progress),
)
} else {
(Vec::new(), 0)
};
let xref_ms = t_xref.elapsed().as_millis() as u32;
progress2.set(crate::app::OPEN_PHASE_CACHING, 7400, 0, 10000);
let t_caches = Instant::now();
let cache_progress = |value: u16| {
progress2.set_fraction(
crate::app::OPEN_PHASE_CACHING,
7400,
2200,
value as usize,
10000,
std::fs::File::open(&path2).map_err(|error| OpenAttemptFailure {
message: format!("failed to open drawing: {error}"),
read_stats: None,
recoverable: false,
})?;
let outcome = load_file_for_open(&path2, Some(parser_progress), &attempt)?;
let read_stats = outcome.stats;
let mut doc = outcome.document;
let parse_ms = t_parse.elapsed().as_millis() as u32;
progress2.set(crate::app::OPEN_PHASE_PARSING, 5800, 1000, 1000);
let t_purge = Instant::now();
let dropped = purge_corrupt_entities(&mut doc);
let purge_ms = t_purge.elapsed().as_millis() as u32;
if matches!(attempt, OpenAttempt::Strict) && dropped > 0 {
return Err(OpenAttemptFailure {
message: format!(
"normal read found {dropped} structurally invalid drawing records"
),
read_stats: Some(read_stats),
recoverable: true,
});
}
progress2.set(crate::app::OPEN_PHASE_XREF, 6000, 0, 1);
let t_xref = Instant::now();
let (xref_infos, xref_dropped) = if let Some(base_dir) = path2.parent() {
let xref_progress = {
let progress = Arc::clone(&progress2);
let callback: Arc<dyn Fn(usize, usize) + Send + Sync> =
Arc::new(move |completed, total| {
progress.set_fraction(
crate::app::OPEN_PHASE_XREF,
6000,
1400,
completed,
total,
);
});
callback
};
crate::io::xref::resolve_xrefs_with_progress(
&mut doc,
base_dir,
Some(xref_progress),
)
} else {
(Vec::new(), 0)
};
let xref_ms = t_xref.elapsed().as_millis() as u32;
progress2.set(crate::app::OPEN_PHASE_CACHING, 7400, 0, 10000);
let t_caches = Instant::now();
let cache_progress = |value: u16| {
progress2.set_fraction(
crate::app::OPEN_PHASE_CACHING,
7400,
2200,
value as usize,
10000,
);
};
let mut caches = crate::scene::build_derived_caches_with_progress(
&doc,
&cache_progress,
path2.parent(),
);
};
let mut caches = crate::scene::build_derived_caches_with_progress(
&doc,
&cache_progress,
path2.parent(),
);
caches.timings = crate::scene::OpenTimings {
parse_ms,
purge_ms,
caches_ms: t_caches.elapsed().as_millis() as u32,
xref_ms,
// Filled in after `prepare_open_geometry` below.
finalize_ms: 0,
};
caches.corrupt_dropped = dropped;
caches.read_stats = Some(read_stats);
caches.xref_dropped = xref_dropped;
caches.xrefs = xref_infos;
if recovery_fingerprint_needed(&caches) {
caches.source_sha256 = stable_sha256_file(
&path2,
initial_fingerprint.as_ref(),
);
}
progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9600, 0, 1);
let t_finalize = Instant::now();
let (prepared_doc, prepared_geometry) =
crate::scene::prepare_open_geometry(doc, &caches, model_bg);
caches.timings.finalize_ms = t_finalize.elapsed().as_millis() as u32;
doc = prepared_doc;
caches.prepared_geometry = Some(prepared_geometry);
progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9950, 1, 1);
// The localized command-line summary predates `finalize_ms` and
// is keyed on its format string, so the full breakdown goes out
// here instead of changing that key across every locale.
crate::perf_record!(
caches.timings = crate::scene::OpenTimings {
parse_ms,
purge_ms,
caches_ms: t_caches.elapsed().as_millis() as u32,
xref_ms,
// Filled in after `prepare_open_geometry` below.
finalize_ms: 0,
};
caches.corrupt_dropped = dropped;
caches.read_stats = Some(read_stats);
caches.xref_dropped = xref_dropped;
caches.xrefs = xref_infos;
if recovery_fingerprint_needed(&caches) {
caches.source_sha256 =
stable_sha256_file(&path2, initial_fingerprint.as_ref());
}
progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9600, 0, 1);
let t_finalize = Instant::now();
let (prepared_doc, prepared_geometry) =
crate::scene::prepare_open_geometry(doc, &caches, model_bg);
caches.timings.finalize_ms = t_finalize.elapsed().as_millis() as u32;
doc = prepared_doc;
caches.prepared_geometry = Some(prepared_geometry);
progress2.set(crate::app::OPEN_PHASE_FINALIZING, 9950, 1, 1);
// The localized command-line summary predates `finalize_ms` and
// is keyed on its format string, so the full breakdown goes out
// here instead of changing that key across every locale.
crate::perf_record!(
"[perf] open-phases parse={}ms purge={}ms xref={}ms caches={}ms finalize={}ms",
caches.timings.parse_ms,
caches.timings.purge_ms,
@@ -430,10 +471,7 @@ async fn open_path_with_phase_attempt(
} else {
OpenLoadError {
message: failure.message,
source_sha256: stable_sha256_file(
&path2,
initial_fingerprint.as_ref(),
),
source_sha256: stable_sha256_file(&path2, initial_fingerprint.as_ref()),
read_stats: failure.read_stats,
recovery_available: false,
}
@@ -445,17 +483,14 @@ async fn open_path_with_phase_attempt(
);
Err(OpenLoadError {
message,
source_sha256: stable_sha256_file(
&path2,
initial_fingerprint.as_ref(),
),
source_sha256: stable_sha256_file(&path2, initial_fingerprint.as_ref()),
read_stats: None,
recovery_available: false,
})
}
};
let _ = sender.send(result);
})
})
.map_err(|error| OpenLoadError::from(format!("failed to start parser thread: {error}")))?;
let (doc, caches) = receiver
.await
@@ -505,12 +540,13 @@ pub struct WebOpenOutcome {
}
#[cfg(target_arch = "wasm32")]
pub async fn pick_and_load_web(
progress: Arc<OpenProgressState>,
) -> WebOpenOutcome {
pub async fn pick_and_load_web(progress: Arc<OpenProgressState>) -> WebOpenOutcome {
let Some(handle) = crate::sys::file_dialog()
.set_title(crate::t!("Open CAD file").as_ref())
.add_filter(crate::t!("CAD Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"])
.add_filter(
crate::t!("CAD Files").as_ref(),
&["dwg", "dxf", "DWG", "DXF"],
)
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
@@ -546,10 +582,7 @@ pub async fn pick_and_load_web(
/// Reopen a browser-private recent copy without showing the file picker.
#[cfg(target_arch = "wasm32")]
pub async fn open_recent_web(
path: PathBuf,
progress: Arc<OpenProgressState>,
) -> WebOpenOutcome {
pub async fn open_recent_web(path: PathBuf, progress: Arc<OpenProgressState>) -> WebOpenOutcome {
open_recent_web_attempt(path, progress, false, String::new()).await
}
@@ -562,15 +595,7 @@ pub async fn recover_web_bytes(
initial_stats: Option<acadrust::ReadStats>,
) -> WebOpenOutcome {
let size_bytes = bytes.len() as u64;
let result = load_web_bytes(
&name,
&bytes,
progress,
true,
&initial_error,
initial_stats,
)
.await;
let result = load_web_bytes(&name, &bytes, progress, true, &initial_error, initial_stats).await;
WebOpenOutcome {
name,
size_bytes,
@@ -613,15 +638,7 @@ async fn open_recent_web_attempt(
bytes.len(),
bytes.len(),
);
let result = load_web_bytes(
&name,
&bytes,
progress,
recovery_mode,
&initial_error,
None,
)
.await;
let result = load_web_bytes(&name, &bytes, progress, recovery_mode, &initial_error, None).await;
let keep_for_recovery = result
.as_ref()
.err()
@@ -647,38 +664,32 @@ async fn load_web_bytes(
mut initial_stats: Option<acadrust::ReadStats>,
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), OpenLoadError> {
progress.set(crate::app::OPEN_PHASE_PARSING, 1000, 0, 1);
let (outcome, mut source_sha256) = match web_worker::parse_document(
name,
bytes,
recovery_mode,
initial_error,
)
.await
{
Ok(result) => result,
Err(error) => {
let recoverable_parse_error = error.recovery_available;
let mut source_sha256 = error.source_sha256;
if recovery_mode && source_sha256.is_none() {
source_sha256 = web_worker::sha256_document(bytes).await.ok();
}
let read_stats = merge_read_stats(error.read_stats, initial_stats.take());
let message = if recovery_mode && !error.message.contains("initial read failed:") {
format!(
"initial read failed: {initial_error}; recovery read failed: {}",
let (outcome, mut source_sha256) =
match web_worker::parse_document(name, bytes, recovery_mode, initial_error).await {
Ok(result) => result,
Err(error) => {
let recoverable_parse_error = error.recovery_available;
let mut source_sha256 = error.source_sha256;
if recovery_mode && source_sha256.is_none() {
source_sha256 = web_worker::sha256_document(bytes).await.ok();
}
let read_stats = merge_read_stats(error.read_stats, initial_stats.take());
let message = if recovery_mode && !error.message.contains("initial read failed:") {
format!(
"initial read failed: {initial_error}; recovery read failed: {}",
error.message
)
} else {
error.message
)
} else {
error.message
};
return Err(OpenLoadError {
message: format!("Web parser worker: {message}"),
recovery_available: !recovery_mode && recoverable_parse_error,
source_sha256,
read_stats,
});
}
};
};
return Err(OpenLoadError {
message: format!("Web parser worker: {message}"),
recovery_available: !recovery_mode && recoverable_parse_error,
source_sha256,
read_stats,
});
}
};
let mut outcome = outcome;
if let Some(initial_stats) = initial_stats.take() {
merge_read_diagnostics(&mut outcome.stats, initial_stats);
@@ -695,9 +706,7 @@ async fn load_web_bytes(
let dropped = purge_corrupt_entities(&mut doc);
if !recovery_mode && dropped > 0 {
return Err(OpenLoadError {
message: format!(
"normal read found {dropped} structurally invalid drawing records"
),
message: format!("normal read found {dropped} structurally invalid drawing records"),
source_sha256: None,
read_stats: Some(outcome.stats),
recovery_available: true,
@@ -715,6 +724,50 @@ async fn load_web_bytes(
Ok((name.to_string(), path, doc, caches))
}
/// Web: parse a *second* drawing (a block to insert, a layer-standards
/// file) from bytes on the parse worker, with the same post-parse fixups
/// `load_web_bytes` applies — the byte-level counterpart of `load_file`.
#[cfg(target_arch = "wasm32")]
async fn parse_web_document(name: &str, bytes: &[u8]) -> Result<CadDocument, String> {
let (outcome, _) = web_worker::parse_document(name, bytes, false, "")
.await
.map_err(|error| error.message)?;
let mut doc = outcome.document;
normalize_block_origins(&mut doc);
if name.to_ascii_lowercase().ends_with(".dxf") {
fix_dxf_dimension_rotations(&mut doc);
fix_dxf_layout_plot_settings(&mut doc);
}
fix_viewport_status_flags(&mut doc);
fix_current_style_names(&mut doc);
Ok(doc)
}
/// Web: show the browser picker, read the chosen file's bytes, and parse it
/// as a drawing on the worker — `pick_and_load_web`'s pick-read-parse core
/// for features that consume a second drawing instead of opening a tab.
/// `Ok(None)` means the user cancelled.
#[cfg(target_arch = "wasm32")]
pub async fn pick_and_parse_web(
title: &str,
filter_label: &str,
extensions: &[&str],
) -> Result<Option<(String, CadDocument)>, String> {
let Some(handle) = crate::sys::file_dialog()
.set_title(title)
.add_filter(filter_label, extensions)
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
else {
return Ok(None);
};
let name = handle.file_name();
let bytes = handle.read().await;
let document = parse_web_document(&name, &bytes).await?;
Ok(Some((name, document)))
}
/// Parse a drawing from bytes using its name or recovery-file signature.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_bytes(name: &str, bytes: Vec<u8>) -> Result<CadDocument, String> {
@@ -820,13 +873,12 @@ fn load_file_for_open(
) -> Result<acadrust::ReadOutcome, OpenAttemptFailure> {
let outcome = match attempt {
OpenAttempt::Strict => {
let outcome = read_file_attempt(path, progress, false).map_err(|failure| {
OpenAttemptFailure {
let outcome =
read_file_attempt(path, progress, false).map_err(|failure| OpenAttemptFailure {
message: failure.message,
read_stats: None,
recoverable: failure.recoverable,
}
})?;
})?;
if !outcome.stats.has_usable_drawing_data() {
return Err(OpenAttemptFailure {
message: "initial read returned no source drawing records".to_string(),
@@ -855,16 +907,15 @@ fn load_file_for_open(
outcome
}
OpenAttempt::Recovery(initial_error, initial_stats) => {
let mut outcome = read_file_attempt(path, progress, true).map_err(|failure| {
OpenAttemptFailure {
let mut outcome =
read_file_attempt(path, progress, true).map_err(|failure| OpenAttemptFailure {
message: format!(
"initial read failed: {initial_error}; recovery read failed: {}",
failure.message
),
read_stats: initial_stats.clone(),
recoverable: false,
}
})?;
})?;
if !outcome.stats.has_usable_drawing_data() {
if let Some(initial_stats) = initial_stats.clone() {
merge_read_diagnostics(&mut outcome.stats, initial_stats);
@@ -942,10 +993,7 @@ fn recoverable_reader_error(error: &acadrust::DxfError) -> bool {
)
}
fn merge_read_diagnostics(
target: &mut acadrust::ReadStats,
source: acadrust::ReadStats,
) {
fn merge_read_diagnostics(target: &mut acadrust::ReadStats, source: acadrust::ReadStats) {
for diagnostic in source.diagnostics {
if !target.diagnostics.contains(&diagnostic) {
acadrust::push_read_diagnostic(&mut target.diagnostics, diagnostic);
@@ -1029,14 +1077,12 @@ fn read_dwg_path(
reader
};
#[cfg(target_arch = "wasm32")]
let mut reader = DwgReader::from_file_with_options(path, options)
.map_err(ReaderFailure::from_reader)?;
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)
reader.read_with_stats().map_err(ReaderFailure::from_reader)
}
fn read_dxf_path(path: &Path, failsafe: bool) -> Result<acadrust::ReadOutcome, ReaderFailure> {
@@ -1359,10 +1405,7 @@ impl SaveFailure {
#[cfg(not(target_arch = "wasm32"))]
fn externally_modified(path: &Path) -> Self {
Self {
message: format!(
"{} changed on disk after it was opened",
path.display()
),
message: format!("{} changed on disk after it was opened", path.display()),
file_in_use: false,
externally_modified: true,
}
@@ -1529,8 +1572,8 @@ mod save_failure_tests {
/// parse produced no feedback at all). The table is paired with an optional
/// non-fatal note for the command line — currently the registration
/// collision message below.
pub async fn pick_plot_style()
-> Result<Option<(plot_style::PlotStyleTable, Option<String>)>, String> {
pub async fn pick_plot_style(
) -> Result<Option<(plot_style::PlotStyleTable, Option<String>)>, String> {
let dialog = crate::sys::file_dialog()
.set_title(crate::t!("Load Plot Style Table").as_ref())
.add_filter(
@@ -1546,28 +1589,45 @@ pub async fn pick_plot_style()
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).map_err(|error| {
crate::tf!(
"Could not load plot style table '{}': {error}",
path.display()
)
.into_owned()
})?;
// Loading a table registers it, the way AutoCAD does: copy it next to the
// built-ins so it stays in the Plot dialog's table list after restart.
// Only real AutoCAD containers are registered — the permissive heuristic
// also accepts junk (any text file, a stray deflate stream), and writing
// those into the folder would pollute it permanently — so heuristic
// parses stay loadable for this session only. Failures are non-fatal —
// the table is already applied to this plot.
#[cfg(not(target_arch = "wasm32"))]
let note = should_register_plot_style(&table)
.then(|| register_picked_plot_style(&path))
.flatten();
{
let path = crate::sys::handle_path(&handle);
let table = plot_style::PlotStyleTable::load(&path).map_err(|error| {
crate::tf!(
"Could not load plot style table '{}': {error}",
path.display()
)
.into_owned()
})?;
// Loading a table registers it, the way AutoCAD does: copy it next to the
// built-ins so it stays in the Plot dialog's table list after restart.
// Only real AutoCAD containers are registered — the permissive heuristic
// also accepts junk (any text file, a stray deflate stream), and writing
// those into the folder would pollute it permanently — so heuristic
// parses stay loadable for this session only. Failures are non-fatal —
// the table is already applied to this plot.
let note = should_register_plot_style(&table)
.then(|| register_picked_plot_style(&path))
.flatten();
Ok(Some((table, note)))
}
// Web: read the picked file's bytes now — the name-only path has nothing
// behind it — parse them, and remember them in the session registry so
// `load_named` resolves the name afterwards (the Plot dialog's table
// list and APPLY, plot settings, display plot style). There is no
// plot-styles folder to copy the file into on the web, so registration
// lasts for the session; the picked table is already applied to this
// plot either way.
#[cfg(target_arch = "wasm32")]
let note = None;
Ok(Some((table, note)))
{
let name = handle.file_name();
let bytes: std::sync::Arc<Vec<u8>> = std::sync::Arc::new(handle.read().await);
let table = plot_style::PlotStyleTable::from_bytes(&name, &bytes).map_err(|error| {
crate::tf!("Could not load plot style table '{}': {error}", name).into_owned()
})?;
plot_style::register_session_table(&name, bytes);
Ok(Some((table, None)))
}
}
/// Whether a loaded table should be registered permanently: only real
@@ -1656,10 +1716,7 @@ mod plot_style_registration_tests {
std::fs::create_dir_all(picked.parent().unwrap()).unwrap();
std::fs::write(&picked, b"fresh bytes").unwrap();
assert!(register_plot_style_into(&dir, &picked).is_none());
assert_eq!(
std::fs::read(dir.join("new.ctb")).unwrap(),
b"fresh bytes"
);
assert_eq!(std::fs::read(dir.join("new.ctb")).unwrap(), b"fresh bytes");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -1677,8 +1734,7 @@ mod plot_style_registration_tests {
// A loose zlib stream without the container header still parses…
use std::io::Write;
let mut enc =
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
enc.write_all(b"description=\"junk\n").unwrap();
let mut loose = b"any first line\n".to_vec();
loose.extend_from_slice(&enc.finish().unwrap());
@@ -1693,10 +1749,19 @@ mod plot_style_registration_tests {
/// Show a file-open dialog for raster images and decode the selected file.
/// Returns `(path, pixel_width, pixel_height)` or an error string.
///
/// The bytes are read through the dialog handle and registered against the
/// returned path (name-only on the web — there is no filesystem behind it),
/// mirroring the PDF-underlay pick. Decoding then comes from those bytes, and
/// the entity the IMAGE command commits resolves through the same registry
/// instead of a `image::open` on a path that only exists natively.
pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), 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!("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(), &["*"])
@@ -1704,9 +1769,16 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> {
.await
.ok_or_else(|| "Cancelled".to_string())?;
let path = crate::sys::handle_path(&handle);
let img = image::open(&path).map_err(|e| e.to_string())?;
let (w, h) = image::GenericImageView::dimensions(&img);
Ok((path, w, h))
let bytes = std::sync::Arc::new(handle.read().await);
let reference = path.to_string_lossy().into_owned();
let short = std::path::Path::new(&reference)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| reference.clone());
crate::scene::model::image_model::register_source(&reference, bytes);
let decoded = crate::scene::model::image_model::resolve_image(&reference)
.ok_or_else(|| format!("Unsupported or corrupt image \"{short}\""))?;
Ok((path, decoded.width, decoded.height))
}
/// Save `doc` to `path` with the given DXF version, overriding `doc.version`.
@@ -1849,11 +1921,7 @@ fn replace_save_file(temp_path: &Path, path: &Path) -> std::io::Result<()> {
}
use std::os::windows::ffi::OsStrExt;
let replaced: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
let replacement: Vec<u16> = temp_path
.as_os_str()
.encode_wide()
.chain(Some(0))
.collect();
let replacement: Vec<u16> = temp_path.as_os_str().encode_wide().chain(Some(0)).collect();
let ok = unsafe {
windows_sys::Win32::Storage::FileSystem::ReplaceFileW(
replaced.as_ptr(),
@@ -1901,7 +1969,9 @@ pub fn save_to_bytes(
let dimensions_ms = dimensions_started.elapsed().as_secs_f64() * 1000.0;
let write_started = iced::time::Instant::now();
let result = match ext.to_lowercase().as_str() {
"dxf" => DxfWriter::new(&doc).write_to_vec().map_err(|e| e.to_string()),
"dxf" => DxfWriter::new(&doc)
.write_to_vec()
.map_err(|e| e.to_string()),
_ => {
let mut buf = std::io::Cursor::new(Vec::new());
DwgWriter::write_to_writer(&mut buf, &doc).map_err(|e| e.to_string())?;
@@ -1925,7 +1995,6 @@ pub fn save_to_bytes(
result
}
// ── Post-load fixups ──────────────────────────────────────────────────────
// Resolve the current text / dimension / multiline style from the handle the
@@ -1939,13 +2008,23 @@ fn fix_current_style_names(doc: &mut CadDocument) {
let h = doc.header.current_text_style_handle;
if h.is_valid() {
if let Some(name) = doc.text_styles.iter().find(|s| s.handle == h).map(|s| s.name.clone()) {
if let Some(name) = doc
.text_styles
.iter()
.find(|s| s.handle == h)
.map(|s| s.name.clone())
{
doc.header.current_text_style_name = name;
}
}
let h = doc.header.current_dimstyle_handle;
if h.is_valid() {
if let Some(name) = doc.dim_styles.iter().find(|s| s.handle == h).map(|s| s.name.clone()) {
if let Some(name) = doc
.dim_styles
.iter()
.find(|s| s.handle == h)
.map(|s| s.name.clone())
{
doc.header.current_dimstyle_name = name;
}
}
@@ -2049,9 +2128,7 @@ pub(crate) fn set_drawing_variable(doc: &mut CadDocument, name: &str, value: &st
let root = crate::scene::annotative::root_named_dict_handle(doc);
let variable_dictionary = crate::scene::annotative::as_dict(doc, root)
.and_then(|dictionary| dictionary.get("AcDbVariableDictionary"))
.filter(|handle| {
matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_)))
})
.filter(|handle| matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_))))
.unwrap_or_else(|| {
let handle = doc.allocate_handle();
let mut dictionary = Dictionary::new();
@@ -2227,12 +2304,10 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool {
|| p.vertices.iter().any(|v| !finite_vec3(&v.location))
}
E::Polyline3D(p) => {
p.vertices.len() >= MAX_VERTS
|| p.vertices.iter().any(|v| !finite_vec3(&v.position))
p.vertices.len() >= MAX_VERTS || p.vertices.iter().any(|v| !finite_vec3(&v.position))
}
E::Polyline(p) => {
p.vertices.len() >= MAX_VERTS
|| p.vertices.iter().any(|v| !finite_vec3(&v.location))
p.vertices.len() >= MAX_VERTS || p.vertices.iter().any(|v| !finite_vec3(&v.location))
}
E::Line(l) => !finite_vec3(&l.start) || !finite_vec3(&l.end),
E::Circle(c) => {
@@ -2442,7 +2517,10 @@ mod layer_roundtrip_tests {
#[test]
fn dwg_preserves_new_layer() {
assert!(roundtrip_layers("dwg", 1), "DWG dropped the new layer (issue #67)");
assert!(
roundtrip_layers("dwg", 1),
"DWG dropped the new layer (issue #67)"
);
}
#[test]
@@ -2454,7 +2532,10 @@ mod layer_roundtrip_tests {
// the last are dropped on a handle-based DWG save (issue #67).
#[test]
fn dwg_preserves_multiple_new_layers() {
assert!(roundtrip_layers("dwg", 3), "DWG dropped colliding new layers (issue #67)");
assert!(
roundtrip_layers("dwg", 3),
"DWG dropped colliding new layers (issue #67)"
);
}
// #252: an entity added (as a plugin does) on a layer that no LAYER command
@@ -2485,7 +2566,11 @@ mod layer_roundtrip_tests {
);
let ent = loaded
.get_entity(h)
.or_else(|| loaded.entities().find(|e| matches!(e, EntityType::Point(_))))
.or_else(|| {
loaded
.entities()
.find(|e| matches!(e, EntityType::Point(_)))
})
.expect("point entity missing after round-trip");
assert_eq!(
ent.common().layer,