web: XREFs, data links & extraction, ARCHIVE transmittals, worker-side saves

The last four big web-vs-desktop gaps.

XREFs: XATTACH reads the picked drawing's bytes and registers them
name-keyed (the image/PDF-underlay pattern), pre-parses on the web
worker, and the commit merge produces the same xref block flow as
native. Opened drawings resolve xrefs from the registry (trailing-name
match) before cache build; unresolved references produce ONE warning
listing the names and the fix — drop the referenced files on the page
(drops and picker opens now register their bytes) and reopen or
XRELOAD. XRELOAD works off the registry on unsaved drawings too.

Data Link & Extraction: spreadsheet links read picked xlsx/csv bytes
(calamine from slices / CSV from string) through a session registry,
so preview, refresh, and table-insert work on web; extraction takes
external drawings via a multi-file picker parsed on the worker, and
outputs CSV/TXT/.dxex via download_bytes. The disabled 'Add folder'
button explains why.

ARCHIVE/ETRANSMIT: assembles the drawing plus registered images, PDF
underlays, and session plot tables into a ZIP delivered as
{stem}_transmit.zip; skipped dependencies are reported by name.

Save/autosave off the UI thread: the parse worker gains a serialize
action (protocol v5) — the document is prepared once on the main
thread (clone, styles, dimension bake), then the DWG/DXF writer pass,
the dominant cost, runs on the worker; any worker failure falls back
to inline serialization with the identical shared implementation.
The save path also clears the dirty flag only when the tab didn't
change mid-write, and the runtime-fields sidecar now carries
graphic_data, fixing a pre-existing loss of raw DWG round-trip bytes
on web-opened documents.

(cherry picked from commit 2e100634)

Ported onto main (#1717 review): the new ocs_save crate builds on the
pinned codec; the hand-written ZIP writer is replaced by the zip crate
already in the tree; ARCHIVE packs registered xref bytes too and
serializes document_for_save() like a web Save; the web save prepares
document_for_save() (prepare_document_for_save now takes the copy by
value, so no second clone); the wasm resolve_path consults the xref
registry, so every reference lookup — not just resolve — finds dropped
files; XRELOAD keeps main's undo, unload and stale-baseline handling;
native data extraction streams the source drawings one at a time.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 9cd898a17a
commit 61c32c7f74
43 files changed
+2187 -336

No files matched your search

+361 -69
View File
@@ -1,5 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use codec::entities::{table::CellRange, TableBuilder};
use codec::objects::{ClassObject, ClassObjectData, DataLink, DataLinkCustomData, ObjectType};
@@ -25,6 +27,100 @@ struct ExtractionRecord {
details: String,
}
// ── In-session picked-file registries (web) ───────────────────────────────
//
// The browser exposes no reusable filesystem path: a picked file's bytes exist
// only while the app keeps them. Both registries below hold those bytes (and
// what was parsed from them) keyed by the reference the drawing or wizard
// carries, following the image-model / PDF-raster pattern. Native builds never
// fill them — the real path stays readable — but the lookup and matching
// logic compiles everywhere so it stays unit-testable.
fn picked_sources() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = OnceLock::new();
SOURCES.get_or_init(|| Mutex::new(HashMap::new()))
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn extraction_documents() -> &'static Mutex<HashMap<String, Arc<CadDocument>>> {
static DOCUMENTS: OnceLock<Mutex<HashMap<String, Arc<CadDocument>>>> = OnceLock::new();
DOCUMENTS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Lower-cased trailing component of a path-style reference. Links and wizard
/// sources carry whatever the author stored, so the file name — not the full
/// path — is the only component a re-picked file can be expected to match.
fn source_name_key(path: &str) -> String {
path.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_ascii_lowercase()
}
/// Do two source references name the same file? Compares trailing components
/// case-insensitively (Windows paths are case-insensitive where the link was
/// authored, and the web has no path beyond the name at all).
fn same_source_name(a: &str, b: &str) -> bool {
let (a, b) = (source_name_key(a), source_name_key(b));
!a.is_empty() && a == b
}
/// Keep the bytes of a spreadsheet or settings file chosen through a picker.
/// Re-opening the link in-session re-reads these instead of demanding a new
/// pick; re-picking the same file overwrites the entry.
// Only the web registers sources (native re-reads the real file, so a file
// edited after picking is picked up); tests exercise the registry directly.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn register_picked_source(path: &str, bytes: Arc<Vec<u8>>) {
picked_sources()
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(path.to_string(), bytes);
}
/// Bytes registered for this reference — exact key first, then any entry that
/// names the same file (the web fallback for links whose stored path is the
/// original author's, while the user picked only the bare file).
pub(crate) fn picked_source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
let cache = picked_sources()
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(bytes) = cache.get(path) {
return Some(Arc::clone(bytes));
}
cache
.iter()
.find(|(key, _)| same_source_name(key, path))
.map(|(_, bytes)| Arc::clone(bytes))
}
/// Keep a source drawing parsed at pick time, keyed by the name the wizard
/// lists. The extraction pipeline reads these on the web instead of calling
/// the path-based `load_file`.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn register_extraction_document(name: &str, document: CadDocument) {
extraction_documents()
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(name.to_string(), Arc::new(document));
}
/// The parsed drawing registered under this source reference, matched by exact
/// key or by file name.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn extraction_document(path: &str) -> Option<Arc<CadDocument>> {
let cache = extraction_documents()
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(document) = cache.get(path) {
return Some(Arc::clone(document));
}
cache
.iter()
.find(|(key, _)| same_source_name(key, path))
.map(|(_, document)| Arc::clone(document))
}
impl ExtractionRecord {
fn value(&self, key: &str) -> String {
match key {
@@ -88,11 +184,15 @@ fn data_link_choices(doc: &CadDocument) -> Vec<DataLinkChoice> {
ObjectType::ClassObject(object) => match &object.data {
ClassObjectData::DataLink(link) => {
let path = resolve_link_path(doc, link);
let path_text = path.to_string_lossy().into_owned();
// The web has no filesystem: a link is valid when its
// picked bytes are still registered in-session.
let valid = path.is_file() || picked_source_bytes(&path_text).is_some();
Some(DataLinkChoice {
handle: *handle,
name: link_display_name(link),
path: path.to_string_lossy().into_owned(),
valid: path.is_file(),
path: path_text,
valid,
})
}
_ => None,
@@ -180,6 +280,12 @@ fn workbook_metadata(path: &Path) -> Result<(Vec<String>, Vec<String>), String>
if ext == "csv" || ext == "txt" {
return Ok((Vec::new(), Vec::new()));
}
if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) {
return workbook_metadata_bytes(&bytes);
}
if cfg!(target_arch = "wasm32") {
return Err(crate::t!("Pick the file again to load its contents in the browser.").into_owned());
}
use calamine::{open_workbook_auto, Reader};
let workbook = open_workbook_auto(path).map_err(|error| error.to_string())?;
let sheets = workbook.sheet_names();
@@ -191,6 +297,20 @@ fn workbook_metadata(path: &Path) -> Result<(Vec<String>, Vec<String>), String>
Ok((sheets, names))
}
/// Sheet and defined-name catalog read straight from bytes (a picked file on
/// the web, where no path exists to reopen).
fn workbook_metadata_bytes(bytes: &[u8]) -> Result<(Vec<String>, Vec<String>), String> {
use calamine::{open_workbook_auto_from_rs, Reader};
let workbook = open_workbook_auto_from_rs(Cursor::new(bytes)).map_err(|error| error.to_string())?;
let sheets = workbook.sheet_names();
let names = workbook
.defined_names()
.iter()
.map(|(name, _)| name.clone())
.collect();
Ok((sheets, names))
}
fn named_range_target(formula: &str) -> Option<(String, String)> {
let formula = formula.trim().trim_start_matches('=');
let (sheet, range) = formula.rsplit_once('!')?;
@@ -199,6 +319,66 @@ fn named_range_target(formula: &str) -> Option<(String, String)> {
(!sheet.is_empty() && !range.is_empty()).then_some((sheet, range))
}
/// Read the text of a CSV/TXT (or settings) source: registered picked bytes
/// first, then the native filesystem. On the web an unregistered path has
/// nothing behind it, so it asks for the pick instead of failing on I/O.
fn read_text_source(path: &Path) -> Result<String, String> {
if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) {
return Ok(String::from_utf8_lossy(&bytes).into_owned());
}
if cfg!(target_arch = "wasm32") {
return Err(crate::t!("Pick the file again to load its contents in the browser.").into_owned());
}
std::fs::read_to_string(path).map_err(|error| error.to_string())
}
/// Sheet selection + range resolution shared by the path- and bytes-based
/// workbook readers. Returns the raw rows and the cell range to crop to, if
/// any.
fn workbook_rows<RS: std::io::Read + std::io::Seek>(
workbook: &mut calamine::Sheets<RS>,
sheet: Option<&str>,
range_kind: LinkRange,
range_value: Option<&str>,
) -> Result<(Vec<Vec<String>>, Option<String>), String> {
use calamine::Reader;
let (selected_sheet, cell_range) = if range_kind == LinkRange::NamedRange {
let name = range_value
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| crate::t!("Select a named range.").into_owned())?;
let formula = workbook
.defined_names()
.iter()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
.map(|(_, formula)| formula.clone())
.ok_or_else(|| crate::t!("The named range was not found.").into_owned())?;
named_range_target(&formula)
.ok_or_else(|| crate::t!("The named range does not refer to a worksheet cell range.").into_owned())?
} else {
let selected_sheet = sheet
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.or_else(|| workbook.sheet_names().first().cloned())
.ok_or_else(|| crate::t!("The spreadsheet contains no worksheets.").into_owned())?;
let range = (range_kind == LinkRange::CellRange)
.then_some(range_value)
.flatten()
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_default();
(selected_sheet, range)
};
let data = workbook
.worksheet_range(&selected_sheet)
.map_err(|error| error.to_string())?;
Ok((
data.rows()
.map(|row| row.iter().map(ToString::to_string).collect())
.collect(),
(!cell_range.is_empty()).then_some(cell_range),
))
}
pub(crate) fn read_tabular_file(
path: &Path,
sheet: Option<&str>,
@@ -214,53 +394,24 @@ pub(crate) fn read_tabular_file(
if range_kind == LinkRange::NamedRange {
return Err(crate::t!("Named ranges require a spreadsheet file.").into_owned());
}
let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?;
(
crate::app::commands::display::parse_csv_table(&text),
crate::app::commands::display::parse_csv_table(&read_text_source(path)?),
(range_kind == LinkRange::CellRange)
.then_some(range_value)
.flatten()
.map(str::to_string),
)
} else {
use calamine::{open_workbook_auto, Reader};
let mut workbook = open_workbook_auto(path).map_err(|error| error.to_string())?;
let (selected_sheet, cell_range) = if range_kind == LinkRange::NamedRange {
let name = range_value
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| crate::t!("Select a named range.").into_owned())?;
let formula = workbook
.defined_names()
.iter()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
.map(|(_, formula)| formula.clone())
.ok_or_else(|| crate::t!("The named range was not found.").into_owned())?;
named_range_target(&formula).ok_or_else(|| {
crate::t!("The named range does not refer to a worksheet cell range.").into_owned()
})?
} else {
let selected_sheet = sheet
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.or_else(|| workbook.sheet_names().first().cloned())
.ok_or_else(|| crate::t!("The spreadsheet contains no worksheets.").into_owned())?;
let range = (range_kind == LinkRange::CellRange)
.then_some(range_value)
.flatten()
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_default();
(selected_sheet, range)
};
let data = workbook
.worksheet_range(&selected_sheet)
} else if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) {
use calamine::open_workbook_auto_from_rs;
let mut workbook = open_workbook_auto_from_rs(Cursor::new(bytes.as_slice()))
.map_err(|error| error.to_string())?;
(
data.rows()
.map(|row| row.iter().map(ToString::to_string).collect())
.collect(),
(!cell_range.is_empty()).then_some(cell_range),
)
workbook_rows(&mut workbook, sheet, range_kind, range_value)?
} else if cfg!(target_arch = "wasm32") {
return Err(crate::t!("Pick the file again to load its contents in the browser.").into_owned());
} else {
use calamine::open_workbook_auto;
let mut workbook = open_workbook_auto(path).map_err(|error| error.to_string())?;
workbook_rows(&mut workbook, sheet, range_kind, range_value)?
};
let rows = match cell_range
.as_deref()
@@ -471,12 +622,14 @@ fn collect_records(
}
}
#[cfg(not(target_arch = "wasm32"))]
fn is_drawing_file(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("dwg") || value.eq_ignore_ascii_case("dxf"))
}
#[cfg(not(target_arch = "wasm32"))]
fn collect_drawing_paths(
path: &Path,
recursive: bool,
@@ -505,6 +658,7 @@ fn collect_drawing_paths(
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn extraction_drawing_paths(state: &DataExtractionState) -> Result<Vec<PathBuf>, String> {
let mut paths = Vec::new();
for source in &state.source_files {
@@ -515,6 +669,31 @@ fn extraction_drawing_paths(state: &DataExtractionState) -> Result<Vec<PathBuf>,
Ok(paths)
}
/// Visit the source drawings the extraction pipeline reads beyond the current
/// one. Native loads them from disk one at a time (folders walked
/// recursively), so only one foreign drawing is in memory at once; the web
/// reads the documents parsed at pick time from the in-session registry —
/// every listed source must have been picked, or it asks for the pick again.
fn for_each_extraction_document(
state: &DataExtractionState,
mut visit: impl FnMut(&CadDocument),
) -> Result<(), String> {
#[cfg(target_arch = "wasm32")]
for source in &state.source_files {
let document = extraction_document(source).ok_or_else(|| {
crate::tf!("Drawing not loaded in the browser: {name}.", name = source).into_owned()
})?;
visit(&document);
}
#[cfg(not(target_arch = "wasm32"))]
for source in extraction_drawing_paths(state)? {
let document = crate::io::load_file(&source)
.map_err(|error| format!("{}: {error}", source.display()))?;
visit(&document);
}
Ok(())
}
fn collect_type_counts(
doc: &CadDocument,
allowed_handles: Option<&BTreeSet<Handle>>,
@@ -560,11 +739,9 @@ fn extraction_preview(
if state.include_current {
collect_records(current, None, &allowed_types, &mut records);
}
for source in extraction_drawing_paths(state)? {
let doc = crate::io::load_file(&source)
.map_err(|error| format!("{}: {error}", source.display()))?;
collect_records(&doc, None, &allowed_types, &mut records);
}
for_each_extraction_document(state, |document| {
collect_records(document, None, &allowed_types, &mut records);
})?;
}
}
let props = state
@@ -693,11 +870,25 @@ fn save_extraction_settings(state: &DataExtractionState) -> Result<(), String> {
"tableTitle": state.table_title,
});
let text = serde_json::to_string_pretty(&value).map_err(|error| error.to_string())?;
std::fs::write(&state.settings_path, text).map_err(|error| error.to_string())
#[cfg(target_arch = "wasm32")]
{
// No filesystem to write to: hand the settings to the browser as a
// download, mirroring the native write of the same JSON.
let name = Path::new(state.settings_path.trim())
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "extraction.dxex".into());
crate::sys::download_bytes(&name, text.as_bytes());
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::write(&state.settings_path, text).map_err(|error| error.to_string())
}
}
fn load_extraction_settings(state: &mut DataExtractionState) -> Result<(), String> {
let text = std::fs::read_to_string(&state.settings_path).map_err(|error| error.to_string())?;
let text = read_text_source(Path::new(state.settings_path.trim()))?;
let value: serde_json::Value =
serde_json::from_str(&text).map_err(|error| error.to_string())?;
state.source = match value["source"].as_str() {
@@ -1075,7 +1266,7 @@ impl OpenCADStudio {
pub(super) fn on_data_link_browse(&mut self) -> Task<Message> {
Task::perform(
async {
crate::sys::file_dialog()
let Some(handle) = crate::sys::file_dialog()
.set_title(crate::t!("Choose a Spreadsheet File").as_ref())
.add_filter(
crate::t!("Spreadsheet Files").as_ref(),
@@ -1084,7 +1275,16 @@ impl OpenCADStudio {
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
.map(|handle| crate::sys::handle_path(&handle))
else {
return None;
};
let path = crate::sys::handle_path(&handle);
// The browser forgets the file the moment the pick resolves:
// keep its bytes so preview, save, and later re-opens read
// them instead of asking for the picker again.
#[cfg(target_arch = "wasm32")]
register_picked_source(&path.to_string_lossy(), Arc::new(handle.read().await));
Some(path)
},
Message::DataLinkBrowseResult,
)
@@ -1403,11 +1603,9 @@ impl OpenCADStudio {
if state.include_current {
collect_type_counts(current, None, &mut counts);
}
for path in extraction_drawing_paths(&state)? {
let doc = crate::io::load_file(&path)
.map_err(|error| format!("{}: {error}", path.display()))?;
collect_type_counts(&doc, None, &mut counts);
}
for_each_extraction_document(&state, |document| {
collect_type_counts(document, None, &mut counts);
})?;
}
}
let previous = self
@@ -1570,18 +1768,21 @@ impl OpenCADStudio {
.set_title(crate::t!("Data Extraction Settings").as_ref())
.add_filter(crate::t!("Data Extraction Settings").as_ref(), &["dxex"])
.add_filter(crate::t!("All Files").as_ref(), &["*"]);
if save {
dialog
.set_file_name("extraction.dxex")
.save_file()
.await
.map(|handle| crate::sys::handle_path(&handle))
let handle = if save {
dialog.set_file_name("extraction.dxex").save_file().await
} else {
dialog
.pick_file()
.await
.map(|handle| crate::sys::handle_path(&handle))
dialog.pick_file().await
};
let handle = handle?;
let path = crate::sys::handle_path(&handle);
// Loading settings in the browser: the picked .dxex has no
// path to read back from, so its bytes are kept for the load
// that follows this message.
#[cfg(target_arch = "wasm32")]
if !save {
register_picked_source(&path.to_string_lossy(), Arc::new(handle.read().await));
}
Some(path)
},
Message::DataExtractionBrowseSettingsResult,
)
@@ -1604,10 +1805,11 @@ impl OpenCADStudio {
Task::none()
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task<Message> {
Task::perform(
async {
crate::sys::file_dialog()
Ok(crate::sys::file_dialog()
.set_title(crate::t!("Add Drawings").as_ref())
.add_filter(
crate::t!("CAD Files").as_ref(),
@@ -1619,7 +1821,42 @@ impl OpenCADStudio {
.unwrap_or_default()
.into_iter()
.map(|handle| crate::sys::handle_path(&handle))
.collect()
.collect())
},
Message::DataExtractionAddDrawingsResult,
)
}
/// Web: the picked drawings have no paths to re-open later, so each one is
/// parsed on the shared worker right here and the document registered
/// under its name — the pipeline then reads the registry instead of the
/// filesystem. The first file that fails to parse aborts the add with the
/// same `name: error` shape the native refresh reports.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task<Message> {
Task::perform(
async {
let handles = crate::sys::file_dialog()
.set_title(crate::t!("Add Drawings").as_ref())
.add_filter(
crate::t!("CAD Files").as_ref(),
&["dwg", "dxf", "DWG", "DXF"],
)
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_files()
.await
.unwrap_or_default();
let mut paths = Vec::new();
for handle in handles {
let name = handle.file_name();
let bytes = handle.read().await;
let document = crate::io::parse_drawing_bytes_web(&name, &bytes)
.await
.map_err(|error| format!("{name}: {error}"))?;
register_extraction_document(&name, document);
paths.push(PathBuf::from(name));
}
Ok(paths)
},
Message::DataExtractionAddDrawingsResult,
)
@@ -1740,6 +1977,18 @@ impl OpenCADStudio {
} else {
rows_to_csv(&rows)
};
// Web output mirrors the native CSV/TXT write (there is no xlsx
// writer on either target): the browser gets the same bytes as a
// download under the picked name.
#[cfg(target_arch = "wasm32")]
{
let name = path
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "extraction.csv".into());
crate::sys::download_bytes(&name, text.as_bytes());
}
#[cfg(not(target_arch = "wasm32"))]
if let Err(error) = std::fs::write(&path, text) {
self.data_extraction.error = error.to_string();
return Task::none();
@@ -1835,4 +2084,47 @@ mod tests {
vec![vec!["x".to_string()]]
);
}
#[test]
fn csv_links_read_from_registered_bytes() {
let name = format!("ocs-registry-{}-{}.csv", std::process::id(), line!());
register_picked_source(&name, Arc::new(b"a,b,c\nd,e,f\n".to_vec()));
let rows = read_tabular_file(Path::new(&name), None, LinkRange::CellRange, Some("B1:C2"));
assert_eq!(
rows.unwrap(),
vec![
vec!["b".to_string(), "c".to_string()],
vec!["e".to_string(), "f".to_string()],
]
);
}
#[test]
fn registered_bytes_match_by_file_name() {
let key = format!("ocs-match-{}-{}.xlsx", std::process::id(), line!());
register_picked_source(
&format!(r"C:\drawings\{key}"),
Arc::new(Vec::new()),
);
// Exact key…
assert!(picked_source_bytes(&format!(r"C:\drawings\{key}")).is_some());
// …same file under a differently-cased bare name (the web stores only
// the name, links may store the author's path)…
assert!(picked_source_bytes(&key.to_ascii_uppercase()).is_some());
// …and not another file.
assert!(picked_source_bytes("ocs-match-other.xlsx").is_none());
}
#[test]
fn registered_extraction_documents_match_by_file_name() {
let key = format!("ocs-doc-{}-{}.dwg", std::process::id(), line!());
register_extraction_document(&key, CadDocument::new());
assert!(extraction_document(&key.to_ascii_uppercase()).is_some());
assert!(extraction_document("ocs-doc-other.dwg").is_none());
}
#[test]
fn workbook_metadata_bytes_rejects_non_workbooks() {
assert!(workbook_metadata_bytes(b"not a workbook").is_err());
}
}
+24 -4
View File
@@ -1612,9 +1612,20 @@ impl OpenCADStudio {
"XRELOAD" => {
// Reload all xrefs for the current drawing. Every direct key
// leaves the session-unloaded set (reloaded content is live
// again) with fresh stat baselines on the next refresh.
if let Some(path) = &self.tabs[i].current_path.clone() {
if let Some(base_dir) = path.parent() {
// again) with fresh stat baselines on the next refresh. Web:
// xrefs re-read from the in-session bytes registry, so an
// unsaved drawing reloads too; native needs the drawing's own
// folder for relative xref paths.
#[cfg(target_arch = "wasm32")]
let base_dir: Option<std::path::PathBuf> = Some(std::path::PathBuf::new());
#[cfg(not(target_arch = "wasm32"))]
let base_dir: Option<std::path::PathBuf> = self.tabs[i]
.current_path
.as_ref()
.and_then(|path| path.parent().map(std::path::Path::to_path_buf));
if let Some(base_dir) = base_dir {
{
let base_dir: &std::path::Path = &base_dir;
let reload_keys: Vec<u64> = crate::io::xref::collect_entries_with_prev(
&self.tabs[i].scene.document,
base_dir,
@@ -1631,7 +1642,7 @@ impl OpenCADStudio {
}
let (infos, _dropped) = crate::io::xref::resolve_xrefs(
&mut self.tabs[i].scene.document,
base_dir,
&base_dir,
);
// `resolve_xrefs` re-merges ALL references — refresh stat
// baselines for every currently-Loaded entry so no row
@@ -1652,8 +1663,17 @@ impl OpenCADStudio {
}
}
for info in &infos {
// Web: unresolved references surface once below,
// with the drop hint, instead of a filesystem path
// no browser can open.
#[cfg(target_arch = "wasm32")]
if info.status == crate::io::xref::XrefStatus::NotFound {
continue;
}
self.report_xref_status(info);
}
#[cfg(target_arch = "wasm32")]
self.warn_unresolved_xrefs(&infos);
self.post_ref_op(i);
}
} else {
+275 -56
View File
@@ -169,72 +169,81 @@ impl OpenCADStudio {
// ARCHIVE / ETRANSMIT — package the drawing and its referenced files
// (xrefs + raster images) into a sibling "<name>_archive" folder.
// Web: there is no folder to copy into — the same dependency set is
// collected from the session's bytes registries and delivered as a
// "<name>_transmit.zip" download (`archive_web` below).
"ARCHIVE" | "ETRANSMIT" => {
use std::path::PathBuf;
if let Some(src) = self.tabs[i].current_path.clone() {
let stem = src
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
let parent = src
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let folder = parent.join(format!("{stem}_archive"));
match std::fs::create_dir_all(&folder) {
Ok(()) => {
let mut copied = 0usize;
if let Some(fname) = src.file_name() {
if std::fs::copy(&src, folder.join(fname)).is_ok() {
copied += 1;
}
}
let mut deps: Vec<String> = Vec::new();
for br in self.tabs[i].scene.document.block_records.iter() {
if !br.xref_path.trim().is_empty() {
deps.push(br.xref_path.clone());
}
}
for e in self.tabs[i].scene.document.entities() {
if let codec::EntityType::RasterImage(img) = e {
if !img.file_path.trim().is_empty() {
deps.push(img.file_path.clone());
#[cfg(not(target_arch = "wasm32"))]
{
use std::path::PathBuf;
if let Some(src) = self.tabs[i].current_path.clone() {
let stem = src
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
let parent = src
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let folder = parent.join(format!("{stem}_archive"));
match std::fs::create_dir_all(&folder) {
Ok(()) => {
let mut copied = 0usize;
if let Some(fname) = src.file_name() {
if std::fs::copy(&src, folder.join(fname)).is_ok() {
copied += 1;
}
}
}
for d in deps {
let dp = PathBuf::from(&d);
let resolved = if dp.is_absolute() {
dp
} else {
parent.join(&dp)
};
if resolved.exists() {
if let Some(fname) = resolved.file_name() {
if std::fs::copy(&resolved, folder.join(fname)).is_ok() {
copied += 1;
let mut deps: Vec<String> = Vec::new();
for br in self.tabs[i].scene.document.block_records.iter() {
if !br.xref_path.trim().is_empty() {
deps.push(br.xref_path.clone());
}
}
for e in self.tabs[i].scene.document.entities() {
if let codec::EntityType::RasterImage(img) = e {
if !img.file_path.trim().is_empty() {
deps.push(img.file_path.clone());
}
}
}
for d in deps {
let dp = PathBuf::from(&d);
let resolved = if dp.is_absolute() {
dp
} else {
parent.join(&dp)
};
if resolved.exists() {
if let Some(fname) = resolved.file_name() {
if std::fs::copy(&resolved, folder.join(fname)).is_ok()
{
copied += 1;
}
}
}
}
self.command_line.push_output(
crate::tf!(
"{cmd}: packaged {copied} file(s) into {}",
folder.display()
)
.as_ref(),
);
}
self.command_line.push_output(
crate::tf!(
"{cmd}: packaged {copied} file(s) into {}",
folder.display()
)
.as_ref(),
);
Err(e) => self.command_line.push_error(
crate::tf!("{cmd}: cannot create folder ({e}).").as_ref(),
),
}
Err(e) => self
.command_line
.push_error(crate::tf!("{cmd}: cannot create folder ({e}).").as_ref()),
} else {
self.command_line.push_error(
crate::t!("ARCHIVE: save the drawing first (it has no file path yet).")
.as_ref(),
);
}
} else {
self.command_line.push_error(
crate::t!("ARCHIVE: save the drawing first (it has no file path yet).")
.as_ref(),
);
}
#[cfg(target_arch = "wasm32")]
self.archive_web(i, cmd);
}
"EXIT" | "QUIT" => {
@@ -545,6 +554,216 @@ fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> {
None
}
// ── Web ARCHIVE / ETRANSMIT ──────────────────────────────────────────────────
//
// The browser has no folder to copy into, so the transmittal is assembled in
// memory and downloaded as one ZIP — the same dependency set the native
// folder walk collects (drawing, xrefs, raster images) plus the web-only
// bytes-backed kinds (PDF underlays, session-picked plot tables). Anything
// whose bytes never entered the session is reported as skipped rather than
// silently dropped, the web counterpart of native's missing-file skip.
#[cfg(target_arch = "wasm32")]
impl OpenCADStudio {
pub(in crate::app) fn archive_web(&mut self, i: usize, cmd: &str) {
let Some(src) = self.tabs[i].current_path.clone() else {
self.command_line.push_error(
crate::t!("ARCHIVE: save the drawing first (it has no file path yet).").as_ref(),
);
return;
};
let stem = src
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
let ext = src
.extension()
.map(|e| e.to_string_lossy().into_owned())
.unwrap_or_else(|| "dwg".into());
let filename = src
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("{stem}.{ext}"));
let version = self.tabs[i].scene.document.version;
// The drawing itself: the same serialization a web Save performs
// (native copies the last-saved file; the web build has none, so the
// current document is re-serialized).
let document = self.tabs[i].scene.document_for_save();
let drawing = match crate::io::save_to_bytes(&document, &ext, version) {
Ok(bytes) => bytes,
Err(e) => {
self.command_line.push_error(
crate::tf!("{cmd}: cannot package the drawing ({e}).").as_ref(),
);
return;
}
};
let mut entries: Vec<(String, Vec<u8>)> = vec![(filename, drawing)];
let mut skipped: Vec<String> = Vec::new();
// Xrefs — the block-record walk native copies; the bytes come from
// the session's xref registry (picked, dropped or opened files).
for br in self.tabs[i].scene.document.block_records.iter() {
if br.xref_path.trim().is_empty() {
continue;
}
match crate::io::xref::find_registered_source(&br.xref_path)
.and_then(|key| crate::io::xref::registered_bytes(&key))
{
Some(bytes) => push_entry(
&mut entries,
transmit_file_name(&br.xref_path),
(*bytes).clone(),
),
None => push_skipped(&mut skipped, &transmit_file_name(&br.xref_path)),
}
}
// Raster images — picked/dropped bytes registered for the reference
// stand in for the file native would copy.
for e in self.tabs[i].scene.document.entities() {
if let codec::EntityType::RasterImage(img) = e {
if img.file_path.trim().is_empty() {
continue;
}
match crate::scene::model::image_model::registered_source_bytes(&img.file_path) {
Some(bytes) => push_entry(
&mut entries,
transmit_file_name(&img.file_path),
(*bytes).clone(),
),
None => push_skipped(&mut skipped, &transmit_file_name(&img.file_path)),
}
}
}
// PDF underlays — bytes registered at attach time.
for o in self.tabs[i].scene.document.objects.values() {
if let codec::objects::ObjectType::UnderlayDefinition(def) = o {
if !matches!(def.underlay_type, codec::entities::UnderlayType::Pdf)
|| def.file_path.trim().is_empty()
{
continue;
}
match crate::scene::model::pdf_raster::registered_source_bytes(&def.file_path) {
Some(bytes) => push_entry(
&mut entries,
transmit_file_name(&def.file_path),
(*bytes).clone(),
),
None => push_skipped(&mut skipped, &transmit_file_name(&def.file_path)),
}
}
}
// Plot tables named by the layouts (and the session's active one).
// Only tables picked this session have bytes; the built-ins ship
// with the app, so they are neither packaged nor reported missing.
let mut style_names: Vec<String> = self.tabs[i]
.scene
.document
.objects
.values()
.filter_map(|o| {
if let codec::objects::ObjectType::Layout(l) = o {
Some(l.plot_style_sheet.clone())
} else {
None
}
})
.collect();
if let Some(table) = self.active_plot_style.as_ref() {
style_names.push(table.name.clone());
}
for name in style_names {
if name.trim().is_empty()
|| crate::io::plot_style::PlotStyleTable::builtin(&name).is_ok()
{
continue;
}
match crate::io::plot_style::session_table(&name) {
Some(bytes) => push_entry(&mut entries, name, (*bytes).clone()),
None => push_skipped(&mut skipped, &name),
}
}
let zip_name = format!("{stem}_transmit.zip");
match build_zip(&entries) {
Ok(zip) => {
crate::sys::download_bytes(&zip_name, &zip);
// The same report shape the native folder transmittal makes.
let copied = entries.len();
self.command_line.push_output(
crate::tf!("{cmd}: packaged {copied} file(s) into {}", zip_name).as_ref(),
);
}
Err(e) => self.command_line.push_error(
crate::tf!("{cmd}: cannot build the transmittal ZIP ({e}).").as_ref(),
),
}
if !skipped.is_empty() {
let n = skipped.len();
let names = skipped.join(", ");
self.command_line.push_output(
crate::tf!("{cmd}: skipped {n} referenced file(s) not loaded in this session: {names}")
.as_ref(),
);
}
}
}
/// Trailing component of a reference path — the flat name a transmittal
/// packages it under (native copies `file_name()` into one folder).
#[cfg(target_arch = "wasm32")]
fn transmit_file_name(path: &str) -> String {
path.trim()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_string()
}
/// Add one entry under its flat name; a later dependency with the same name
/// replaces the bytes (native's `fs::copy` overwrite into the folder).
#[cfg(target_arch = "wasm32")]
fn push_entry(entries: &mut Vec<(String, Vec<u8>)>, name: String, data: Vec<u8>) {
if let Some(slot) = entries
.iter_mut()
.find(|(entry, _)| entry.eq_ignore_ascii_case(&name))
{
slot.1 = data;
} else {
entries.push((name, data));
}
}
/// Pack the transmittal entries into one deflated ZIP archive in memory.
#[cfg(target_arch = "wasm32")]
fn build_zip(entries: &[(String, Vec<u8>)]) -> Result<Vec<u8>, String> {
use std::io::Write;
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (name, data) in entries {
writer
.start_file(name.as_str(), options)
.map_err(|e| e.to_string())?;
writer.write_all(data).map_err(|e| e.to_string())?;
}
writer
.finish()
.map(std::io::Cursor::into_inner)
.map_err(|e| e.to_string())
}
/// Remember a missing dependency for the report, once per name.
#[cfg(target_arch = "wasm32")]
fn push_skipped(skipped: &mut Vec<String>, name: &str) {
if !name.is_empty() && !skipped.iter().any(|s| s.eq_ignore_ascii_case(name)) {
skipped.push(name.to_string());
}
}
#[cfg(test)]
mod tests {
use crate::app::{Message, OpenCADStudio};
+19 -1
View File
@@ -2650,6 +2650,22 @@ pub enum Message {
bounds: Option<iced::Rectangle>,
screenshot: Option<iced::window::Screenshot>,
},
/// Web SAVE: the serializer worker (or the inline fallback) produced the
/// drawing bytes; download them and finish the save bookkeeping. The
/// epoch/revision/camera markers snapshot the tab when serialization
/// started — the web counterpart of native's `snapshot_is_current`, so a
/// save that raced user edits does not wipe the newer dirty flag.
#[cfg(target_arch = "wasm32")]
WebSaveSerialized {
tab_id: u64,
filename: String,
autosave_key: String,
version: codec::DxfVersion,
geometry_epoch: u64,
edit_revision: u64,
camera_generation: u64,
result: Result<Vec<u8>, String>,
},
/// Retry the failed save after the other application releases the file.
#[cfg(not(target_arch = "wasm32"))]
SaveFileInUseRetry,
@@ -4127,7 +4143,9 @@ pub enum Message {
DataExtractionBrowseSettings,
DataExtractionBrowseSettingsResult(Option<std::path::PathBuf>),
DataExtractionAddDrawings,
DataExtractionAddDrawingsResult(Vec<std::path::PathBuf>),
/// Picked source drawings — `Err` carries a pick/parse failure (web
/// parses each file on the worker at pick time).
DataExtractionAddDrawingsResult(Result<Vec<std::path::PathBuf>, String>),
DataExtractionAddFolder,
DataExtractionAddFolderResult(Option<std::path::PathBuf>),
DataExtractionClearSources,
+164 -64
View File
@@ -1958,11 +1958,18 @@ impl OpenCADStudio {
crate::tf!("XREF Recovered with warnings: \"{}\"", info.name).as_ref(),
);
}
// Web: NotFound xrefs are summarized in one warning after the
// loop (see below) — the per-reference line carries a
// filesystem path no browser can open, and a drawing with
// several unresolved xrefs would repeat the same hint.
#[cfg(not(target_arch = "wasm32"))]
crate::io::xref::XrefStatus::NotFound => {
self.command_line.push_error(
crate::tf!("XREF Not found: \"{}\" ({})", info.name, info.path).as_ref(),
);
}
#[cfg(target_arch = "wasm32")]
crate::io::xref::XrefStatus::NotFound => {}
crate::io::xref::XrefStatus::Failed => {
self.command_line.push_error(
crate::tf!("XREF Recovery failed: \"{}\" ({})", info.name, info.path)
@@ -1976,6 +1983,11 @@ impl OpenCADStudio {
}
}
}
// Web: one warning per open lists every unresolved xref and the way
// to fix it — dropping the files on the page registers their bytes,
// and reopening the drawing (or XRELOAD) then resolves them.
#[cfg(target_arch = "wasm32")]
self.warn_unresolved_xrefs(&caches.xrefs);
#[cfg(not(target_arch = "wasm32"))]
let thumbs_task = self.push_recent(path.clone());
#[cfg(target_arch = "wasm32")]
@@ -2589,83 +2601,144 @@ impl OpenCADStudio {
self.tabs[i].scene.document.preview = Some(preview);
}
let mut recent_task = Task::none();
// The name the autosave snapshot for this tab is keyed by — capture it
// before the save re-points `current_path` at the download name.
let autosave_key = Self::web_autosave_key(&self.tabs[i]);
let document = self.tabs[i].scene.document_for_save();
let saved = match crate::io::save_to_bytes(&document, &ext, version) {
Ok(bytes) => {
crate::sys::download_bytes(&filename, &bytes);
let cache_name = std::path::Path::new(&filename)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| filename.clone());
let path = std::path::PathBuf::from(cache_name);
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].scene.document.version = version;
self.tabs[i].dirty = false;
self.tabs[i].recovery_save_as_required = false;
recent_task = Task::perform(
async move {
crate::io::web_recent::store(&path.to_string_lossy(), &bytes)
.await
.map(|_| path)
},
Message::WebRecentStored,
);
// A clean save supersedes any crash-recovery snapshot for this
// drawing — drop the old key and the new one so stale copies
// don't resurface in the next boot scan. But an autosave
// round may still be writing the same key: its write would
// land AFTER the remove and resurrect the snapshot (with a
// newer mtime, so the next scan would offer it as
// recoverable). Defer the removes to the end of the round.
let new_key = self.tabs[i]
.current_path
.as_ref()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_default();
if self.web_autosave_in_flight {
self.web_autosave_clear_after.push(autosave_key);
if !new_key.is_empty() {
self.web_autosave_clear_after.push(new_key);
}
} else {
recent_task = Task::batch([
recent_task,
Task::perform(
async move {
let _ = crate::io::web_autosave::remove(&autosave_key).await;
let _ = crate::io::web_autosave::remove(&new_key).await;
},
|_| Message::Noop,
),
]);
}
self.command_line
.push_output(crate::tf!("Saved: {filename}").as_ref());
true
}
// Race guard (the web counterpart of native's `snapshot_is_current`):
// completion only clears the dirty flag if the tab did not change
// while the writer ran off-thread.
let geometry_epoch = self.tabs[i].scene.geometry_epoch;
let edit_revision = self.tabs[i].edit_revision;
let camera_generation = self.tabs[i].scene.camera_generation;
// Prepare the document here (clone + style sync + dimension bake — the
// app-side half of the pipeline), then hand the DWG/DXF writer pass to
// the browser worker so the UI thread stays responsive on large
// drawings. Completion (download + bookkeeping) continues in
// `on_web_save_serialized`.
let prepared =
crate::io::prepare_document_for_save(self.tabs[i].scene.document_for_save(), version);
Task::perform(
async move { crate::io::serialize_prepared_document(&prepared, &ext).await },
move |result| Message::WebSaveSerialized {
tab_id,
filename,
autosave_key,
version,
geometry_epoch,
edit_revision,
camera_generation,
result,
},
)
}
/// Web SAVE completion: the serializer worker (or inline fallback) has the
/// drawing bytes. The download and tab bookkeeping that used to run
/// synchronously inside `on_web_save_screenshot`, unchanged in semantics —
/// except the dirty flag is only cleared when the serialized snapshot is
/// still the tab's current state.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_web_save_serialized(
&mut self,
tab_id: u64,
filename: String,
autosave_key: String,
version: codec::DxfVersion,
geometry_epoch: u64,
edit_revision: u64,
camera_generation: u64,
result: Result<Vec<u8>, String>,
) -> Task<Message> {
let bytes = match result {
Ok(bytes) => bytes,
Err(error) => {
self.command_line
.push_error(crate::tf!("Save failed: {error}").as_ref());
false
return self.finish_web_save(false);
}
};
// The download is the only artifact of the save — fire it even if the
// tab was closed while serialization was in flight.
crate::sys::download_bytes(&filename, &bytes);
let cache_name = std::path::Path::new(&filename)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| filename.clone());
let path = std::path::PathBuf::from(cache_name);
let mut recent_task = Task::perform(
async move {
crate::io::web_recent::store(&path.to_string_lossy(), &bytes)
.await
.map(|_| path)
},
Message::WebRecentStored,
);
let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
return recent_task;
};
self.tabs[i].current_path = Some(
std::path::Path::new(&filename)
.file_name()
.map(|name| std::path::PathBuf::from(name.to_string_lossy().into_owned()))
.unwrap_or_else(|| std::path::PathBuf::from(filename.clone())),
);
self.tabs[i].scene.document.version = version;
self.tabs[i].recovery_save_as_required = false;
let snapshot_is_current = self.tabs[i].scene.geometry_epoch == geometry_epoch
&& self.tabs[i].edit_revision == edit_revision
&& self.tabs[i].scene.camera_generation == camera_generation;
if snapshot_is_current {
self.tabs[i].dirty = false;
}
// A clean save supersedes any crash-recovery snapshot for this
// drawing — drop the old key and the new one so stale copies don't
// resurface in the next boot scan. But an autosave round may still be
// writing the same key: its write would land AFTER the remove and
// resurrect the snapshot (with a newer mtime, so the next scan would
// offer it as recoverable). Defer the removes to the end of the round.
let new_key = self.tabs[i]
.current_path
.as_ref()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_default();
if self.web_autosave_in_flight {
self.web_autosave_clear_after.push(autosave_key);
if !new_key.is_empty() {
self.web_autosave_clear_after.push(new_key);
}
} else {
recent_task = Task::batch([
recent_task,
Task::perform(
async move {
let _ = crate::io::web_autosave::remove(&autosave_key).await;
let _ = crate::io::web_autosave::remove(&new_key).await;
},
|_| Message::Noop,
),
]);
}
self.command_line
.push_output(crate::tf!("Saved: {filename}").as_ref());
Task::batch([recent_task, self.finish_web_save(true)])
}
/// Tail of the web save flow: continue a pending close when the save
/// succeeded, or re-open the unsaved-changes dialog when it failed.
#[cfg(target_arch = "wasm32")]
fn finish_web_save(&mut self, saved: bool) -> Task<Message> {
if self.save_dialog_for_unsaved {
if saved {
if let Some(crate::app::PendingClose::Tab(tab_id)) = self.pending_close.take() {
let continuation = self.update(Message::TabClose(tab_id));
let rest = self.continue_tab_close_queue();
return Task::batch([recent_task, continuation, rest]);
return Task::batch([continuation, rest]);
}
} else if self.pending_close.is_some() {
let retry = self.open_unsaved_dialog_window();
return Task::batch([recent_task, retry]);
return self.open_unsaved_dialog_window();
}
}
recent_task
Task::none()
}
#[cfg(not(target_arch = "wasm32"))]
@@ -3661,7 +3734,10 @@ impl OpenCADStudio {
/// native `.sv$` copy — best-effort and non-destructive: no download, no
/// recents update, dirty flag untouched. Snapshots run serially in one
/// round; while it lasts (or a web save's thumbnail capture is pending)
/// further ticks are dropped, not queued.
/// further ticks are dropped, not queued. Each tab is prepared (clone +
/// style sync + dimension bake) once up front, then the DWG/DXF writer
/// pass runs on the serializer worker — the UI thread only awaits the
/// result between OPFS writes.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_autosave(&mut self) -> Task<Message> {
if self.web_autosave_in_flight || self.pending_web_thumbnail_save.is_some() {
@@ -3688,7 +3764,11 @@ impl OpenCADStudio {
let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), &tab.scene.document);
let ext = if is_dxf { "dxf" } else { "dwg" }.to_string();
let version = tab.scene.document.version;
pending.push((name, ext, version, tab.scene.document.clone()));
pending.push((
name,
ext,
crate::io::prepare_document_for_save(tab.scene.document.clone(), version),
));
}
if pending.is_empty() {
return Task::none();
@@ -3697,10 +3777,10 @@ impl OpenCADStudio {
Task::perform(
async move {
let mut failure = None;
for (name, ext, version, doc) in pending {
for (name, ext, prepared) in pending {
// Autosave exists to recover geometry, not pretty recents:
// no thumbnail capture, just bytes into OPFS.
match crate::io::save_to_bytes(&doc, &ext, version) {
match crate::io::serialize_prepared_document(&prepared, &ext).await {
Ok(bytes) => {
if let Err(error) =
crate::io::web_autosave::snapshot(&name, &bytes, None).await
@@ -8132,3 +8212,23 @@ mod background_task_tests {
assert!(error.contains("worker exploded"), "error = {error}");
}
}
impl OpenCADStudio {
/// Web: one warning lists every unresolved xref and the way to fix it —
/// dropping the files on the page registers their bytes, and reopening
/// the drawing (or XRELOAD) then resolves them.
#[cfg(target_arch = "wasm32")]
pub(in crate::app) fn warn_unresolved_xrefs(&mut self, infos: &[crate::io::xref::XrefInfo]) {
let unresolved = crate::io::xref::unresolved_xref_names(infos);
if unresolved.is_empty() {
return;
}
let names = unresolved.join(", ");
self.command_line.push_warning(
crate::tf!(
"XREF Unresolved external references: {names} — drop the referenced DWG/DXF files on the page to register them, then reopen this drawing or run XRELOAD."
)
.as_ref(),
);
}
}
+80 -26
View File
@@ -1580,26 +1580,46 @@ impl OpenCADStudio {
Task::none()
}
Message::XAttachPick => Task::perform(
async {
let handle = crate::sys::file_dialog()
.set_title(crate::t!("Select External Reference File").as_ref())
.add_filter(
crate::t!("CAD Files").as_ref(),
&["dwg", "dxf", "bak", "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 Files").as_ref(), &["bak", "BAK"])
.pick_file()
.await;
match handle {
Some(h) => Ok(crate::sys::handle_path(&h)),
None => Err("Cancelled".to_string()),
}
},
Message::XAttachPickResult,
),
Message::XAttachPick => {
// Web: the browser hands back bytes, not a path — read them,
// register them name-keyed (so reloads and re-opens of the
// host drawing re-resolve the xref in-session) and pre-parse
// on the worker in one step. The returned name-only PathBuf
// is what the native branch's `handle_path` yields there.
#[cfg(target_arch = "wasm32")]
{
Task::perform(
crate::io::pick_and_register_xref_web(),
Message::XAttachPickResult,
)
}
#[cfg(not(target_arch = "wasm32"))]
{
Task::perform(
async {
let handle = crate::sys::file_dialog()
.set_title(crate::t!("Select External Reference File").as_ref())
.add_filter(
crate::t!("CAD Files").as_ref(),
&["dwg", "dxf", "bak", "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 Files").as_ref(),
&["bak", "BAK"],
)
.pick_file()
.await;
match handle {
Some(h) => Ok(crate::sys::handle_path(&h)),
None => Err("Cancelled".to_string()),
}
},
Message::XAttachPickResult,
)
}
}
Message::XAttachPickResult(Ok(path)) => {
self.open_xref_attach_dialog(path);
@@ -1692,9 +1712,13 @@ impl OpenCADStudio {
self.on_data_extraction_browse_settings_result(path)
}
Message::DataExtractionAddDrawings => self.on_data_extraction_add_drawings(),
Message::DataExtractionAddDrawingsResult(paths) => {
Message::DataExtractionAddDrawingsResult(Ok(paths)) => {
self.on_data_extraction_add_drawings_result(paths)
}
Message::DataExtractionAddDrawingsResult(Err(error)) => {
self.data_extraction.error = error;
Task::none()
}
Message::DataExtractionAddFolder => self.on_data_extraction_add_folder(),
Message::DataExtractionAddFolderResult(path) => {
self.on_data_extraction_add_folder_result(path)
@@ -1728,13 +1752,22 @@ impl OpenCADStudio {
}
Message::DataExtractionSaveResult(csv, Some(path)) => {
match std::fs::write(&path, csv.as_bytes()) {
let fname = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
// The browser has no writable path behind the picked name:
// the CSV leaves as a download instead of an fs::write.
#[cfg(target_arch = "wasm32")]
let write_result: Result<(), String> = {
crate::sys::download_bytes(&fname, csv.as_bytes());
Ok(())
};
#[cfg(not(target_arch = "wasm32"))]
let write_result = std::fs::write(&path, csv.as_bytes()).map_err(|e| e.to_string());
match write_result {
Ok(()) => {
let rows = csv.lines().count().saturating_sub(1);
let fname = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
self.command_line.push_output(
crate::tf!("DATAEXTRACTION {rows} rows → \"{fname}\"").as_ref(),
);
@@ -10103,6 +10136,27 @@ impl OpenCADStudio {
screenshot,
} => self.on_web_save_screenshot(tab_id, filename, ext, version, bounds, screenshot),
#[cfg(target_arch = "wasm32")]
Message::WebSaveSerialized {
tab_id,
filename,
autosave_key,
version,
geometry_epoch,
edit_revision,
camera_generation,
result,
} => self.on_web_save_serialized(
tab_id,
filename,
autosave_key,
version,
geometry_epoch,
edit_revision,
camera_generation,
result,
),
#[cfg(not(target_arch = "wasm32"))]
Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(),
+288 -25
View File
@@ -42,8 +42,13 @@ pub mod xref_model;
use crate::scene::DerivedCaches;
use codec::entities::EntityType;
// The path-based readers are native-only; web parsing runs through the
// parse worker / the xref bytes registry instead.
#[cfg(not(target_arch = "wasm32"))]
use codec::io::dwg::DwgReader;
use codec::{CadDocument, DwgReadOptions, DwgWriter, DxfReader, DxfReaderConfiguration, DxfWriter};
#[cfg(not(target_arch = "wasm32"))]
use codec::{DwgReadOptions, DxfReader, DxfReaderConfiguration};
use codec::{CadDocument, DwgWriter, DxfWriter};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU16, AtomicU32, AtomicU8, Ordering};
use std::sync::Arc;
@@ -608,6 +613,10 @@ pub async fn open_bytes_web(
bytes: Arc<[u8]>,
progress: Arc<OpenProgressState>,
) -> WebOpenOutcome {
// The opened drawing's own bytes enter the xref registry too: another
// open drawing may reference it by name, and the registry — not the
// absent filesystem — is what re-resolves that reference in-session.
crate::io::xref::register_source(&name, Arc::new(bytes.to_vec()));
let size_bytes = bytes.len() as u64;
let result = load_web_bytes(&name, &bytes, progress.clone(), false, "", None).await;
let keep_for_recovery = result
@@ -758,9 +767,16 @@ async fn load_web_bytes(
recovery_available: true,
});
}
// XREFs inside the opened drawing resolve from the in-session bytes
// registry (see `resolve_web_xrefs`) before the caches snapshot the
// document, so merged xref geometry is part of the caches — the same
// phase order the native open uses.
let (xref_infos, xref_dropped) = resolve_web_xrefs(&mut doc, progress.clone()).await;
let mut caches = crate::scene::build_derived_caches(&doc);
caches.corrupt_dropped = dropped;
caches.read_stats = Some(outcome.stats);
caches.xrefs = xref_infos;
caches.xref_dropped = xref_dropped;
if source_sha256.is_none() && recovery_fingerprint_needed(&caches) {
source_sha256 = web_worker::sha256_document(bytes).await.ok();
}
@@ -814,6 +830,91 @@ pub async fn pick_and_parse_web(
Ok(Some((name, document)))
}
/// Web: parse drawing bytes already in hand on the shared worker — the
/// byte-level counterpart of `load_file` for callers that ran their own
/// picker (Data Extraction's multi-file source list). Same normalization as
/// every other web-opened drawing.
#[cfg(target_arch = "wasm32")]
pub async fn parse_drawing_bytes_web(name: &str, bytes: &[u8]) -> Result<CadDocument, String> {
parse_web_document(name, bytes).await
}
/// Web: resolve the XREF blocks inside a freshly opened drawing against the
/// in-session bytes registry — the browser counterpart of the native open's
/// xref phase. Every referenced file whose bytes are registered (picked or
/// dropped earlier this session) is parsed on the shared worker first, so
/// the UI thread only runs the (cheap) merge; unregistered references come
/// back as `NotFound` for the open handler to warn about.
#[cfg(target_arch = "wasm32")]
async fn resolve_web_xrefs(
doc: &mut CadDocument,
progress: Arc<OpenProgressState>,
) -> (Vec<crate::io::xref::XrefInfo>, usize) {
let pending: Vec<String> = doc
.block_records
.iter()
.filter(|br| (br.flags.is_xref || br.flags.is_xref_overlay) && !br.xref_path.is_empty())
.map(|br| br.xref_path.clone())
.collect();
let total = pending.len().max(1);
for (index, raw_path) in pending.iter().enumerate() {
progress.set(crate::app::OPEN_PHASE_XREF, 5900, index, total);
let Some(key) = crate::io::xref::find_registered_source(raw_path) else {
continue;
};
let Some(bytes) = crate::io::xref::registered_bytes(&key) else {
continue;
};
// The strict worker refuses recoverable parses; those fall through
// to a direct registry read inside the resolve so their stats (and
// the Failed status derived from them) match the native loader.
if let Ok((outcome, _)) = web_worker::parse_document(&key, &bytes, false, "").await {
crate::io::xref::register_parsed(&key, outcome);
}
}
progress.set(crate::app::OPEN_PHASE_XREF, 5900, total, total);
// `set_fraction` is native-only; the web phase reports completed/total
// directly, which the open overlay surfaces alongside the fixed weight.
let xref_progress: Arc<dyn Fn(usize, usize) + Send + Sync> = {
let progress = Arc::clone(&progress);
Arc::new(move |completed, total| {
progress.set(crate::app::OPEN_PHASE_XREF, 5900, completed, total);
})
};
crate::io::xref::resolve_xrefs_with_progress(doc, Path::new(""), Some(xref_progress))
}
/// Web XATTACH pick: read the chosen drawing's bytes, keep them in the xref
/// registry (so later reloads and re-opens of the host drawing re-resolve
/// the reference in-session), and pre-parse on the worker so the
/// commit-time merge stays cheap. A parse failure defers to commit time,
/// which re-reads the registered bytes and reports the same Failed status
/// the native attach would. `Err("Cancelled")` mirrors the native pick.
#[cfg(target_arch = "wasm32")]
pub async fn pick_and_register_xref_web() -> Result<PathBuf, String> {
let Some(handle) = crate::sys::file_dialog()
.set_title(crate::t!("Select External Reference File").as_ref())
.add_filter(
crate::t!("CAD Files").as_ref(),
&["dwg", "dxf", "bak", "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 Files").as_ref(), &["bak", "BAK"])
.pick_file()
.await
else {
return Err("Cancelled".to_string());
};
let name = handle.file_name();
let bytes = handle.read().await;
crate::io::xref::register_source(&name, Arc::new(bytes.clone()));
if let Ok((outcome, _)) = web_worker::parse_document(&name, &bytes, false, "").await {
crate::io::xref::register_parsed(&name, outcome);
}
Ok(PathBuf::from(name))
}
/// 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> {
@@ -870,6 +971,8 @@ pub fn load_bytes_finalized(path: &Path, bytes: Vec<u8>) -> Result<(CadDocument,
/// Load a DWG or DXF file directly from a path (auto-detect by extension).
/// Peek at a file's leading bytes to tell a DWG (version tag "AC10xx") from a
/// DXF. Used for `.bak` copies, whose extension hides the real format.
/// Native-only: the web reads registered bytes instead of the filesystem.
#[cfg(not(target_arch = "wasm32"))]
fn sniff_dwg_or_dxf(path: &Path) -> String {
use std::io::Read;
let mut buf = [0u8; 6];
@@ -905,6 +1008,10 @@ pub fn load_file(_path: &Path) -> Result<CadDocument, String> {
Err(crate::t!("Opening a drawing by path is not available in the browser.").into_owned())
}
/// Path-based reader with progress — native-only: on the web xrefs resolve
/// from the bytes registry (`io::xref::read_registered_outcome`), never a
/// filesystem path.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn load_file_with_progress(
path: &Path,
_progress: Option<Arc<dyn Fn(u16) + Send + Sync>>,
@@ -1001,12 +1108,14 @@ fn load_file_for_open(
finalize_loaded_outcome(path, outcome).map_err(OpenAttemptFailure::from)
}
#[cfg(not(target_arch = "wasm32"))]
struct ReaderFailure {
message: String,
#[cfg(not(target_arch = "wasm32"))]
recoverable: bool,
}
#[cfg(not(target_arch = "wasm32"))]
impl ReaderFailure {
fn from_reader(error: codec::DxfError) -> Self {
Self {
@@ -1067,6 +1176,7 @@ fn merge_read_stats(
}
}
#[cfg(not(target_arch = "wasm32"))]
fn read_file_attempt(
path: &Path,
progress: Option<Arc<dyn Fn(u16) + Send + Sync>>,
@@ -1094,6 +1204,7 @@ fn read_file_attempt(
}
}
#[cfg(not(target_arch = "wasm32"))]
fn finalize_loaded_outcome(
path: &Path,
mut outcome: codec::ReadOutcome,
@@ -1116,6 +1227,7 @@ fn finalize_loaded_outcome(
Ok(outcome)
}
#[cfg(not(target_arch = "wasm32"))]
fn read_dwg_path(
path: &Path,
progress: Option<Arc<dyn Fn(u16) + Send + Sync>>,
@@ -1236,6 +1348,7 @@ fn read_drawing_snapshot(path: &Path) -> std::io::Result<Vec<u8>> {
}
}
#[cfg(not(target_arch = "wasm32"))]
fn read_dxf_path(path: &Path, failsafe: bool) -> Result<codec::ReadOutcome, ReaderFailure> {
DxfReader::from_file(path)
.map_err(ReaderFailure::from_reader)?
@@ -1312,6 +1425,7 @@ fn normalize_block_origins(doc: &mut CadDocument) {
/// are untouched. File handles are reused when still free so a repair +
/// resave stays handle-stable; otherwise a fresh handle is minted and the
/// record is pointed at it.
#[cfg(not(target_arch = "wasm32"))]
fn materialize_missing_block_markers(doc: &mut CadDocument) {
use codec::entities::{Block, BlockEnd};
use codec::{EntityType, Handle};
@@ -1386,6 +1500,9 @@ fn materialize_missing_block_markers(doc: &mut CadDocument) {
/// that doesn't exist on this machine. Resolve each image to a usable path:
/// the stored path if it exists, otherwise the same file name next to the
/// drawing — and write the result onto the entity so the renderer finds it.
/// Native-only: its callers resolve against a real folder; web images bind
/// through the name-keyed byte registry instead.
#[cfg(not(target_arch = "wasm32"))]
fn resolve_raster_image_paths(doc: &mut CadDocument, base_dir: Option<&Path>) {
use codec::objects::ObjectType;
use codec::EntityType;
@@ -2133,6 +2250,13 @@ pub fn save(doc: &CadDocument, path: &Path) -> Result<(), String> {
/// `doc.version`). Used by the web build, which hands the bytes to a browser
/// download instead of writing a path — the byte-buffer counterpart to
/// [`save_as_version`].
///
/// This is the *inline* path (main thread on wasm). The web SAVE/AUTOSAVE
/// flows prefer [`prepare_document_for_save`] +
/// [`serialize_prepared_document`], which runs the writer half on the browser
/// worker; both routes share the same preparation and the same writer
/// dispatch (`ocs_save::serialize_document`), so the produced bytes are
/// identical.
#[allow(dead_code)] // web/wasm build only; unused in the native target
pub fn save_to_bytes(
doc: &CadDocument,
@@ -2141,35 +2265,14 @@ pub fn save_to_bytes(
) -> Result<Vec<u8>, String> {
let perf = crate::perf::enabled();
let total_started = iced::time::Instant::now();
let clone_started = iced::time::Instant::now();
let mut doc = doc.clone();
let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0;
doc.version = version;
let styles_started = iced::time::Instant::now();
sync_current_styles_on_save(&mut doc);
let styles_ms = styles_started.elapsed().as_secs_f64() * 1000.0;
let dimensions_started = iced::time::Instant::now();
crate::modules::draw::modify::explode::bake_dimension_blocks(&mut doc);
let dimensions_ms = dimensions_started.elapsed().as_secs_f64() * 1000.0;
let doc = prepare_document_for_save(doc.clone(), version);
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()),
_ => {
let mut buf = std::io::Cursor::new(Vec::new());
DwgWriter::write_to_writer(&mut buf, &doc).map_err(|e| e.to_string())?;
Ok(buf.into_inner())
}
};
let result = ocs_save::serialize_document(&doc, ext);
if perf {
let bytes = result.as_ref().map_or(0, Vec::len);
crate::perf_record!(
"[perf] save-bytes total={:.1}ms clone={:.1} styles={:.1} dimensions={:.1} write={:.1} bytes={} entities={} objects={}",
"[perf] save-bytes total={:.1}ms write={:.1} bytes={} entities={} objects={}",
total_started.elapsed().as_secs_f64() * 1000.0,
clone_ms,
styles_ms,
dimensions_ms,
write_started.elapsed().as_secs_f64() * 1000.0,
bytes,
doc.entities().count(),
@@ -2179,6 +2282,59 @@ pub fn save_to_bytes(
result
}
/// Prepare a save copy of a document: stamp the target DXF version, sync the
/// current-style markers into their header handles / dictionary variables, and
/// bake missing dimension geometry blocks. The app-side half of the save
/// pipeline — it must run where the live document lives (the worker crate
/// cannot reach the app's style/dimension modules). It takes the copy by
/// value, so callers that already built one (`document_for_save`) pay no
/// second clone and the live document is never touched. Both
/// [`save_to_bytes`] and the web worker offload call this exactly once per
/// save, so there is one implementation of the preparation for every target.
pub fn prepare_document_for_save(mut doc: CadDocument, version: codec::DxfVersion) -> CadDocument {
let perf = crate::perf::enabled();
doc.version = version;
let styles_started = perf.then(iced::time::Instant::now);
sync_current_styles_on_save(&mut doc);
let styles_ms = crate::perf::elapsed_ms(styles_started);
let dimensions_started = perf.then(iced::time::Instant::now);
crate::modules::draw::modify::explode::bake_dimension_blocks(&mut doc);
let dimensions_ms = crate::perf::elapsed_ms(dimensions_started);
if perf {
crate::perf_record!(
"[perf] save-prepare styles={:.1} dimensions={:.1} entities={} objects={}",
styles_ms,
dimensions_ms,
doc.entities().count(),
doc.objects.len(),
);
}
doc
}
/// Web SAVE/AUTOSAVE: offload the DWG/DXF writer pass to the browser worker.
/// `prepared` comes from [`prepare_document_for_save`] — the serialization
/// itself (the expensive half) runs off the UI thread and the encoded bytes
/// come back here. Falls back to inline serialization when the worker cannot
/// spawn or errors, so a save never hard-fails just because the worker is
/// unavailable.
#[cfg(target_arch = "wasm32")]
pub async fn serialize_prepared_document(
prepared: &CadDocument,
ext: &str,
) -> Result<Vec<u8>, String> {
let encoded = match ocs_save::encode_request(prepared, ext) {
Ok(encoded) => encoded,
// The worker cannot help if the document cannot even be encoded —
// serialize inline from the document we still hold.
Err(error) => {
log::warn!("{error}; serializing inline");
return ocs_save::serialize_document(prepared, ext);
}
};
web_worker::serialize_document(encoded).await
}
// ── Post-load fixups ──────────────────────────────────────────────────────
// Resolve the current text / dimension / multiline style from the handle the
@@ -3259,3 +3415,110 @@ mod pre_r2000_layer_plot_tests {
);
}
}
// ── Save-pipeline prepare tests ────────────────────────────────────────────
//
// `prepare_document_for_save` is the extracted app-side half of the save
// pipeline, shared by the inline `save_to_bytes` and the web worker offload —
// these pin the behaviours both routes depend on.
#[cfg(test)]
mod prepare_save_tests {
use super::*;
use codec::entities::{Dimension, DimensionLinear};
use codec::tables::TextStyle;
use codec::types::Vector3;
// The live document must never observe the save preparation: version
// stamp, style sync and dimension baking all happen on the clone only.
#[test]
fn prepare_stamps_version_and_leaves_live_document_untouched() {
let mut doc = CadDocument::new();
doc.version = codec::DxfVersion::AC1027;
let prepared = prepare_document_for_save(doc.clone(), codec::DxfVersion::AC1032);
assert_eq!(prepared.version, codec::DxfVersion::AC1032);
assert_eq!(doc.version, codec::DxfVersion::AC1027);
}
// The current text style marker must resolve from its name to the table
// entry's handle on the prepared clone (the DWG header stores a handle),
// while the live document keeps its previous marker.
#[test]
fn prepare_syncs_current_style_markers() {
let mut doc = CadDocument::new();
crate::io::linetypes::populate_document(&mut doc);
let mut style = TextStyle::new("ANNOT");
style.handle = doc.allocate_handle();
let style_handle = style.handle;
doc.text_styles.add(style).unwrap();
doc.header.current_text_style_name = "ANNOT".to_string();
let live_handle = doc.header.current_text_style_handle;
let prepared = prepare_document_for_save(doc.clone(), doc.version);
assert_eq!(prepared.header.current_text_style_handle, style_handle);
assert_eq!(doc.header.current_text_style_handle, live_handle);
}
// A dimension without its anonymous geometry block must get one baked on
// the clone (the writer emits the *D block), with the group-10 definition
// point mirrored into `base` — while the live document keeps neither.
#[test]
fn prepare_bakes_missing_dimension_blocks() {
let mut doc = CadDocument::new();
crate::io::linetypes::populate_document(&mut doc);
let mut dim = DimensionLinear::new(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
);
dim.definition_point = Vector3::new(5.0, 7.0, 0.0);
dim.base.definition_point = Vector3::ZERO;
dim.base.common.handle = doc.allocate_handle();
let handle = dim.base.common.handle;
doc.add_entity(EntityType::Dimension(Dimension::Linear(dim)))
.unwrap();
let prepared = prepare_document_for_save(doc.clone(), doc.version);
let EntityType::Dimension(Dimension::Linear(linear)) =
prepared.get_entity(handle).unwrap()
else {
panic!("dimension missing from prepared clone");
};
assert!(
linear.base.block_name.starts_with("*D"),
"anonymous dimension block not baked: {}",
linear.base.block_name
);
assert!(prepared.block_records.get(&linear.base.block_name).is_some());
assert_eq!(linear.base.definition_point, linear.definition_point);
// The live document is untouched: no block name, stale group-10.
let EntityType::Dimension(Dimension::Linear(live)) =
doc.get_entity(handle).unwrap()
else {
panic!("dimension missing from live document");
};
assert!(live.base.block_name.is_empty());
assert_eq!(live.base.definition_point, Vector3::ZERO);
}
// The whole point of the split: the inline fallback (`save_to_bytes`) and
// the worker route (`prepare_document_for_save` → `ocs_save` on the
// worker) must produce identical bytes for the same input.
#[test]
fn save_to_bytes_equals_prepare_then_shared_serialize() {
let mut doc = CadDocument::new();
crate::io::linetypes::populate_document(&mut doc);
let mut dim = DimensionLinear::new(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
);
dim.base.common.handle = doc.allocate_handle();
doc.add_entity(EntityType::Dimension(Dimension::Linear(dim)))
.unwrap();
for ext in ["dwg", "dxf"] {
let inline = save_to_bytes(&doc, ext, codec::DxfVersion::AC1032).expect("inline");
let prepared = prepare_document_for_save(doc.clone(), codec::DxfVersion::AC1032);
let shared = ocs_save::serialize_document(&prepared, ext).expect("shared");
assert_eq!(inline, shared, "inline and shared serialize diverge for {ext}");
}
}
}
+5 -2
View File
@@ -132,8 +132,11 @@ pub fn register_session_table(name: &str, bytes: Arc<Vec<u8>>) {
}
/// The bytes registered under `name` this session, matched
/// case-insensitively the way the folder scan matches file names.
fn session_table(name: &str) -> Option<Arc<Vec<u8>>> {
/// case-insensitively the way the folder scan matches file names. The web
/// ARCHIVE/ETRANSMIT packages a drawing's registered plot table into the
/// transmittal; `None` means the table was not picked this session (the
/// built-ins ship with the app and need no transmittal copy).
pub fn session_table(name: &str) -> Option<Arc<Vec<u8>>> {
session_tables()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
+5
View File
@@ -130,6 +130,11 @@ pub async fn open_dropped(
bytes: Vec<u8>,
progress: std::sync::Arc<crate::io::OpenProgressState>,
) -> crate::io::WebOpenOutcome {
// The dropped drawing's bytes also enter the xref registry (name-keyed,
// like dropped raster images): another drawing may reference it by
// name, and the registry — not the absent filesystem — is what
// re-resolves that reference when its host is reopened or reloaded.
super::xref::register_source(&name, std::sync::Arc::new(bytes.clone()));
progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2);
let bytes: std::sync::Arc<[u8]> = std::sync::Arc::from(bytes);
let size_bytes = bytes.len() as u64;
+95 -44
View File
@@ -7,24 +7,10 @@ use wasm_bindgen::{JsCast, JsValue};
use web_sys::{ErrorEvent, MessageEvent, Worker, WorkerOptions, WorkerType};
const HASH_MARKER: &str = "\nreport-source-sha256:";
const PROTOCOL_VERSION: u16 = 4;
const WORKER_URL: &str = "ocs-parse-worker.js?v=4";
#[derive(serde::Deserialize)]
struct EntityRuntimeFields {
handle: u64,
linetype_handle: Option<u64>,
color_book_handle: Option<u64>,
face_visual_style_handle: Option<u64>,
edge_visual_style_handle: Option<u64>,
material_flags: u8,
material_handle: Option<u64>,
shadow_flags: u8,
plotstyle_flags: u8,
plotstyle_handle: Option<u64>,
entity_mode: Option<u8>,
has_ds_data: bool,
}
/// Version of the shared worker wire format (`ocs_save`); the `?v=` on the
/// worker URL cache-busts to match. v5 adds the `serialize` action.
const PROTOCOL_VERSION: u16 = ocs_save::PROTOCOL_VERSION;
const WORKER_URL: &str = "ocs-parse-worker.js?v=5";
pub(super) async fn parse_document(
name: &str,
@@ -58,7 +44,7 @@ pub(super) async fn parse_document(
Result<codec::ReadOutcome, (String, Option<codec::ReadStats>)>,
Option<String>,
bool,
Vec<EntityRuntimeFields>,
Vec<ocs_save::EntityRuntimeFields>,
) = bincode::deserialize(&bytes)
.map_err(|error| super::OpenLoadError::from(error.to_string()))?;
if payload.0 != PROTOCOL_VERSION {
@@ -69,7 +55,10 @@ pub(super) async fn parse_document(
}
match payload.1 {
Ok(mut outcome) => {
restore_entity_runtime_fields(&mut outcome.document, payload.4);
ocs_save::restore_entity_runtime_fields(
&mut outcome.document,
payload.4,
);
Ok((outcome, payload.2))
}
Err((message, read_stats)) => Err(super::OpenLoadError {
@@ -136,30 +125,6 @@ pub(super) async fn parse_document(
result
}
fn restore_entity_runtime_fields(
document: &mut codec::CadDocument,
fields: Vec<EntityRuntimeFields>,
) {
let handle = |value: Option<u64>| value.map(codec::Handle::new);
for fields in fields {
let Some(entity) = document.get_entity_mut(codec::Handle::new(fields.handle)) else {
continue;
};
let common = entity.common_mut();
common.linetype_handle = handle(fields.linetype_handle);
common.color_book_handle = handle(fields.color_book_handle);
common.face_visual_style_handle = handle(fields.face_visual_style_handle);
common.edge_visual_style_handle = handle(fields.edge_visual_style_handle);
common.material_flags = fields.material_flags;
common.material_handle = handle(fields.material_handle);
common.shadow_flags = fields.shadow_flags;
common.plotstyle_flags = fields.plotstyle_flags;
common.plotstyle_handle = handle(fields.plotstyle_handle);
common.entity_mode = fields.entity_mode;
common.has_ds_data = fields.has_ds_data;
}
}
pub(super) async fn sha256_document(bytes: &[u8]) -> Result<String, super::OpenLoadError> {
let options = WorkerOptions::new();
options.set_type(WorkerType::Module);
@@ -226,6 +191,92 @@ fn decode_worker_error(message: String) -> super::OpenLoadError {
super::OpenLoadError::new(message.to_string(), source_sha256)
}
/// Run the DWG/DXF writer pass for SAVE/AUTOSAVE on the browser worker.
/// `encoded_request` is the shared `ocs_save` payload (prepared document +
/// runtime-field sidecar + target extension); the worker replies with the
/// encoded drawing bytes. Any worker failure — cannot spawn, script error,
/// stale protocol — falls back to decoding the document here and serializing
/// inline on this thread, so a save never hard-fails because the worker is
/// unavailable (strict CSP, blocked worker script, out-of-memory spawn...).
pub(super) async fn serialize_document(encoded_request: Vec<u8>) -> Result<Vec<u8>, String> {
match serialize_document_via_worker(&encoded_request).await {
Ok(bytes) => Ok(bytes),
Err(worker_error) => {
log::warn!("save worker unavailable, serializing inline: {worker_error}");
let (document, ext) = ocs_save::decode_request(&encoded_request)?;
ocs_save::serialize_document(&document, &ext)
}
}
}
async fn serialize_document_via_worker(encoded_request: &[u8]) -> Result<Vec<u8>, String> {
let options = WorkerOptions::new();
options.set_type(WorkerType::Module);
let worker =
Worker::new_with_options(WORKER_URL, &options).map_err(|error| js_error(error))?;
let (sender, receiver) =
iced::futures::channel::oneshot::channel::<Result<Vec<u8>, String>>();
let sender = Rc::new(RefCell::new(Some(sender)));
let message_sender = sender.clone();
let on_message = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
let data = event.data();
let ok = Reflect::get(&data, &JsValue::from_str("ok"))
.ok()
.and_then(|value| value.as_bool())
.unwrap_or(false);
let result = if ok {
Reflect::get(&data, &JsValue::from_str("data"))
.map_err(|error| js_error(error))
.and_then(|value| {
let bytes = Uint8Array::new(&value).to_vec();
ocs_save::decode_response(&bytes)
})
.and_then(|result| result)
} else {
let message = Reflect::get(&data, &JsValue::from_str("error"))
.ok()
.and_then(|value| value.as_string())
.unwrap_or_else(|| "CAD serializer worker failed".to_string());
Err(message)
};
if let Some(sender) = message_sender.borrow_mut().take() {
let _ = sender.send(result);
}
});
worker.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
let error_sender = sender;
let on_error = Closure::<dyn FnMut(ErrorEvent)>::new(move |event: ErrorEvent| {
if let Some(sender) = error_sender.borrow_mut().take() {
let _ = sender.send(Err(event.message()));
}
});
worker.set_onerror(Some(on_error.as_ref().unchecked_ref()));
let payload = Object::new();
Reflect::set(
&payload,
&JsValue::from_str("action"),
&JsValue::from_str("serialize"),
)
.map_err(|error| js_error(error))?;
let input = Uint8Array::from(encoded_request);
Reflect::set(&payload, &JsValue::from_str("document"), &input.buffer())
.map_err(|error| js_error(error))?;
let transfer = Array::new();
transfer.push(&input.buffer());
worker
.post_message_with_transfer(&payload, &transfer)
.map_err(|error| js_error(error))?;
let result = receiver
.await
.map_err(|_| "CAD serializer worker closed without a result".to_string())?;
worker.terminate();
result
}
fn js_error(value: JsValue) -> String {
value
.as_string()
+317
View File
@@ -7,6 +7,7 @@ use codec::types::{Handle, Vector3};
use codec::{CadDocument, EntityType};
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
#[cfg(not(target_arch = "wasm32"))]
type SourceFingerprint = crate::io::edit_lock::FileFingerprint;
@@ -44,6 +45,197 @@ pub struct XrefInfo {
pub source_sha256: Option<String>,
}
/// Names of the xrefs that resolved to nothing (`NotFound`) — the list the
/// one-line "unresolved references" warning after a web open shows.
pub fn unresolved_xref_names(infos: &[XrefInfo]) -> Vec<String> {
infos
.iter()
.filter(|info| info.status == XrefStatus::NotFound)
.map(|info| info.name.clone())
.collect()
}
// ── In-session xref byte registry (web) ────────────────────────────────
//
// The browser exposes no filesystem behind the path a drawing's XREF block
// carries, so the web build keeps the bytes of every DWG/DXF the user picked
// or dropped, keyed by file name — the xref counterpart of
// `image_model::register_source`. Resolution matches a block-record's
// `xref_path` against the registry by trailing file name (case-insensitive),
// the only component two machines can be expected to agree on. Native builds
// never consult the registry: `resolve_path` reads the real filesystem, so
// their behaviour is byte-identical to before. The registry itself compiles
// on every target so its pure matching logic stays unit-testable on the
// native host.
/// Lower-cased trailing component of a path-style xref reference. Drawings
/// store xref paths as the author wrote them (often an absolute foreign
/// path), so the file name is the only part a picked or dropped file on
/// another machine can be expected to match.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub(crate) fn file_name_key(path: &str) -> String {
path.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_ascii_lowercase()
}
/// Do two xref references name the same file? Compares trailing components
/// case-insensitively (the web has no path beyond the name at all, and the
/// Windows paths most drawings were authored on are case-insensitive).
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub(crate) fn same_file_name(a: &str, b: &str) -> bool {
let (a, b) = (file_name_key(a), file_name_key(b));
!a.is_empty() && a == b
}
fn source_cache() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = OnceLock::new();
SOURCES.get_or_init(|| Mutex::new(HashMap::default()))
}
fn parsed_cache() -> &'static Mutex<HashMap<String, codec::ReadOutcome>> {
static PARSED: OnceLock<Mutex<HashMap<String, codec::ReadOutcome>>> = OnceLock::new();
PARSED.get_or_init(|| Mutex::new(HashMap::default()))
}
/// Keep a picked or dropped drawing's bytes, keyed by the file name the
/// drawing's xref references will be matched against. Registering supersedes
/// any eager parse cached for the same file, so a re-pick (or a drop that
/// finally supplies a missing reference) reloads from the new bytes.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // mirrors image_model::register_source
pub fn register_source(name: &str, bytes: Arc<Vec<u8>>) {
source_cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(name.to_string(), bytes);
parsed_cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.retain(|parsed, _| !same_file_name(parsed, name));
}
/// Cache an off-thread parse (web parse worker) for the next resolve to
/// merge. The entry is one-shot: a resolve consumes it, so a later reload
/// re-reads the registered bytes instead of replaying a stale document.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn register_parsed(name: &str, outcome: codec::ReadOutcome) {
parsed_cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(name.to_string(), outcome);
}
/// Registry key whose trailing file name matches `raw` (case-insensitive) —
/// the web stand-in for `resolve_path`, which has no filesystem to consult.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native
pub(crate) fn find_registered_source(raw: &str) -> Option<String> {
source_cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.keys()
.find(|key| same_file_name(key, raw))
.cloned()
}
/// Bytes registered under exactly `key`.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn registered_bytes(key: &str) -> Option<Arc<Vec<u8>>> {
source_cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(key)
.cloned()
}
/// Take the cached parse for `key`, if one is pending.
#[cfg(any(target_arch = "wasm32", test))]
fn take_parsed(key: &str) -> Option<codec::ReadOutcome> {
parsed_cache()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(key)
}
/// Read a registered xref on the web: the cached off-thread parse when the
/// pick/open pre-pass produced one, otherwise a direct parse of the
/// registered bytes (the eager parse never caches recoverable outcomes —
/// those must keep their stats so phase 2 reports them as Failed, like the
/// native reader does).
#[cfg(target_arch = "wasm32")]
fn read_registered_outcome(
key: &str,
progress: Option<Arc<dyn Fn(u16) + Send + Sync>>,
) -> Result<codec::ReadOutcome, String> {
if let Some(mut outcome) = take_parsed(key) {
finalize_xref_outcome(&mut outcome);
return Ok(outcome);
}
let Some(bytes) = registered_bytes(key) else {
return Err(format!("no registered bytes for xref \"{key}\""));
};
let mut outcome = read_outcome_from_bytes(key, bytes, progress)?;
finalize_xref_outcome(&mut outcome);
Ok(outcome)
}
/// Apply the same post-parse fixups the file-open tail applies, so merged
/// xref content behaves like natively loaded content.
#[cfg(target_arch = "wasm32")]
fn finalize_xref_outcome(outcome: &mut codec::ReadOutcome) {
let doc = &mut outcome.document;
super::normalize_block_origins(doc);
if outcome.stats.source_format == Some(codec::SourceFormat::Dxf) {
super::fix_dxf_dimension_rotations(doc);
super::fix_dxf_layout_plot_settings(doc);
}
super::fix_viewport_status_flags(doc);
super::fix_current_style_names(doc);
}
/// Parse DWG/DXF bytes into a reader outcome — `load_file_with_progress`
/// for the browser, where the "file" is the registry entry. Recoverable
/// parse issues stay in the stats instead of failing the read, matching the
/// native xref loader's behaviour (phase 2 turns them into Failed).
#[cfg(target_arch = "wasm32")]
fn read_outcome_from_bytes(
name: &str,
bytes: Arc<Vec<u8>>,
progress: Option<Arc<dyn Fn(u16) + Send + Sync>>,
) -> Result<codec::ReadOutcome, String> {
use codec::io::dwg::DwgReader;
use codec::DxfReader;
// The readers keep their stream and want an owned, 'static buffer —
// take the registry `Arc`'s contents by value (one copy; the worker
// pre-parse makes this fallback path the exception, not the rule).
let bytes = bytes.to_vec();
let ext = name.rsplit('.').next().unwrap_or_default().to_lowercase();
// A `.bak`/`.sv$` copy hides the real format behind its extension —
// sniff the leading bytes, exactly as the path-based reader does.
let effective = if matches!(ext.as_str(), "bak" | "sv$") {
super::sniff_dwg_or_dxf_bytes(&bytes).to_string()
} else {
ext.clone()
};
let outcome = match effective.as_str() {
"dwg" => {
let mut reader = DwgReader::from_stream(std::io::Cursor::new(bytes));
if let Some(progress) = progress {
reader.set_progress_callback(progress);
}
reader.read_with_stats()
}
"dxf" => DxfReader::from_reader(std::io::Cursor::new(bytes))
.and_then(|reader| reader.read_with_stats()),
_ => return Err(format!("Unsupported file format: .{ext}")),
};
let outcome = outcome.map_err(|error| error.to_string())?;
if !outcome.stats.has_usable_drawing_data() {
return Err("initial read returned no source drawing records".to_string());
}
Ok(outcome)
}
/// Scan `doc` for XREF block-records, resolve their paths relative to
/// `base_dir`, and populate each xref block with entities from the
/// referenced file.
@@ -148,9 +340,16 @@ fn resolve_xrefs_with_filter(
});
callback
});
#[cfg(not(target_arch = "wasm32"))]
let xref_outcome = resolved
.as_ref()
.map(|path| super::load_file_with_progress(path, nested_progress));
// Web: read the registry — the cached off-thread parse when the
// pick/open pre-pass made one, otherwise the registered bytes.
#[cfg(target_arch = "wasm32")]
let xref_outcome = resolved
.as_ref()
.map(|path| read_registered_outcome(&path.to_string_lossy(), nested_progress));
let source_sha256 = resolved.as_ref().and_then(|path| {
let needs_fingerprint = match &xref_outcome {
Some(Ok(outcome)) => {
@@ -414,8 +613,17 @@ pub fn layer_overrides(doc: &CadDocument, name: &str, raw_path: &str, base_dir:
})
}
/// Web: there is no filesystem behind a stored path, so a reference resolves
/// to the name-keyed bytes registry instead (trailing-name match); the
/// returned path is the registry key.
#[cfg(target_arch = "wasm32")]
fn resolve_path(raw: &str, _base_dir: &Path) -> Option<PathBuf> {
find_registered_source(raw).map(PathBuf::from)
}
/// Try to build an absolute path from a raw xref path string.
/// Handles absolute paths, relative paths, and Windows-style separators.
#[cfg(not(target_arch = "wasm32"))]
fn resolve_path(raw: &str, base_dir: &Path) -> Option<PathBuf> {
let normalised = raw.replace('\\', "/");
let p = PathBuf::from(&normalised);
@@ -3279,3 +3487,112 @@ fn relative_point_cloud_path(doc: &mut CadDocument, handle: Handle, host: &Path)
def.source_filename = relative;
}
}
#[cfg(test)]
mod web_registry_tests {
use super::*;
fn outcome() -> codec::ReadOutcome {
codec::ReadOutcome::new(CadDocument::new(), codec::ReadStats::default())
}
#[test]
fn file_name_key_takes_the_trailing_component_case_folded() {
assert_eq!(file_name_key("Site Plan.dwg"), "site plan.dwg");
assert_eq!(
file_name_key(r"D:\Projects\Arch\XREFS\Elevation.DWG"),
"elevation.dwg"
);
assert_eq!(file_name_key("../consultant/model.dxf"), "model.dxf");
assert_eq!(file_name_key(""), "");
}
#[test]
fn same_file_name_ignores_path_and_case_but_not_empty() {
assert!(same_file_name(
r"C:\foreign\abs\PATH\Elevation.DWG",
"elevation.dwg"
));
assert!(same_file_name("a/x.dwg", r"b\X.Dwg"));
assert!(!same_file_name("elevation.dwg", "elevation-2.dwg"));
// An empty trailing component names nothing — never a match, or a
// drawing with a blank xref path would resolve to any empty key.
assert!(!same_file_name("", ""));
assert!(!same_file_name("", "x.dwg"));
}
#[test]
fn registry_finds_a_registered_source_by_trailing_name() {
let registered = "Registry-Find-Site.dwg";
register_source(registered, Arc::new(vec![1, 2, 3]));
// The drawing's stored path is the author's absolute foreign path;
// only the trailing name can match, case-insensitively.
assert_eq!(
find_registered_source(r"E:\2009\Consultant\REGISTRY-FIND-SITE.DWG"),
Some(registered.to_string())
);
assert_eq!(find_registered_source("registry-find-site.dxf"), None);
assert_eq!(
registered_bytes(registered).map(|bytes| bytes.as_ref().clone()),
Some(vec![1, 2, 3])
);
}
#[test]
fn register_source_supersedes_a_cached_parse_of_the_same_file() {
let name = "Registry-Supersede-Plan.dwg";
register_parsed(name, outcome());
// Re-registering the same file under a differently-cased path name
// must drop the stale parse so a reload re-reads the new bytes.
register_source("registry-supersede-plan.DWG", Arc::new(vec![9]));
assert!(take_parsed(name).is_none());
// And the fresh bytes are what the registry hands back.
assert_eq!(
find_registered_source(name).as_deref(),
Some("registry-supersede-plan.DWG")
);
}
#[test]
fn unresolved_xref_names_lists_not_found_entries_only() {
let infos = vec![
XrefInfo {
name: "Loaded.dwg".into(),
path: "Loaded.dwg".into(),
status: XrefStatus::Loaded,
diagnostics: Vec::new(),
read_stats: None,
source_sha256: None,
},
XrefInfo {
name: "Missing A.dwg".into(),
path: r"C:\gone\Missing A.dwg".into(),
status: XrefStatus::NotFound,
diagnostics: Vec::new(),
read_stats: None,
source_sha256: None,
},
XrefInfo {
name: "Broken.dwg".into(),
path: "Broken.dwg".into(),
status: XrefStatus::Failed,
diagnostics: Vec::new(),
read_stats: None,
source_sha256: None,
},
XrefInfo {
name: "Missing B.dxf".into(),
path: "Missing B.dxf".into(),
status: XrefStatus::NotFound,
diagnostics: Vec::new(),
read_stats: None,
source_sha256: None,
},
];
assert_eq!(
unresolved_xref_names(&infos),
vec!["Missing A.dwg".to_string(), "Missing B.dxf".to_string()]
);
assert!(unresolved_xref_names(&[]).is_empty());
}
}
+7
View File
@@ -3756,6 +3756,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"new path for selection" => Some(("insert", "xref-new-path-for-selection")),
"new path prefix" => Some(("insert", "xref-new-path-prefix")),
"old path prefix" => Some(("insert", "xref-old-path-prefix")),
"XREF Unresolved external references: {names} — drop the referenced DWG/DXF files on the page to register them, then reopen this drawing or run XRELOAD." => Some(("insert", "xref-unresolved-external-references")),
"Y (mm)" => Some(("plot", "y-mm")),
"Yards" => Some(("common", "yards")),
"Yellow" => Some(("common", "yellow")),
@@ -3793,11 +3794,14 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"that object" => Some(("modal", "that-object")),
"those objects" => Some(("modal", "those-objects")),
"units" => Some(("common", "units")),
"{cmd}: cannot build the transmittal ZIP ({e})." => Some(("file", "cmd-cannot-build-the-transmittal-zip-e")),
"{cmd}: cannot create folder ({e})." => Some(("file", "cmd-cannot-create-folder-e")),
"{cmd}: cannot package the drawing ({e})." => Some(("file", "cmd-cannot-package-the-drawing-e")),
"{cmd}: no hatch objects." => Some(("view", "cmd-no-hatch-objects")),
"{cmd}: no text or dimension objects." => Some(("view", "cmd-no-text-or-dimension-objects")),
"{cmd}: not yet implemented." => Some(("view", "cmd-not-yet-implemented")),
"{cmd}: packaged {copied} file(s) into {}" => Some(("file", "cmd-packaged-copied-file-s-into")),
"{cmd}: skipped {n} referenced file(s) not loaded in this session: {names}" => Some(("file", "cmd-skipped-n-referenced-file-s-not-loaded-in")),
"{count} object(s) added to selection." => Some(("common", "count-object-s-added-to-selection")),
"{count} object(s) pasted." => Some(("common", "count-object-s-pasted")),
"{error}. External-change checks remain active." => Some(("file", "error-external-change-checks-remain-active")),
@@ -4178,6 +4182,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Data Extraction Settings" => Some(("common", "data-extraction-settings")),
"Add Drawings" => Some(("common", "add-drawings")),
"CSV" => Some(("common", "csv")),
"Pick the file again to load its contents in the browser." => Some(("file", "pick-the-file-again-to-load-its-contents")),
"Adding folders is not available in the browser. Add drawing files instead." => Some(("file", "adding-folders-is-not-available-in-the-browser")),
"Drawing not loaded in the browser: {name}." => Some(("file", "drawing-not-loaded-in-the-browser")),
"Export STL" => Some(("common", "export-stl")),
"STL Files" => Some(("common", "stl-files")),
"Export STEP AP203" => Some(("common", "export-step-ap203")),
+13
View File
@@ -573,6 +573,19 @@ fn registered_bytes_by_name(path: &str) -> Option<Arc<Vec<u8>>> {
.map(|(_, bytes)| Arc::clone(bytes))
}
/// Original image file bytes registered for a reference, resolved the way
/// `decode_reference` resolves pixels: exact reference first, then any
/// registration that names the same file. The web ARCHIVE/ETRANSMIT
/// packages these bytes into the transmittal; `None` means nothing was
/// picked or dropped for the reference this session.
pub fn registered_source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
let path = path.trim();
if path.is_empty() {
return None;
}
registered_bytes(path).or_else(|| registered_bytes_by_name(path))
}
/// Drop every memoised image so the next resolve re-reads / re-fetches. Called
/// when a new document is opened — a fresh drawing shouldn't inherit a prior
/// one's stale successes or offline failures.
+12
View File
@@ -38,6 +38,18 @@ fn source_cache() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
SOURCES.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Bytes registered for exactly this PDF reference (the attach flow keys by
/// the same path the underlay definition stores). The web ARCHIVE/ETRANSMIT
/// packages the underlay into the transmittal; `None` means no PDF was
/// picked for this reference this session.
pub fn registered_source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
source_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.get(path)
.cloned()
}
/// Keep bytes selected through a file dialog. Browsers expose no reusable path,
/// while native builds also benefit by avoiding a second disk read.
pub fn register_source(path: &str, bytes: Arc<Vec<u8>>) {
+11
View File
@@ -899,6 +899,17 @@ pub fn data_extraction_view(
let add_folder = button(text(t!("Add folder…")).size(12)).padding([5, 10]);
#[cfg(not(target_arch = "wasm32"))]
let add_folder = add_folder.on_press(Message::DataExtractionAddFolder);
// The web has no folder picker and no directory to walk, so the button
// stays disabled there — with a tooltip that says why and what to do.
#[cfg(target_arch = "wasm32")]
let add_folder = iced::widget::tooltip(
add_folder,
container(text(t!("Adding folders is not available in the browser. Add drawing files instead.")).size(11))
.padding([3, 6])
.style(container::bordered_box),
iced::widget::tooltip::Position::Top,
)
.gap(4);
let body: Element<'_, Message> = match state.page {
ExtractionPage::Begin => group(