From 2e1006348b2a1f64ee8c35f62234a502c101d9d7 Mon Sep 17 00:00:00 2001 From: Afonsop Date: Thu, 17 Sep 2026 23:23:01 +0100 Subject: [PATCH] web: XREFs, data links & extraction, ARCHIVE transmittals, worker-side saves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 deterministic ZIP (minimal writer over flate2 — no new dependency) 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. --- Cargo.lock | 11 + Cargo.toml | 4 + crates/ocs_save/Cargo.toml | 15 ++ crates/ocs_save/src/lib.rs | 290 +++++++++++++++++++++ crates/ocs_web_worker/Cargo.toml | 3 + crates/ocs_web_worker/src/lib.rs | 70 ++--- locales/ar-SA/opencadstudio.ftl | 7 + locales/bg-BG/opencadstudio.ftl | 7 + locales/cs-CZ/opencadstudio.ftl | 7 + locales/de-DE/opencadstudio.ftl | 7 + locales/el-GR/opencadstudio.ftl | 7 + locales/en-US/opencadstudio.ftl | 7 + locales/es-ES/opencadstudio.ftl | 7 + locales/fi-FI/opencadstudio.ftl | 7 + locales/fr-FR/opencadstudio.ftl | 7 + locales/hi-IN/opencadstudio.ftl | 7 + locales/hu-HU/opencadstudio.ftl | 7 + locales/it-IT/opencadstudio.ftl | 7 + locales/ja-JP/opencadstudio.ftl | 7 + locales/ko-KR/opencadstudio.ftl | 7 + locales/nl-NL/opencadstudio.ftl | 7 + locales/pl-PL/opencadstudio.ftl | 7 + locales/pt-BR/opencadstudio.ftl | 7 + locales/ru-RU/opencadstudio.ftl | 7 + locales/tr-TR/opencadstudio.ftl | 7 + locales/zh-CN/opencadstudio.ftl | 7 + locales/zh-TW/opencadstudio.ftl | 7 + src/app/annotation_data.rs | 421 ++++++++++++++++++++++++++----- src/app/commands/blocks.rs | 39 ++- src/app/commands/fileops.rs | 304 ++++++++++++++++++---- src/app/mod.rs | 20 +- src/app/update/file.rs | 218 +++++++++++----- src/app/update/mod.rs | 106 ++++++-- src/io/mod.rs | 327 +++++++++++++++++++++--- src/io/plot_style.rs | 7 +- src/io/web_drop.rs | 5 + src/io/web_worker.rs | 136 ++++++---- src/io/xref.rs | 321 +++++++++++++++++++++++ src/io/zip.rs | 391 ++++++++++++++++++++++++++++ src/locale_catalog.rs | 7 + src/scene/model/image_model.rs | 13 + src/scene/model/pdf_raster.rs | 12 + src/ui/window/annotation_data.rs | 11 + web/ocs-parse-worker.js | 16 +- 44 files changed, 2564 insertions(+), 330 deletions(-) create mode 100644 crates/ocs_save/Cargo.toml create mode 100644 crates/ocs_save/src/lib.rs create mode 100644 src/io/zip.rs diff --git a/Cargo.lock b/Cargo.lock index 76a3880ef..8ae175025 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,7 @@ dependencies = [ "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "ocs_plugin_api", + "ocs_save", "open", "printpdf", "rayon", @@ -4174,6 +4175,15 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ocs_save" +version = "0.1.0" +dependencies = [ + "acadrust", + "bincode", + "serde", +] + [[package]] name = "ocs_web_worker" version = "0.1.0" @@ -4183,6 +4193,7 @@ dependencies = [ "console_error_panic_hook", "getrandom 0.3.4", "js-sys", + "ocs_save", "serde", "sha2 0.10.9", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index b643c8aca..c59429762 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ default-run = "OpenCADStudio" [workspace] members = [ "crates/ocs_plugin_api", + "crates/ocs_save", "crates/ocs_web_worker", "crates/dwg-thumbnailer", "crates/dwg-thumbnailer-win", @@ -36,6 +37,9 @@ acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12 cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840", features = ["acis", "offset"] } cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dcf0840" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } +# Shared SAVE serialization dispatch + web-worker wire format: one +# implementation for the inline (fallback/native) path and the worker path. +ocs_save = { path = "crates/ocs_save" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } hayro = "0.7.1" diff --git a/crates/ocs_save/Cargo.toml b/crates/ocs_save/Cargo.toml new file mode 100644 index 000000000..9c29ef618 --- /dev/null +++ b/crates/ocs_save/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ocs_save" +version = "0.1.0" +edition = "2021" +publish = false + +# Pure-Rust SAVE pipeline pieces shared by the app crate (native host and the +# main wasm instance) and the browser parse/serialize worker (`ocs_web_worker`), +# so the DWG/DXF byte serialization and the worker wire format each have +# exactly one implementation. No UI/app dependencies — only acadrust + serde. + +[dependencies] +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } +bincode = "1.3" +serde = { version = "1", features = ["derive"] } diff --git a/crates/ocs_save/src/lib.rs b/crates/ocs_save/src/lib.rs new file mode 100644 index 000000000..5270e3462 --- /dev/null +++ b/crates/ocs_save/src/lib.rs @@ -0,0 +1,290 @@ +//! Shared SAVE/serialize pipeline pieces. +//! +//! Both sides of the browser worker boundary use this crate so the DWG/DXF +//! byte serialization and the worker wire format have exactly one +//! implementation: +//! +//! - the app crate's inline `io::save_to_bytes` (native host fallback and the +//! web fallback path when the worker is unavailable), and +//! - `ocs_web_worker`'s `serialize_document` action, which runs the same +//! serialization on the dedicated worker thread. +//! +//! The document-crossing helpers (bincode encode/decode plus the runtime-field +//! sidecar) are also shared with the existing `parse_document` action, which +//! returns documents over the same boundary in the opposite direction. + +use acadrust::{CadDocument, DwgWriter, DxfWriter}; + +/// Worker protocol version. Bumped whenever a request/response payload shape +/// changes; the `?v=` cache-buster on the worker URL must match. v5 adds the +/// `serialize` action and extends the runtime-field sidecar with +/// `graphic_data`. +pub const PROTOCOL_VERSION: u16 = 5; + +/// Serialize a document to an in-memory byte buffer, format chosen by `ext` +/// (`dxf` → DXF, anything else → DWG). The document is expected to be fully +/// prepared for saving (version stamped, current styles synced, dimension +/// blocks baked) — see the app crate's `io::prepare_document_for_save`, which +/// owns that app-side half of the pipeline. +pub fn serialize_document(doc: &CadDocument, ext: &str) -> Result, String> { + 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()) + } + } +} + +// ── Runtime-field sidecar ────────────────────────────────────────────────── + +/// Per-entity fields `acadrust` marks `serde(skip)` on `EntityCommon` (raw +/// DWG storage/round-trip data the logical model does not own). They do not +/// survive a bincode crossing on their own, so every document that crosses +/// the worker boundary carries them beside the payload and re-installs them +/// after decode — otherwise the saved file would silently lose per-entity +/// linetype/material/plotstyle handles and raw graphic bytes. +#[derive(serde::Serialize, serde::Deserialize)] +pub struct EntityRuntimeFields { + pub handle: u64, + pub linetype_handle: Option, + pub color_book_handle: Option, + pub face_visual_style_handle: Option, + pub edge_visual_style_handle: Option, + pub material_flags: u8, + pub material_handle: Option, + pub shadow_flags: u8, + pub plotstyle_flags: u8, + pub plotstyle_handle: Option, + pub entity_mode: Option, + pub has_ds_data: bool, + pub graphic_data: Option>, +} + +/// Collect the serde-skipped runtime fields of every entity in `doc`. +pub fn extract_entity_runtime_fields(doc: &CadDocument) -> Vec { + doc.entities() + .map(|entity| { + let common = entity.common(); + let handle = |value: Option| value.map(|handle| handle.value()); + EntityRuntimeFields { + handle: common.handle.value(), + linetype_handle: handle(common.linetype_handle), + color_book_handle: handle(common.color_book_handle), + face_visual_style_handle: handle(common.face_visual_style_handle), + edge_visual_style_handle: handle(common.edge_visual_style_handle), + material_flags: common.material_flags, + material_handle: handle(common.material_handle), + shadow_flags: common.shadow_flags, + plotstyle_flags: common.plotstyle_flags, + plotstyle_handle: handle(common.plotstyle_handle), + entity_mode: common.entity_mode, + has_ds_data: common.has_ds_data, + graphic_data: common.graphic_data.clone(), + } + }) + .collect() +} + +/// Re-install runtime fields collected by [`extract_entity_runtime_fields`] +/// into a document that crossed a serde boundary. Entities whose handle no +/// longer resolves are skipped silently (mirrors the parse direction). +pub fn restore_entity_runtime_fields(document: &mut CadDocument, fields: Vec) { + let handle = |value: Option| value.map(acadrust::Handle::new); + for fields in fields { + let Some(entity) = document.get_entity_mut(acadrust::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; + common.graphic_data = fields.graphic_data; + } +} + +// ── Worker wire format (`serialize` action) ──────────────────────────────── +// +// Request (main → worker): bincode `(PROTOCOL_VERSION, ext, &CadDocument, +// Vec)` transferred as an ArrayBuffer. +// Response (worker → main): bincode `(PROTOCOL_VERSION, Result, +// String>)` — the encoded DWG/DXF bytes on success, the writer's +// error text on failure. + +/// Encode a prepared document for the trip main → worker. Costs one bincode +/// pass over the document plus the runtime-field sidecar; see the crate docs +/// for why the sidecar exists. +pub fn encode_request(doc: &CadDocument, ext: &str) -> Result, String> { + let runtime_fields = extract_entity_runtime_fields(doc); + bincode::serialize(&(PROTOCOL_VERSION, ext, doc, runtime_fields)) + .map_err(|error| format!("encode save document for worker: {error}")) +} + +/// Decode a `serialize` request on the worker: returns the document (with +/// runtime fields restored) and the target extension. +pub fn decode_request(bytes: &[u8]) -> Result<(CadDocument, String), String> { + let (version, ext, mut document, runtime_fields): ( + u16, + String, + CadDocument, + Vec, + ) = bincode::deserialize(bytes).map_err(|error| format!("decode save document: {error}"))?; + if version != PROTOCOL_VERSION { + return Err(format!( + "serializer worker protocol mismatch: expected {PROTOCOL_VERSION}, received {version}" + )); + } + restore_entity_runtime_fields(&mut document, runtime_fields); + Ok((document, ext)) +} + +/// Encode the worker's reply: the serialized drawing bytes, or the error text. +pub fn encode_response(result: Result, String>) -> Result, String> { + bincode::serialize(&(PROTOCOL_VERSION, result)) + .map_err(|error| format!("encode save response: {error}")) +} + +/// Decode the worker's reply on the main thread, rejecting stale-protocol +/// payloads. +pub fn decode_response(bytes: &[u8]) -> Result, String>, String> { + let (version, result): (u16, Result, String>) = + bincode::deserialize(bytes).map_err(|error| format!("decode save response: {error}"))?; + if version != PROTOCOL_VERSION { + return Err(format!( + "serializer worker protocol mismatch: expected {PROTOCOL_VERSION}, received {version}" + )); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::{EntityType, Line, Point}; + use acadrust::types::Vector3; + + fn sample_document() -> CadDocument { + let mut doc = CadDocument::new(); + let mut pt = Point::new(); + pt.common.handle = doc.allocate_handle(); + pt.common.layer = "RUNTIME-FIELDS".to_string(); + // Fill every serde-skipped field with a recognizable value. + pt.common.linetype_handle = Some(acadrust::Handle::new(0xABCD)); + pt.common.color_book_handle = Some(acadrust::Handle::new(0x101)); + pt.common.material_flags = 0b11; + pt.common.material_handle = Some(acadrust::Handle::new(0x202)); + pt.common.plotstyle_flags = 0b11; + pt.common.plotstyle_handle = Some(acadrust::Handle::new(0x303)); + pt.common.shadow_flags = 7; + pt.common.entity_mode = Some(2); + pt.common.has_ds_data = true; + pt.common.graphic_data = Some(vec![0xDE, 0xAD, 0xBE, 0xEF]); + let _ = doc.add_entity(EntityType::Point(pt)); + let mut line = Line::new(); + line.start = Vector3::new(0.0, 0.0, 0.0); + line.end = Vector3::new(10.0, 0.0, 0.0); + line.common.handle = doc.allocate_handle(); + let _ = doc.add_entity(EntityType::Line(line)); + doc + } + + // The sidecar is the whole reason documents don't lose raw DWG storage + // fields when they cross the worker boundary: serde skips them, so + // encode → decode must restore every one. + #[test] + fn request_round_trip_restores_runtime_fields() { + let doc = sample_document(); + let encoded = encode_request(&doc, "dwg").expect("encode"); + let (decoded, ext) = decode_request(&encoded).expect("decode"); + assert_eq!(ext, "dwg"); + assert_eq!(decoded.entities().count(), 2); + let point = decoded + .entities() + .find(|e| matches!(e, EntityType::Point(_))) + .expect("point survived"); + let common = point.common(); + assert_eq!(common.layer, "RUNTIME-FIELDS"); + assert_eq!(common.linetype_handle.map(|h| h.value()), Some(0xABCD)); + assert_eq!( + common.color_book_handle.map(|h| h.value()), + Some(0x101) + ); + assert_eq!(common.material_flags, 0b11); + assert_eq!(common.material_handle.map(|h| h.value()), Some(0x202)); + assert_eq!(common.plotstyle_flags, 0b11); + assert_eq!( + common.plotstyle_handle.map(|h| h.value()), + Some(0x303) + ); + assert_eq!(common.shadow_flags, 7); + assert_eq!(common.entity_mode, Some(2)); + assert!(common.has_ds_data); + assert_eq!(common.graphic_data.as_deref(), Some(&[0xDE, 0xAD, 0xBE, 0xEF][..])); + } + + #[test] + fn response_round_trip_ok_and_err() { + let ok = decode_response(&encode_response(Ok(vec![1, 2, 3])).expect("encode")) + .expect("decode") + .expect("inner ok"); + assert_eq!(ok, vec![1, 2, 3]); + let err = decode_response(&encode_response(Err("writer failed".to_string())).expect("encode")) + .expect("decode") + .expect_err("inner err"); + assert_eq!(err, "writer failed"); + } + + #[test] + fn response_rejects_foreign_protocol_version() { + let stale = + bincode::serialize(&(PROTOCOL_VERSION - 1, Ok::, String>(vec![]))).unwrap(); + assert!(decode_response(&stale).is_err()); + } + + #[test] + fn request_rejects_foreign_protocol_version() { + let stale = bincode::serialize(&( + PROTOCOL_VERSION - 1, + "dwg", + CadDocument::new(), + Vec::::new(), + )) + .unwrap(); + assert!(decode_request(&stale).is_err()); + } + + // The ext dispatch both save paths (inline fallback and worker) share: + // `dxf` must produce an ASCII DXF, anything else the DWG sentinel. + #[test] + fn serialize_document_picks_format_by_extension() { + let mut doc = CadDocument::new(); + doc.version = acadrust::DxfVersion::AC1032; + let dxf = serialize_document(&doc, "dxf").expect("dxf bytes"); + assert!(!dxf.is_empty()); + assert!(dxf.starts_with(b" 0") || dxf.starts_with(b"0\n"), "not a DXF header"); + let dwg = serialize_document(&doc, "dwg").expect("dwg bytes"); + assert!(dwg.starts_with(b"AC10"), "not a DWG sentinel"); + // Case-insensitive: the tab state and save dialog both pass through. + let dwg_upper = serialize_document(&doc, "DWG").expect("dwg bytes"); + assert!(dwg_upper.starts_with(b"AC10")); + } + + // The worker path must serialize to the same bytes the inline fallback + // would — the whole point of sharing this implementation. + #[test] + fn serialize_document_is_deterministic_across_calls() { + let doc = sample_document(); + let a = serialize_document(&doc, "dxf").expect("bytes a"); + let b = serialize_document(&doc, "dxf").expect("bytes b"); + assert_eq!(a, b); + } +} diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 9aa9b8bfc..65f6ec33b 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -14,5 +14,8 @@ serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" getrandom = { version = "0.3", features = ["wasm_js"] } js-sys = "0.3" +# Shared save serialization + worker wire format (one implementation for both +# this worker and the app's inline fallback). +ocs_save = { path = "../ocs_save" } sha2 = "0.10" wasm-bindgen = "0.2" diff --git a/crates/ocs_web_worker/src/lib.rs b/crates/ocs_web_worker/src/lib.rs index a3593b95d..5bd49327b 100644 --- a/crates/ocs_web_worker/src/lib.rs +++ b/crates/ocs_web_worker/src/lib.rs @@ -8,23 +8,11 @@ use sha2::{Digest, Sha256}; use wasm_bindgen::prelude::*; const HASH_MARKER: &str = "\nreport-source-sha256:"; -const PROTOCOL_VERSION: u16 = 4; -#[derive(serde::Serialize)] -struct EntityRuntimeFields { - handle: u64, - linetype_handle: Option, - color_book_handle: Option, - face_visual_style_handle: Option, - edge_visual_style_handle: Option, - material_flags: u8, - material_handle: Option, - shadow_flags: u8, - plotstyle_flags: u8, - plotstyle_handle: Option, - entity_mode: Option, - has_ds_data: bool, -} +/// Version of the shared worker wire format (see `ocs_save`). Both sides of +/// the boundary — this crate and the app's `io::web_worker` — check payloads +/// against it. +const PROTOCOL_VERSION: u16 = ocs_save::PROTOCOL_VERSION; /// Parse DWG/DXF on a dedicated browser worker and return a compact serialized /// document. The main wasm instance only deserializes and installs it, so the @@ -147,6 +135,28 @@ pub fn sha256_document(bytes: Uint8Array) -> String { sha256_document_bytes(&bytes.to_vec()) } +/// Serialize an already-prepared document to DWG/DXF bytes on the dedicated +/// browser worker, so the expensive writer pass (bit-level DWG encoding / +/// ASCII DXF formatting) never occupies the UI thread. `encoded_document` is +/// the shared `ocs_save` request payload (document + runtime-field sidecar + +/// target extension); the reply is the shared response payload (protocol +/// version + `Result`). The main wasm instance only decodes +/// the result — the save-side counterpart of [`parse_document`]. +#[wasm_bindgen] +pub fn serialize_document( + encoded_document: Uint8Array, + report_stage: &Function, +) -> Result { + console_error_panic_hook::set_once(); + report_stage.call1(&JsValue::NULL, &JsValue::from_str("decode document"))?; + let (document, ext) = ocs_save::decode_request(&encoded_document.to_vec())?; + report_stage.call1(&JsValue::NULL, &JsValue::from_str("serialize drawing"))?; + let result = ocs_save::serialize_document(&document, &ext); + report_stage.call1(&JsValue::NULL, &JsValue::from_str("copy output"))?; + let encoded = ocs_save::encode_response(result)?; + Ok(Uint8Array::from(encoded.as_slice())) +} + fn report_fingerprint_needed(stats: &acadrust::ReadStats) -> bool { stats.recovered() || stats.skipped_source_records > 0 || !stats.stream_completed } @@ -178,33 +188,7 @@ fn encode_result( let runtime_fields = result .as_ref() .ok() - .map(|outcome| { - outcome - .document - .entities() - .map(|entity| { - let common = entity.common(); - EntityRuntimeFields { - handle: common.handle.value(), - linetype_handle: common.linetype_handle.map(|handle| handle.value()), - color_book_handle: common.color_book_handle.map(|handle| handle.value()), - face_visual_style_handle: common - .face_visual_style_handle - .map(|handle| handle.value()), - edge_visual_style_handle: common - .edge_visual_style_handle - .map(|handle| handle.value()), - material_flags: common.material_flags, - material_handle: common.material_handle.map(|handle| handle.value()), - shadow_flags: common.shadow_flags, - plotstyle_flags: common.plotstyle_flags, - plotstyle_handle: common.plotstyle_handle.map(|handle| handle.value()), - entity_mode: common.entity_mode, - has_ds_data: common.has_ds_data, - } - }) - .collect::>() - }) + .map(|outcome| ocs_save::extract_entity_runtime_fields(&outcome.document)) .unwrap_or_default(); let encoded = bincode::serialize(&( PROTOCOL_VERSION, diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index efdcb121c..079f34725 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1993,11 +1993,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = تحذير: أُسقط __ocs_fmt_0__ كيان تالف (بيانات تحليل غير صالحة — اتجاهات/أعداد سيئة) .warning-corrupt-xref-entities-dropped = تحذير: أُسقط __ocs_fmt_0__ كيان مرجع خارجي تالف .xref-loaded = XREF حُمّل "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: لا يمكن إنشاء ZIP الإرسال (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: تعذر إنشاء المجلد (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: لا يمكن حزم الرسم (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: جُمّع __ocs_fmt_1__ ملف في __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: تم تخطي __ocs_fmt_1__ ملفًا مرجعيًا غير محمّل في هذه الجلسة: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. تظل فحوص التغيير الخارجي نشطة. .warning-external-change-checks-remain-active = __ocs_fmt_0__. تظل فحوص التغيير الخارجي نشطة. .opening-a-drawing-by-path-is-not-available-in = فتح رسم بواسطة المسار غير متاح في المتصفح. + .pick-the-file-again-to-load-its-contents = اختر الملف مرة أخرى لتحميل محتوياته في المتصفح. + .adding-folders-is-not-available-in-the-browser = إضافة المجلدات غير متاحة في المتصفح. أضف ملفات الرسومات بدلاً من ذلك. + .drawing-not-loaded-in-the-browser = رسم غير محمّل في المتصفح: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ حدد الكائنات (__ocs_arg_count__ محدد، Enter للتطبيق): @@ -3147,6 +3153,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF لا توجد مراجع خارجية في هذا الرسم. .xref-reloaded = XREF أُعيد تحميل "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF احفظ الرسم أولًا لحل مسارات المراجع الخارجية النسبية. + .xref-unresolved-external-references = XREF مراجع خارجية غير محلولة: __ocs_fmt_0__ — أفلت ملفات DWG/DXF المُشار إليها على الصفحة لتسجيلها، ثم أعد فتح هذا الرسم أو نفّذ XRELOAD. .object-s-copied-to-clipboard-base-3-3 = نُسخ __ocs_fmt_0__ كائن إلى الحافظة (الأساس __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = نُسخ __ocs_fmt_0__ كائن إلى الحافظة. .object-s-cut-to-clipboard = قُص __ocs_fmt_0__ كائن إلى الحافظة. diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index cd396b1f6..1bb25ed04 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1980,11 +1980,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Предупреждение: __ocs_fmt_0__ повредени обекта отхвърлени (грешка в анализатора — лоши нормали / брой) .warning-corrupt-xref-entities-dropped = Предупреждение: __ocs_fmt_0__ повредени xref обекта отхвърлени .xref-loaded = XREF Зареден "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: не може да се създаде ZIP за предаване (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: папката не може да бъде създадена (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: чертежът не може да се пакетира (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: пакетирани __ocs_fmt_1__ файл(а) в __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: пропуснати __ocs_fmt_1__ свързани файла, незаредени в тази сесия: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. Проверките за външни промени остават активни. .warning-external-change-checks-remain-active = __ocs_fmt_0__. Проверките за външни промени остават активни. .opening-a-drawing-by-path-is-not-available-in = Отварянето на чертеж по път не е налично в браузъра. + .pick-the-file-again-to-load-its-contents = Изберете файла отново, за да заредите съдържанието му в браузъра. + .adding-folders-is-not-available-in-the-browser = Добавянето на папки не е налично в браузъра. Вместо това добавете файлове с чертежи. + .drawing-not-loaded-in-the-browser = Чертежът не е зареден в браузъра: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Избери обекти (__ocs_arg_count__ избрани, Enter за прилагане): @@ -3134,6 +3140,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF Няма външни референции в този чертеж. .xref-reloaded = XREF Презареден "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Първо запази чертежа, за да се разрешат относителните XREF пътища. + .xref-unresolved-external-references = XREF Неразрешени външни препратки: __ocs_fmt_0__ — плъзнете посочените файлове DWG/DXF върху страницата, за да ги регистрирате, след което отворете чертежа отново или изпълнете XRELOAD. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ обект(а) копирани в клипборда (база __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ обект(а) копирани в клипборда. .object-s-cut-to-clipboard = __ocs_fmt_0__ обект(а) изрязани в клипборда. diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 32a98fe85..63eacc3be 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1980,11 +1980,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Varování: __ocs_fmt_0__ poškozené entity byly odstraněny (nevyžádaná analyzátor – špatné normály / počty) .warning-corrupt-xref-entities-dropped = Varování: __ocs_fmt_0__ poškozené entity externí reference byly zrušeny .xref-loaded = XREF Načteno "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: nelze vytvořit přenosový ZIP (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: Nelze vytvořit složku (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: výkres nelze zabalit (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: zabalené soubory __ocs_fmt_1__ do __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: přeskočeno __ocs_fmt_1__ odkazovaných souborů, které v této relaci nebyly načteny: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. Kontroly externí změny zůstávají aktivní. .warning-external-change-checks-remain-active = __ocs_fmt_0__. Kontroly externí změny zůstávají aktivní. .opening-a-drawing-by-path-is-not-available-in = Otevření výkresu podle cesty není v prohlížeči k dispozici. + .pick-the-file-again-to-load-its-contents = Vyberte soubor znovu, abyste načetli jeho obsah do prohlížeče. + .adding-folders-is-not-available-in-the-browser = Přidávání složek není v prohlížeči k dispozici. Místo toho přidejte soubory výkresů. + .drawing-not-loaded-in-the-browser = Výkres není v prohlížeči načten: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Vyberte objekty (vybráno __ocs_arg_count__, použít Enter): @@ -3132,6 +3138,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF V tomto výkresu nejsou žádné externí odkazy. .xref-reloaded = XREF Znovu načteno "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Nejprve uložte výkres, abyste vyřešili relativní cesty XREF. + .xref-unresolved-external-references = XREF Nevyřešené externí odkazy: __ocs_fmt_0__ — přetáhněte odkazované soubory DWG/DXF na stránku, aby se zaregistrovaly, a poté tento výkres znovu otevřete nebo spusťte XRELOAD. .object-s-copied-to-clipboard-base-3-3 = Objekt(y) __ocs_fmt_0__ zkopírován do schránky (základna __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = Objekt(y) __ocs_fmt_0__ zkopírován do schránky. .object-s-cut-to-clipboard = Objekt(y) __ocs_fmt_0__ vyříznutý do schránky. diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 83a92fea6..b06c24314 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1981,11 +1981,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Warnung: __ocs_fmt_0__ beschädigte Objekte verworfen (ungültige Analysedaten — fehlerhafte Normalen oder Anzahlen) .warning-corrupt-xref-entities-dropped = Warnung: __ocs_fmt_0__ beschädigte XRef-Objekte verworfen .xref-loaded = XREF Geladen "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: das Übertragungs-ZIP kann nicht erstellt werden (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: kann keinen Ordner erstellen (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: die Zeichnung kann nicht gepackt werden (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: verpackte __ocs_fmt_1__ Datei(en) in __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: __ocs_fmt_1__ referenzierte Datei(en) übersprungen, die in dieser Sitzung nicht geladen wurden: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. Externe Wechselprüfungen bleiben aktiv. .warning-external-change-checks-remain-active = __ocs_fmt_0__. Externe Wechselprüfungen bleiben aktiv. .opening-a-drawing-by-path-is-not-available-in = Das Öffnen einer Zeichnung über einen Pfad ist im Browser nicht verfügbar. + .pick-the-file-again-to-load-its-contents = Wähle die Datei erneut aus, um ihren Inhalt in den Browser zu laden. + .adding-folders-is-not-available-in-the-browser = Das Hinzufügen von Ordnern ist im Browser nicht verfügbar. Füge stattdessen Zeichnungsdateien hinzu. + .drawing-not-loaded-in-the-browser = Zeichnung ist nicht im Browser geladen: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Objekte auswählen (__ocs_arg_count__ ausgewählt, Eingabe zum Anwenden): @@ -3133,6 +3139,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF Keine externen Referenzen in dieser Zeichnung. .xref-reloaded = XREF Reloaded "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Speichern Sie zuerst die Zeichnung, um relative XREF Pfade aufzulösen. + .xref-unresolved-external-references = XREF Ungelöste externe Referenzen: __ocs_fmt_0__ — legen Sie die referenzierten DWG/DXF-Dateien auf der Seite ab, um sie zu registrieren, und öffnen Sie dann diese Zeichnung erneut oder führen Sie XRELOAD aus. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ Objekt(e) kopiert ins Zwischenablage (Basis __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ Objekt(e) kopiert ins Clipboard. .object-s-cut-to-clipboard = __ocs_fmt_0__ Objekt(e) in die Zwischenablage geschnitten. diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 77c955d7e..1b8c28c9d 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1980,11 +1980,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Προειδοποίηση: απορρίφθηκαν __ocs_fmt_0__ κατεστραμμένες οντότητες (άχρηστα δεδομένα αναλυτή — εσφαλμένα κάθετα διανύσματα / πλήθη) .warning-corrupt-xref-entities-dropped = Προειδοποίηση: απορρίφθηκαν __ocs_fmt_0__ κατεστραμμένες οντότητες xref .xref-loaded = XREF Φορτώθηκε το "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: δεν είναι δυνατή η δημιουργία του ZIP μεταφοράς (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: δεν είναι δυνατή η δημιουργία φακέλου (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: δεν είναι δυνατή η συσκευασία του σχεδίου (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: συσκευάστηκαν __ocs_fmt_1__ αρχείο(α) στο __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: παραλείφθηκαν __ocs_fmt_1__ αναφερόμενα αρχεία που δεν φορτώθηκαν σε αυτή τη συνεδρία: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. Οι έλεγχοι εξωτερικών αλλαγών παραμένουν ενεργοί. .warning-external-change-checks-remain-active = __ocs_fmt_0__. Οι έλεγχοι εξωτερικών αλλαγών παραμένουν ενεργοί. .opening-a-drawing-by-path-is-not-available-in = Το άνοιγμα σχεδίου μέσω διαδρομής δεν είναι διαθέσιμο στο πρόγραμμα περιήγησης. + .pick-the-file-again-to-load-its-contents = Επιλέξτε ξανά το αρχείο για να φορτώσετε το περιεχόμενό του στο πρόγραμμα περιήγησης. + .adding-folders-is-not-available-in-the-browser = Η προσθήκη φακέλων δεν είναι διαθέσιμη στο πρόγραμμα περιήγησης. Προσθέστε αρχεία σχεδίων αντί για αυτό. + .drawing-not-loaded-in-the-browser = Το σχέδιο δεν έχει φορτωθεί στο πρόγραμμα περιήγησης: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Επιλέξτε αντικείμενα (__ocs_arg_count__ επιλεγμένα, Enter για εφαρμογή): @@ -3142,6 +3148,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF Δεν υπάρχουν εξωτερικές αναφορές σε αυτό το σχέδιο. .xref-reloaded = XREF Επαναφορτώθηκε το "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Αποθηκεύστε πρώτα το σχέδιο για να επιλυθούν οι σχετικές διαδρομές XREF. + .xref-unresolved-external-references = XREF Ανεπίλυτες εξωτερικές αναφορές: __ocs_fmt_0__ — σύρετε τα αναφερόμενα αρχεία DWG/DXF στη σελίδα για να καταχωρηθούν και, στη συνέχεια, ανοίξτε ξανά αυτό το σχέδιο ή εκτελέστε XRELOAD. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ αντικείμενο(α) αντιγράφηκαν στο πρόχειρο (βάση __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ αντικείμενο(α) αντιγράφηκαν στο πρόχειρο. .object-s-cut-to-clipboard = __ocs_fmt_0__ αντικείμενο(α) αποκόπηκαν στο πρόχειρο. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 6423cc288..db51181f5 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1981,11 +1981,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Warning: __ocs_fmt_0__ corrupt entities dropped (parser junk — bad normals / counts) .warning-corrupt-xref-entities-dropped = Warning: __ocs_fmt_0__ corrupt xref entities dropped .xref-loaded = XREF Loaded "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: cannot build the transmittal ZIP (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: cannot create folder (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: cannot package the drawing (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: packaged __ocs_fmt_1__ file(s) into __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: skipped __ocs_fmt_1__ referenced file(s) not loaded in this session: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. External-change checks remain active. .warning-external-change-checks-remain-active = __ocs_fmt_0__. External-change checks remain active. .opening-a-drawing-by-path-is-not-available-in = Opening a drawing by path is not available in the browser. + .pick-the-file-again-to-load-its-contents = Pick the file again to load its contents in the browser. + .adding-folders-is-not-available-in-the-browser = Adding folders is not available in the browser. Add drawing files instead. + .drawing-not-loaded-in-the-browser = Drawing not loaded in the browser: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Select objects (__ocs_arg_count__ selected, Enter to apply): @@ -3143,6 +3149,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF No external references in this drawing. .xref-reloaded = XREF Reloaded "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Save the drawing first to resolve relative XREF paths. + .xref-unresolved-external-references = XREF Unresolved external references: __ocs_fmt_0__ — drop the referenced DWG/DXF files on the page to register them, then reopen this drawing or run XRELOAD. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ object(s) copied to clipboard (base __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ object(s) copied to clipboard. .object-s-cut-to-clipboard = __ocs_fmt_0__ object(s) cut to clipboard. diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 289c4e0ab..777aa674a 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1981,11 +1981,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Advertencia: se descartaron __ocs_fmt_0__ entidades dañadas (datos inválidos del analizador: normales o recuentos erróneos) .warning-corrupt-xref-entities-dropped = Advertencia: se descartaron __ocs_fmt_0__ entidades de referencia externa dañadas .xref-loaded = XREF Se cargó «__ocs_fmt_0__» + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: no se puede crear el ZIP de transmisión (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: no se puede crear la carpeta (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: no se puede empaquetar el dibujo (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: se empaquetaron __ocs_fmt_1__ archivos en __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: se omitieron __ocs_fmt_1__ archivo(s) referenciado(s) no cargado(s) en esta sesión: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. Las comprobaciones de cambios externos siguen activas. .warning-external-change-checks-remain-active = __ocs_fmt_0__. Las comprobaciones de cambios externos siguen activas. .opening-a-drawing-by-path-is-not-available-in = Abrir un dibujo por ruta no está disponible en el navegador. + .pick-the-file-again-to-load-its-contents = Vuelve a elegir el archivo para cargar su contenido en el navegador. + .adding-folders-is-not-available-in-the-browser = Añadir carpetas no está disponible en el navegador. Añade archivos de dibujos en su lugar. + .drawing-not-loaded-in-the-browser = Dibujo no cargado en el navegador: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Seleccione objetos (__ocs_arg_count__ seleccionados, Enter para aplicar): @@ -3135,6 +3141,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF No hay referencias externas en este dibujo. .xref-reloaded = XREF Se volvió a cargar «__ocs_fmt_0__» .xref-save-the-drawing-first-to-resolve-relativ = XREF Guarde primero el dibujo para resolver las rutas relativas de referencias externas. + .xref-unresolved-external-references = XREF Referencias externas sin resolver: __ocs_fmt_0__ — suelte los archivos DWG/DXF de referencia en la página para registrarlos y luego reabra este dibujo o ejecute XRELOAD. .object-s-copied-to-clipboard-base-3-3 = Se copiaron __ocs_fmt_0__ objetos al portapapeles (base __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = Se copiaron __ocs_fmt_0__ objetos al portapapeles. .object-s-cut-to-clipboard = Se cortaron __ocs_fmt_0__ objetos al portapapeles. diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 2dc40b04b..7d12acc2f 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1980,11 +1980,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Varoitus: __ocs_fmt_0__-korruptoituneet entiteetit pudotettiin (jäsennysroska - huonot normaalit / määrät) .warning-corrupt-xref-entities-dropped = Varoitus: __ocs_fmt_0__-korruptoituneet xref-entiteetit putosivat .xref-loaded = XREF Ladattu "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: siirto-ZIP:n luominen epäonnistui (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: kansiota (__ocs_fmt_1__) ei voi luoda. + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: piirustuksen paketointi epäonnistui (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: pakattu __ocs_fmt_1__-tiedosto(t) kohteeseen __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: ohitettiin __ocs_fmt_1__ viitattua tiedostoa, joita ei ladattu tässä istunnossa: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. Ulkoiset muutostarkastukset pysyvät aktiivisina. .warning-external-change-checks-remain-active = __ocs_fmt_0__. Ulkoiset muutostarkastukset pysyvät aktiivisina. .opening-a-drawing-by-path-is-not-available-in = Piirustuksen avaaminen polun mukaan ei ole käytettävissä selaimessa. + .pick-the-file-again-to-load-its-contents = Valitse tiedosto uudelleen lataaksesi sen sisällön selaimeen. + .adding-folders-is-not-available-in-the-browser = Kansioiden lisääminen ei ole käytettävissä selaimessa. Lisää sen sijaan piirustustiedostoja. + .drawing-not-loaded-in-the-browser = Piirustusta ei ole ladattu selaimeen: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Valitse objektit (__ocs_arg_count__ valittu, Enter käytettäväksi): @@ -3134,6 +3140,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF Tässä piirustuksessa ei ole ulkoisia viittauksia. .xref-reloaded = XREF Ladattu uudelleen "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Tallenna piirustus ensin määrittääksesi suhteelliset XREF-polut. + .xref-unresolved-external-references = XREF Ratkaisemattomat ulkoiset viitteet: __ocs_fmt_0__ — pudota viitatut DWG/DXF-tiedostot sivulle rekisteröitäväksi ja avaa tämä piirros uudelleen tai suorita XRELOAD. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ objekti(t) kopioitu leikepöydälle (perus __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ objekti(t) kopioitu leikepöydälle. .object-s-cut-to-clipboard = __ocs_fmt_0__ objekti(t) leikattu leikepöydälle. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index bd496c719..40c9b0dc0 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1981,11 +1981,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Avertissement : __ocs_fmt_0__ objet(s) corrompu(s) ignoré(s) (données d’analyse invalides — normales ou nombres incorrects) .warning-corrupt-xref-entities-dropped = Avertissement : __ocs_fmt_0__ objet(s) de référence externe corrompu(s) ignoré(s) .xref-loaded = XREF Charge "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__ : impossible de créer le ZIP de transmission (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: ne peut pas créer de dossier (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__ : impossible d'empaqueter le dessin (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__ : fichier(s) empaqueté(s) __ocs_fmt_1__ dans __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__ : __ocs_fmt_1__ fichier(s) référencé(s) non chargé(s) dans cette session ignoré(s) : __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. Les vérifications de changement externe demeurent actives. .warning-external-change-checks-remain-active = __ocs_fmt_0__. Les vérifications de changement externe demeurent actives. .opening-a-drawing-by-path-is-not-available-in = L'ouverture d'un dessin par son chemin n'est pas disponible dans le navigateur. + .pick-the-file-again-to-load-its-contents = Choisissez de nouveau le fichier pour charger son contenu dans le navigateur. + .adding-folders-is-not-available-in-the-browser = L'ajout de dossiers n'est pas disponible dans le navigateur. Ajoutez plutôt des fichiers de dessins. + .drawing-not-loaded-in-the-browser = Dessin non chargé dans le navigateur : __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Sélectionner les objets (__ocs_arg_count__ sélectionnés, Entrez pour appliquer) : @@ -3135,6 +3141,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF Aucune référence externe dans ce dessin. .xref-reloaded = XREF Rechargé "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Enregistrez d’abord le dessin afin de résoudre les chemins XREF relatifs. + .xref-unresolved-external-references = XREF Références externes non résolues : __ocs_fmt_0__ — déposez les fichiers DWG/DXF concernés sur la page pour les enregistrer, puis rouvrez ce dessin ou exécutez XRELOAD. .object-s-copied-to-clipboard-base-3-3 = Objets copiés dans le presse-papiers : __ocs_fmt_0__ (point de base __ocs_fmt_1__, __ocs_fmt_2__). .object-s-copied-to-clipboard = Objets copiés dans le presse-papiers : __ocs_fmt_0__. .object-s-cut-to-clipboard = Objets coupés vers le presse-papiers : __ocs_fmt_0__. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index b9d7c5016..96c6f7f7e 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1969,11 +1969,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = चेतावनी: __ocs_fmt_0__ दूषित ऑब्जेक्ट छोड़े गए (अमान्य पार्सर डेटा — गलत नॉर्मल या गिनती) .warning-corrupt-xref-entities-dropped = चेतावनी: __ocs_fmt_0__ दूषित xref ऑब्जेक्ट छोड़े गए .xref-loaded = XREF लोड हो गया "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: ट्रांसमिट ZIP नहीं बनाया जा सकता (__ocs_fmt_1__)। .cmd-cannot-create-folder-e = __ocs_fmt_0__: फ़ोल्डर नहीं बना सकता (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: चित्र को पैकेज नहीं किया जा सकता (__ocs_fmt_1__)। .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: __ocs_fmt_1__ फ़ाइल (s) को __ocs_fmt_2__ में पैक किया गया + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: __ocs_fmt_1__ संदर्भित फ़ाइलें छोड़ दी गईं जो इस सत्र में लोड नहीं हुईं: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. बाहरी परिवर्तन की जाँच सक्रिय रहती है। .warning-external-change-checks-remain-active = __ocs_fmt_0__. बाहरी परिवर्तन की जाँच सक्रिय रहती है। .opening-a-drawing-by-path-is-not-available-in = ब्राउज़र में पथ द्वारा ड्रॉइंग खोलना उपलब्ध नहीं है। + .pick-the-file-again-to-load-its-contents = ब्राउज़र में इसकी सामग्री लोड करने के लिए फ़ाइल को फिर से चुनें। + .adding-folders-is-not-available-in-the-browser = ब्राउज़र में फ़ोल्डर जोड़ना उपलब्ध नहीं है। इसके बजाय ड्रॉइंग फ़ाइलें जोड़ें। + .drawing-not-loaded-in-the-browser = ड्रॉइंग ब्राउज़र में लोड नहीं है: __ocs_fmt_0__। selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ ऑब्जेक्ट चुनें (__ocs_arg_count__ चयनित, लागू करने के लिए Enter दबाएँ): @@ -3123,6 +3129,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF इस ड्रॉइंग में कोई बाहरी संदर्भ नहीं है। .xref-reloaded = XREF “__ocs_fmt_0__” पुनः लोड किया गया .xref-save-the-drawing-first-to-resolve-relativ = XREF सापेक्ष पथ निर्धारित करने के लिए पहले ड्रॉइंग सहेजें। + .xref-unresolved-external-references = XREF अनसुलझे बाहरी संदर्भ: __ocs_fmt_0__ — उन्हें पंजीकृत करने के लिए संदर्भित DWG/DXF फ़ाइलों को पृष्ठ पर ड्रॉप करें, फिर इस चित्र को फिर से खोलें या XRELOAD चलाएँ। .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ ऑब्जेक्ट (s) क्लिपबोर्ड (बेस __ocs_fmt_1__, __ocs_fmt_2__) को कॉपी किया गया। .object-s-copied-to-clipboard = __ocs_fmt_0__ ऑब्जेक्ट (s) क्लिपबोर्ड पर कॉपी किया गया। .object-s-cut-to-clipboard = __ocs_fmt_0__ ऑब्जेक्ट क्लिपबोर्ड पर काटे गए। diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 5ae3d0aea..46235c51d 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1980,11 +1980,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Figyelmeztetés: A __ocs_fmt_0__ korrupt entitások kimaradtak (szemét elemző – rossz normálértékek/számítások) .warning-corrupt-xref-entities-dropped = Figyelmeztetés: A __ocs_fmt_0__ korrupt xref entitások lemaradtak .xref-loaded = XREF betöltve: "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: nem hozható létre az átviteli ZIP (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: nem hozható létre mappa (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: a rajz nem csomagolható (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: __ocs_fmt_1__ fájl(ok) a __ocs_fmt_2__ mappába csomagolva + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: __ocs_fmt_1__ hivatkozott fájl kihagyva, amely nem lett betöltve ebben a munkamenetben: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. A külső változásellenőrzések aktívak maradnak. .warning-external-change-checks-remain-active = __ocs_fmt_0__. A külső változásellenőrzések aktívak maradnak. .opening-a-drawing-by-path-is-not-available-in = A rajz útvonal szerinti megnyitása nem érhető el a böngészőben. + .pick-the-file-again-to-load-its-contents = Válaszd ki újra a fájlt a tartalmának böngészőbe töltéséhez. + .adding-folders-is-not-available-in-the-browser = Mappák hozzáadása nem érhető el a böngészőben. Ehelyett rajzfájlokat adj hozzá. + .drawing-not-loaded-in-the-browser = A raj nincs betöltve a böngészőben: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Objektumok kiválasztása (__ocs_arg_count__ kiválasztva, Enter alkalmazni): @@ -3134,6 +3140,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF Nincsenek külső hivatkozások ezen a rajzon. .xref-reloaded = XREF újratöltve "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Először mentse a rajzot a relatív XREF útvonalak feloldásához. + .xref-unresolved-external-references = XREF Megoldatlan külső hivatkozások: __ocs_fmt_0__ — húzza a hivatkozott DWG/DXF-fájlokat az oldalra a regisztrálásukhoz, majd nyissa meg újra ezt a rajzot, vagy futtassa az XRELOAD parancsot. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ objektum(ok) a vágólapra másolva (alap __ocs_fmt_1__, __ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ objektum(ok) a vágólapra másolva. .object-s-cut-to-clipboard = __ocs_fmt_0__ objektum(ok) vágólapra vágva. diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index dd569ffa0..5f64f3020 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1980,11 +1980,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Avviso: entità corrotte __ocs_fmt_0__ eliminate (spazzatura del parser: valori normali/conteggi errati) .warning-corrupt-xref-entities-dropped = Avviso: entità xrif corrotte __ocs_fmt_0__ eliminate .xref-loaded = XREF Caricato "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: impossibile creare il ZIP di trasmissione (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: impossibile creare la cartella (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: impossibile pacchettizzare il disegno (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: file __ocs_fmt_1__ compressi in __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: saltati __ocs_fmt_1__ file riferiti non caricati in questa sessione: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. I controlli di modifica esterna rimangono attivi. .warning-external-change-checks-remain-active = __ocs_fmt_0__. I controlli di modifica esterna rimangono attivi. .opening-a-drawing-by-path-is-not-available-in = L'apertura di un disegno per percorso non è disponibile nel browser. + .pick-the-file-again-to-load-its-contents = Scegli di nuovo il file per caricarne il contenuto nel browser. + .adding-folders-is-not-available-in-the-browser = L'aggiunta di cartelle non è disponibile nel browser. Aggiungi invece file di disegni. + .drawing-not-loaded-in-the-browser = Disegno non caricato nel browser: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Seleziona oggetti (__ocs_arg_count__ selezionato, Enter da applicare): @@ -3134,6 +3140,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF Nessun riferimento esterno in questo disegno. .xref-reloaded = XREF Ricaricato "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Salvare prima il disegno per risolvere i percorsi XREF relativi. + .xref-unresolved-external-references = XREF Riferimenti esterni non risolti: __ocs_fmt_0__ — trascina sulla pagina i file DWG/DXF di riferimento per registrarli, poi riapri questo disegno o esegui XRELOAD. .object-s-copied-to-clipboard-base-3-3 = Oggetto/i __ocs_fmt_0__ copiati negli appunti (base __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ oggetto/i copiati negli appunti. .object-s-cut-to-clipboard = __ocs_fmt_0__ oggetto/i tagliato negli appunti. diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index aae3a7e69..1151a0476 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1971,11 +1971,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = 警告: __ocs_fmt_0__ 個の破損した図形を除外しました(パーサーの不正データ — 法線 / 数の異常) .warning-corrupt-xref-entities-dropped = 警告: __ocs_fmt_0__ 個の破損した外部参照図形を除外しました .xref-loaded = XREF 「__ocs_fmt_0__」をロードしました + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: 転送 ZIP を作成できません(__ocs_fmt_1__)。 .cmd-cannot-create-folder-e = __ocs_fmt_0__: フォルダーを作成できません(__ocs_fmt_1__)。 + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: 図面をパッケージ化できません(__ocs_fmt_1__)。 .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: __ocs_fmt_1__ 個のファイルを __ocs_fmt_2__ にパッケージ化しました + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: このセッションで読み込まれていない参照ファイル __ocs_fmt_1__ 個をスキップしました: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__。外部変更の確認は引き続き有効です。 .warning-external-change-checks-remain-active = __ocs_fmt_0__。外部変更の確認は引き続き有効です。 .opening-a-drawing-by-path-is-not-available-in = ブラウザーではパスを指定して図面を開くことはできません。 + .pick-the-file-again-to-load-its-contents = ブラウザーに内容を読み込むには、ファイルをもう一度選択してください。 + .adding-folders-is-not-available-in-the-browser = ブラウザーではフォルダーを追加できません。代わりに図面ファイルを追加してください。 + .drawing-not-loaded-in-the-browser = 図面がブラウザーに読み込まれていません: __ocs_fmt_0__。 selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ オブジェクトを選択(__ocs_arg_count__ 個選択、Enter で適用): @@ -3123,6 +3129,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF この図面には外部参照がありません。 .xref-reloaded = XREF 「__ocs_fmt_0__」を再ロードしました .xref-save-the-drawing-first-to-resolve-relativ = XREF 相対 XREF パスを解決するには、先に図面を保存してください。 + .xref-unresolved-external-references = XREF 未解決の外部参照: __ocs_fmt_0__ — 参照先の DWG/DXF ファイルをページ上にドロップして登録し、この図面を開き直すか XRELOAD を実行してください。 .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ 個のオブジェクトをクリップボードにコピーしました(基点 __ocs_fmt_1__,__ocs_fmt_2__)。 .object-s-copied-to-clipboard = __ocs_fmt_0__ 個のオブジェクトをクリップボードにコピーしました。 .object-s-cut-to-clipboard = __ocs_fmt_0__ 個のオブジェクトを切り取ってクリップボードに保存しました。 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 85b09ebd9..29348e8bf 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1980,11 +1980,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = 경고: __ocs_fmt_0__ 손상된 엔터티가 삭제됨(파서 정크 - 잘못된 노멀/카운트) .warning-corrupt-xref-entities-dropped = 경고: __ocs_fmt_0__ 손상된 외부 참조 엔티티가 삭제되었습니다. .xref-loaded = XREF가 "__ocs_fmt_0__"를 로드했습니다. + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: 전송 ZIP을 만들 수 없습니다(__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: 폴더(__ocs_fmt_1__)를 생성할 수 없습니다. + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: 도면을 패키지할 수 없습니다(__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: __ocs_fmt_1__ 파일을 __ocs_fmt_2__로 패키징했습니다. + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: 이 세션에서 로드되지 않은 참조 파일 __ocs_fmt_1__개를 건너뛰었습니다: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. 외부 변경 검사는 활성 상태로 유지됩니다. .warning-external-change-checks-remain-active = __ocs_fmt_0__. 외부 변경 검사는 활성 상태로 유지됩니다. .opening-a-drawing-by-path-is-not-available-in = 브라우저에서는 경로로 도면을 열 수 없습니다. + .pick-the-file-again-to-load-its-contents = 콘텐츠를 브라우저에 로드하려면 파일을 다시 선택하세요. + .adding-folders-is-not-available-in-the-browser = 브라우저에서는 폴더를 추가할 수 없습니다. 대신 도면 파일을 추가하세요. + .drawing-not-loaded-in-the-browser = 도면이 브라우저에 로드되지 않았습니다: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 객체 선택(__ocs_arg_count__ 선택, Enter 적용): @@ -3134,6 +3140,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF 이 도면에는 외부 참조가 없습니다. .xref-reloaded = XREF가 "__ocs_fmt_0__"를 다시 로드했습니다. .xref-save-the-drawing-first-to-resolve-relativ = XREF 상대 XREF 경로를 확인하려면 먼저 도면을 저장하십시오. + .xref-unresolved-external-references = XREF 확인되지 않은 외부 참조: __ocs_fmt_0__ — 참조된 DWG/DXF 파일을 페이지에 놓아 등록한 다음 이 도면을 다시 열거나 XRELOAD를 실행하세요. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ 개체가 클립보드에 복사되었습니다(기본 __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ 개체가 클립보드에 복사되었습니다. .object-s-cut-to-clipboard = __ocs_fmt_0__ 개체를 클립보드로 잘라냈습니다. diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 669989c59..d37495e40 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1981,11 +1981,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Waarschuwing: __ocs_fmt_0__ beschadigde objecten overgeslagen (ongeldige parsergegevens — foutieve normalen of aantallen) .warning-corrupt-xref-entities-dropped = Waarschuwing: __ocs_fmt_0__ beschadigde xref-objecten overgeslagen .xref-loaded = XREF geladen "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: kan de transmissie-ZIP niet maken (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: kan map (__ocs_fmt_1__) niet aanmaken. + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: kan de tekening niet inpakken (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: verpakt __ocs_fmt_1__ bestand(s) in __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: __ocs_fmt_1__ gerefereerde bestand(en) overgeslagen die niet in deze sessie zijn geladen: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. Externe wijzigingen blijven actief. .warning-external-change-checks-remain-active = __ocs_fmt_0__. Externe wijzigingen blijven actief. .opening-a-drawing-by-path-is-not-available-in = Een tekening openen via een pad is niet beschikbaar in de browser. + .pick-the-file-again-to-load-its-contents = Kies het bestand opnieuw om de inhoud ervan in de browser te laden. + .adding-folders-is-not-available-in-the-browser = Mappen toevoegen is niet beschikbaar in de browser. Voeg in plaats daarvan tekeningbestanden toe. + .drawing-not-loaded-in-the-browser = Tekening is niet geladen in de browser: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Selecteer objecten (__ocs_arg_count__ geselecteerd, Voer in om toe te passen): @@ -3133,6 +3139,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF Geen externe referenties in deze tekening. .xref-reloaded = XREF Herladen "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Sla de tekening eerst op om relatieve XREF paden op te lossen. + .xref-unresolved-external-references = XREF Onopgeloste externe referenties: __ocs_fmt_0__ — sleep de bedoelde DWG/DXF-bestanden naar de pagina om ze te registreren en open deze tekening daarna opnieuw of voer XRELOAD uit. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ object(en) naar het klembord gekopieerd (basispunt __ocs_fmt_1__, __ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ object(en) naar het klembord gekopieerd. .object-s-cut-to-clipboard = __ocs_fmt_0__ object(en) naar het klembord geknipt. diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 3baf7383c..dd046d9ca 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1980,11 +1980,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Ostrzeżenie: usunięte zostały uszkodzone jednostki __ocs_fmt_0__ (śmieci parsera — złe wartości normalne/liczby) .warning-corrupt-xref-entities-dropped = Ostrzeżenie: usunięte zostały uszkodzone elementy odnośników zewnętrznych __ocs_fmt_0__ .xref-loaded = XREF Załadowano „__ocs_fmt_0__” + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: nie można utworzyć archiwum ZIP transmittalu (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: nie można utworzyć folderu (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: nie można spakować rysunku (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: spakowane pliki __ocs_fmt_1__ do __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: pominięto __ocs_fmt_1__ przywołanych plików, niezaładowanych w tej sesji: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. Kontrole zmian zewnętrznych pozostają aktywne. .warning-external-change-checks-remain-active = __ocs_fmt_0__. Kontrole zmian zewnętrznych pozostają aktywne. .opening-a-drawing-by-path-is-not-available-in = Otwieranie rysunku po ścieżce nie jest dostępne w przeglądarce. + .pick-the-file-again-to-load-its-contents = Wybierz plik ponownie, aby załadować jego zawartość do przeglądarki. + .adding-folders-is-not-available-in-the-browser = Dodawanie folderów nie jest dostępne w przeglądarce. Zamiast tego dodaj pliki rysunków. + .drawing-not-loaded-in-the-browser = Rysunek nie został załadowany w przeglądarce: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Wybierz obiekty (wybrano __ocs_arg_count__, zastosowano Enter): @@ -3134,6 +3140,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF Brak zewnętrznych odniesień na tym rysunku. .xref-reloaded = XREF Załadowano ponownie „__ocs_fmt_0__” .xref-save-the-drawing-first-to-resolve-relativ = XREF Zapisz najpierw rysunek, aby ustalić względne ścieżki XREF. + .xref-unresolved-external-references = XREF Nierozwiązane odniesienia zewnętrzne: __ocs_fmt_0__ — upuść przywoływane pliki DWG/DXF na stronę, aby je zarejestrować, a następnie otwórz ponownie ten rysunek lub uruchom XRELOAD. .object-s-copied-to-clipboard-base-3-3 = Obiekt(y) __ocs_fmt_0__ skopiowano do schowka (bazowe __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = Obiekt(y) __ocs_fmt_0__ skopiowano do schowka. .object-s-cut-to-clipboard = Obiekt(y) __ocs_fmt_0__ wycięty do schowka. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 363dccaae..471882316 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1981,11 +1981,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Aviso: __ocs_fmt_0__ entidades corrompidas descartadas (dados inválidos do analisador — normais/contagens incorretas) .warning-corrupt-xref-entities-dropped = Aviso: __ocs_fmt_0__ entidades de referência externa corrompidas descartadas .xref-loaded = XREF “__ocs_fmt_0__” carregada + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: não foi possível criar o ZIP de transmissão (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: não é possível criar a pasta (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: não foi possível empacotar o desenho (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: __ocs_fmt_1__ arquivos empacotados em __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: __ocs_fmt_1__ arquivo(s) referenciado(s) não carregado(s) nesta sessão foram ignorados: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. As verificações de alterações externas permanecem ativas. .warning-external-change-checks-remain-active = __ocs_fmt_0__. As verificações de alterações externas permanecem ativas. .opening-a-drawing-by-path-is-not-available-in = Abrir um desenho pelo caminho não está disponível no navegador. + .pick-the-file-again-to-load-its-contents = Escolha o arquivo novamente para carregar o conteúdo dele no navegador. + .adding-folders-is-not-available-in-the-browser = Adicionar pastas não está disponível no navegador. Em vez disso, adicione arquivos de desenhos. + .drawing-not-loaded-in-the-browser = Desenho não carregado no navegador: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Selecione objetos (__ocs_arg_count__ selecionados, Enter para aplicar): @@ -3135,6 +3141,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF Nenhuma referência externa neste desenho. .xref-reloaded = XREF “__ocs_fmt_0__” recarregada .xref-save-the-drawing-first-to-resolve-relativ = XREF Salve primeiro o desenho para resolver os caminhos relativos das referências externas. + .xref-unresolved-external-references = XREF Referências externas não resolvidas: __ocs_fmt_0__ — solte os arquivos DWG/DXF referenciados na página para registrá-los e depois reabra este desenho ou execute XRELOAD. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ objetos copiados para a área de transferência (base __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ objetos copiados para a área de transferência. .object-s-cut-to-clipboard = __ocs_fmt_0__ objetos recortados para a área de transferência. diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 944460b15..6ad61985b 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1984,11 +1984,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Предупреждение: повреждённые объекты пропущены: __ocs_fmt_0__ (ошибочные данные анализатора — неверные нормали или количества) .warning-corrupt-xref-entities-dropped = Предупреждение: повреждённые объекты внешней ссылки пропущены: __ocs_fmt_0__ .xref-loaded = XREF Загруженный "__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: не удалось создать ZIP-пакет передачи (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: не может создавать папку (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: не удалось упаковать чертёж (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: упакованный файл(ы) __ocs_fmt_1__ в __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: пропущено __ocs_fmt_1__ связанных файлов, не загруженных в этом сеансе: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__ Проверки внешних изменений остаются активными. .warning-external-change-checks-remain-active = __ocs_fmt_0__ Проверки внешних изменений остаются активными. .opening-a-drawing-by-path-is-not-available-in = Открытие чертежа по пути недоступно в браузере. + .pick-the-file-again-to-load-its-contents = Выберите файл ещё раз, чтобы загрузить его содержимое в браузер. + .adding-folders-is-not-available-in-the-browser = Добавление папок в браузере недоступно. Вместо этого добавьте файлы чертежей. + .drawing-not-loaded-in-the-browser = Чертёж не загружен в браузере: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Выберите объекты (__ocs_arg_count__ Выберите, Введите для применения): @@ -3136,6 +3142,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF В этом чертеже нет внешних ссылок. .xref-reloaded = XREF Перезагрузка "__ocs_fmt_0__" .xref-save-the-drawing-first-to-resolve-relativ = XREF Сначала сохраните чертёж, чтобы определить относительные пути внешних ссылок. + .xref-unresolved-external-references = XREF Неразрешённые внешние ссылки: __ocs_fmt_0__ — перетащите указанные файлы DWG/DXF на страницу, чтобы зарегистрировать их, затем снова откройте этот чертёж или выполните XRELOAD. .object-s-copied-to-clipboard-base-3-3 = В буфер обмена скопировано объектов: __ocs_fmt_0__ (базовая точка: __ocs_fmt_1__, __ocs_fmt_2__). .object-s-copied-to-clipboard = В буфер обмена скопировано объектов: __ocs_fmt_0__. .object-s-cut-to-clipboard = В буфер обмена вырезано объектов: __ocs_fmt_0__. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 8b9e60e2a..d0a8bf12f 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1964,11 +1964,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = Uyarı: __ocs_fmt_0__ bozuk nesne atıldı (ayrıştırıcı artığı — hatalı normaller / sayılar) .warning-corrupt-xref-entities-dropped = Uyarı: __ocs_fmt_0__ bozuk dış referans nesnesi atıldı .xref-loaded = XREF "__ocs_fmt_0__" yüklendi + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: aktarım ZIP'i oluşturulamıyor (__ocs_fmt_1__). .cmd-cannot-create-folder-e = __ocs_fmt_0__: Klasör oluşturulamıyor (__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: çizim paketlenemiyor (__ocs_fmt_1__). .cmd-packaged-copied-file-s-into = __ocs_fmt_0__: __ocs_fmt_1__ dosya __ocs_fmt_2__ içine paketlendi + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: bu oturumda yüklenmemiş __ocs_fmt_1__ başvurulan dosya atlandı: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. Dış değişiklik denetimleri etkin kalır. .warning-external-change-checks-remain-active = __ocs_fmt_0__. Dış değişiklik denetimleri etkin kalır. .opening-a-drawing-by-path-is-not-available-in = Tarayıcıda dosya yoluyla çizim açılamaz. + .pick-the-file-again-to-load-its-contents = İçeriğini tarayıcıya yüklemek için dosyayı yeniden seçin. + .adding-folders-is-not-available-in-the-browser = Tarayıcıda klasör ekleme kullanılamıyor. Bunun yerine çizim dosyaları ekleyin. + .drawing-not-loaded-in-the-browser = Çizim tarayıcıya yüklenmedi: __ocs_fmt_0__. selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ Nesneleri seçin (__ocs_arg_count__ seçili, uygulamak için Enter): @@ -3102,6 +3108,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF Bu çizimde dış referans yok. .xref-reloaded = XREF "__ocs_fmt_0__" yeniden yüklendi .xref-save-the-drawing-first-to-resolve-relativ = XREF Göreli XREF yollarını çözümlemek için önce çizimi kaydedin. + .xref-unresolved-external-references = XREF Çözümlenemeyen harici referanslar: __ocs_fmt_0__ — referans verilen DWG/DXF dosyalarını sayfaya bırakarak kaydedin, sonra bu çizimi yeniden açın veya XRELOAD komutunu çalıştırın. .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ nesne panoya kopyalandı (taban __ocs_fmt_1__,__ocs_fmt_2__). .object-s-copied-to-clipboard = __ocs_fmt_0__ nesne panoya kopyalandı. .object-s-cut-to-clipboard = __ocs_fmt_0__ nesne panoya kesildi. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 338c447b9..12eedff9d 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1968,11 +1968,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = 警告:已丢弃 __ocs_fmt_0__ 个损坏对象(解析数据无效——法线或计数错误) .warning-corrupt-xref-entities-dropped = 警告:已丢弃 __ocs_fmt_0__ 个损坏的 xref 对象 .xref-loaded = XREF已装入"__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: 无法创建传输 ZIP(__ocs_fmt_1__)。 .cmd-cannot-create-folder-e = __ocs_fmt_0__:无法创建文件夹(__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: 无法打包图形(__ocs_fmt_1__)。 .cmd-packaged-copied-file-s-into = __ocs_fmt_0__:已将 __ocs_fmt_1__文件打包入 __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: 已跳过 __ocs_fmt_1__ 个本次会话中未加载的参照文件: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. 对外变更检查仍在进行中。 .warning-external-change-checks-remain-active = __ocs_fmt_0__. 对外变更检查仍在进行中。 .opening-a-drawing-by-path-is-not-available-in = 浏览器不支持通过路径打开图形。 + .pick-the-file-again-to-load-its-contents = 请重新选择文件以将其内容加载到浏览器中。 + .adding-folders-is-not-available-in-the-browser = 浏览器中无法添加文件夹。请改为添加图形文件。 + .drawing-not-loaded-in-the-browser = 图形未加载到浏览器中:__ocs_fmt_0__。 selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 选择对象(已选择 __ocs_arg_count__ 个,按 Enter 应用): @@ -3118,6 +3124,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF 此绘图中没有外部引用 。 .xref-reloaded = XREF 重新装入了 “ __ocs_fmt_0__ ” .xref-save-the-drawing-first-to-resolve-relativ = XREF 先保存绘图以解析相亲的XREF路径 。 + .xref-unresolved-external-references = XREF 未解析的外部参照:__ocs_fmt_0__ —— 将参照的 DWG/DXF 文件拖放到页面上以注册它们,然后重新打开此图形或运行 XRELOAD。 .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ 对象已复制到剪贴板( 基准 __ocs_fmt_1__,__ocs_fmt_2__) 。 .object-s-copied-to-clipboard = __ocs_fmt_0__ 对象被复制到剪贴板。 .object-s-cut-to-clipboard = __ocs_fmt_0__ 对象切入剪贴板。 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 05179ce86..9ad2d8bf7 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1980,11 +1980,17 @@ file = .warning-corrupt-entities-dropped-parser-junk-b = 警告:已丟棄 __ocs_fmt_0__ 個損壞物件(解析資料無效——法線或計數錯誤) .warning-corrupt-xref-entities-dropped = 警告:已丟棄 __ocs_fmt_0__ 個損壞的 xref 物件 .xref-loaded = XREF已載入"__ocs_fmt_0__" + .cmd-cannot-build-the-transmittal-zip-e = __ocs_fmt_0__: 無法建立傳送 ZIP(__ocs_fmt_1__)。 .cmd-cannot-create-folder-e = __ocs_fmt_0__:無法建立資料夾(__ocs_fmt_1__). + .cmd-cannot-package-the-drawing-e = __ocs_fmt_0__: 無法打包圖面(__ocs_fmt_1__)。 .cmd-packaged-copied-file-s-into = __ocs_fmt_0__:已將 __ocs_fmt_1__檔案打包入 __ocs_fmt_2__ + .cmd-skipped-n-referenced-file-s-not-loaded-in = __ocs_fmt_0__: 已跳過 __ocs_fmt_1__ 個本次工作階段中未載入的參考檔案: __ocs_fmt_2__ .error-external-change-checks-remain-active = __ocs_fmt_0__. 對外變更檢查仍在進行中。 .warning-external-change-checks-remain-active = __ocs_fmt_0__. 對外變更檢查仍在進行中。 .opening-a-drawing-by-path-is-not-available-in = 瀏覽器不支援通過路徑開啟圖形。 + .pick-the-file-again-to-load-its-contents = 請重新選擇檔案,將其內容載入瀏覽器。 + .adding-folders-is-not-available-in-the-browser = 瀏覽器中無法新增資料夾。請改為新增圖面檔案。 + .drawing-not-loaded-in-the-browser = 圖面未載入瀏覽器:__ocs_fmt_0__。 selection = .cmd-select-objects-count-selected-enter-to-app = __ocs_arg_cmd__ 選擇物件(已選擇 __ocs_arg_count__ 個,按 Enter 套用): @@ -3130,6 +3136,7 @@ insert = .xref-no-external-references-in-this-drawing = XREF 此繪圖中沒有外部引用 。 .xref-reloaded = XREF 重新載入了 “ __ocs_fmt_0__ ” .xref-save-the-drawing-first-to-resolve-relativ = XREF 先儲存繪圖以解析相親的XREF路徑 。 + .xref-unresolved-external-references = XREF 未解析的外部參考: __ocs_fmt_0__ — 將參考的 DWG/DXF 檔案拖放到頁面上以註冊它們,然後重新開啟此圖面或執行 XRELOAD。 .object-s-copied-to-clipboard-base-3-3 = __ocs_fmt_0__ 物件已複製到剪貼簿( 基準 __ocs_fmt_1__,__ocs_fmt_2__) 。 .object-s-copied-to-clipboard = __ocs_fmt_0__ 物件被複製到剪貼簿。 .object-s-cut-to-clipboard = __ocs_fmt_0__ 物件切入剪貼簿。 diff --git a/src/app/annotation_data.rs b/src/app/annotation_data.rs index 725aa226f..7b8a4457d 100644 --- a/src/app/annotation_data.rs +++ b/src/app/annotation_data.rs @@ -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 acadrust::entities::{table::CellRange, TableBuilder}; use acadrust::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>>> { + static SOURCES: OnceLock>>>> = OnceLock::new(); + SOURCES.get_or_init(|| Mutex::new(HashMap::new())) +} + +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn extraction_documents() -> &'static Mutex>> { + static DOCUMENTS: OnceLock>>> = 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>) { + 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>> { + 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> { + 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 { 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, @@ -171,6 +271,12 @@ fn workbook_metadata(path: &Path) -> Result<(Vec, Vec), 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(); @@ -182,6 +288,20 @@ fn workbook_metadata(path: &Path) -> Result<(Vec, Vec), 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, Vec), 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('!')?; @@ -193,6 +313,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 { + 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( + workbook: &mut calamine::Sheets, + sheet: Option<&str>, + range_kind: LinkRange, + range_value: Option<&str>, +) -> Result<(Vec>, Option), 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>, @@ -204,52 +384,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().filter(|value| !value.trim().is_empty()) { Some(value) => crop_range(rows, value)?, @@ -429,12 +581,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, @@ -461,6 +615,7 @@ fn collect_drawing_paths( Ok(()) } +#[cfg(not(target_arch = "wasm32"))] fn extraction_drawing_paths(state: &DataExtractionState) -> Result, String> { let mut paths = Vec::new(); for source in &state.source_files { @@ -471,6 +626,36 @@ fn extraction_drawing_paths(state: &DataExtractionState) -> Result, Ok(paths) } +/// Source drawings the extraction pipeline should read beyond the current +/// one. Native loads them from disk (folders walked recursively); 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 extraction_documents_for( + state: &DataExtractionState, +) -> Result)>, String> { + #[cfg(target_arch = "wasm32")] + { + let mut documents = Vec::new(); + 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() + })?; + documents.push((source.clone(), document)); + } + Ok(documents) + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut documents = Vec::new(); + for source in extraction_drawing_paths(state)? { + let document = crate::io::load_file(&source) + .map_err(|error| format!("{}: {error}", source.display()))?; + documents.push((source.to_string_lossy().into_owned(), Arc::new(document))); + } + Ok(documents) + } +} + fn collect_type_counts( doc: &CadDocument, allowed_handles: Option<&BTreeSet>, @@ -500,9 +685,8 @@ fn extraction_preview(state: &DataExtractionState, current: &CadDocument) -> Res 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 (_, document) in extraction_documents_for(state)? { + collect_records(&document, None, &allowed_types, &mut records); } } } @@ -598,11 +782,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() { Some("currentSelection") => ExtractionSource::CurrentSelection, @@ -878,13 +1076,22 @@ impl OpenCADStudio { pub(super) fn on_data_link_browse(&mut self) -> Task { 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(), &["csv", "txt", "xls", "xlsx", "xlsb", "ods"]) .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, ) @@ -1125,10 +1332,8 @@ 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 (_, document) in extraction_documents_for(&state)? { + collect_type_counts(&document, None, &mut counts); } } } @@ -1253,11 +1458,23 @@ impl OpenCADStudio { let save = self.data_extraction.begin == ExtractionBegin::New; Task::perform(async move { let dialog = crate::sys::file_dialog().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 Some(handle) = handle else { + return None; + }; + 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) } @@ -1275,9 +1492,10 @@ impl OpenCADStudio { Task::none() } + #[cfg(not(target_arch = "wasm32"))] pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task { 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(), &["dwg", "dxf", "DWG", "DXF"]) .add_filter(crate::t!("All Files").as_ref(), &["*"]) @@ -1286,10 +1504,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 { + 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!("{}: {error}", name))?; + register_extraction_document(&name, document); + paths.push(PathBuf::from(name)); + } + Ok(paths) + }, + Message::DataExtractionAddDrawingsResult, + ) + } + pub(super) fn on_data_extraction_add_drawings_result(&mut self, paths: Vec) -> Task { for path in paths { let text = path.to_string_lossy().into_owned(); @@ -1379,6 +1629,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(); @@ -1451,4 +1713,47 @@ mod tests { ] ); } + + #[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()); + } } diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index a0f5d2583..63187c8aa 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -571,13 +571,29 @@ impl OpenCADStudio { } "XRELOAD" => { - // Reload all xrefs for the current drawing. - if let Some(path) = &self.tabs[i].current_path.clone() { - if let Some(base_dir) = path.parent() { + // Reload all xrefs for the current drawing. 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 = Some(std::path::PathBuf::new()); + #[cfg(not(target_arch = "wasm32"))] + let base_dir: Option = self + .tabs[i] + .current_path + .clone() + .and_then(|path| path.parent().map(std::path::Path::to_path_buf)); + if let Some(base_dir) = base_dir { + { let (infos, _dropped) = crate::io::xref::resolve_xrefs( &mut self.tabs[i].scene.document, - base_dir, + &base_dir, ); + // Web: unresolved references surface once, with the + // drop hint, instead of a filesystem path no browser + // can open. + #[cfg(target_arch = "wasm32")] + let mut unresolved: Vec = Vec::new(); for info in &infos { match info.status { crate::io::xref::XrefStatus::Loaded => { @@ -590,12 +606,17 @@ impl OpenCADStudio { info.name ).as_ref()); } + #[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 => { + unresolved.push(info.name.clone()); + } crate::io::xref::XrefStatus::Failed => { self.command_line.push_error(crate::tf!( "XREF Reload failed: \"{}\" ({})", @@ -610,6 +631,16 @@ impl OpenCADStudio { } } } + #[cfg(target_arch = "wasm32")] + if !unresolved.is_empty() { + 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(), + ); + } self.tabs[i].scene.populate_hatches_from_document(); self.tabs[i].scene.populate_images_from_document(); self.tabs[i].scene.populate_meshes_from_document(); diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 2b1f59fe1..8b19b4d9d 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -148,72 +148,81 @@ impl OpenCADStudio { // ARCHIVE / ETRANSMIT — package the drawing and its referenced files // (xrefs + raster images) into a sibling "_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 + // "_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 = 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 acadrust::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 = 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 acadrust::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" => { @@ -499,3 +508,186 @@ 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) { + use crate::io::zip::ZipEntry; + + 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 drawing = match crate::io::save_to_bytes(&self.tabs[i].scene.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 = vec![ZipEntry::new(filename, drawing)]; + let mut skipped: Vec = Vec::new(); + + // Xrefs — the block-record walk native copies. No xref bytes store + // exists on the web build (the file behind the path never entered + // the session), so every reference reports as skipped. + for br in self.tabs[i].scene.document.block_records.iter() { + if !br.xref_path.trim().is_empty() { + 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 acadrust::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 acadrust::objects::ObjectType::UnderlayDefinition(def) = o { + if !matches!(def.underlay_type, acadrust::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 = self.tabs[i] + .scene + .document + .objects + .values() + .filter_map(|o| { + if let acadrust::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 crate::io::zip::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, name: String, data: Vec) { + if let Some(slot) = entries + .iter_mut() + .find(|entry| entry.name.eq_ignore_ascii_case(&name)) + { + slot.data = data; + } else { + entries.push(crate::io::zip::ZipEntry::new(name, data)); + } +} + +/// Remember a missing dependency for the report, once per name. +#[cfg(target_arch = "wasm32")] +fn push_skipped(skipped: &mut Vec, name: &str) { + if !name.is_empty() && !skipped.iter().any(|s| s.eq_ignore_ascii_case(name)) { + skipped.push(name.to_string()); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 03ac0aa06..2b7835a52 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2305,6 +2305,22 @@ pub enum Message { bounds: Option, screenshot: Option, }, + /// 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: acadrust::DxfVersion, + geometry_epoch: u64, + edit_revision: u64, + camera_generation: u64, + result: Result, String>, + }, /// Retry the failed save after the other application releases the file. #[cfg(not(target_arch = "wasm32"))] SaveFileInUseRetry, @@ -3579,7 +3595,9 @@ pub enum Message { DataExtractionBrowseSettings, DataExtractionBrowseSettingsResult(Option), DataExtractionAddDrawings, - DataExtractionAddDrawingsResult(Vec), + /// Picked source drawings — `Err` carries a pick/parse failure (web + /// parses each file on the worker at pick time). + DataExtractionAddDrawingsResult(Result, String>), DataExtractionAddFolder, DataExtractionAddFolderResult(Option), DataExtractionClearSources, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 22d078f97..6a0fcb6f9 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1429,11 +1429,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) @@ -1447,6 +1454,22 @@ 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")] + { + let unresolved = crate::io::xref::unresolved_xref_names(&caches.xrefs); + if !unresolved.is_empty() { + 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(), + ); + } + } #[cfg(not(target_arch = "wasm32"))] let thumbs_task = self.push_recent(path.clone()); #[cfg(target_arch = "wasm32")] @@ -2001,82 +2024,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 saved = match crate::io::save_to_bytes(&self.tabs[i].scene.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, 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: acadrust::DxfVersion, + geometry_epoch: u64, + edit_revision: u64, + camera_generation: u64, + result: Result, String>, + ) -> Task { + 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 { if self.save_dialog_for_unsaved { if saved { if let Some(crate::app::PendingClose::Tab(index)) = self.pending_close.take() { let continuation = self.update(Message::TabClose(index)); 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"))] @@ -3017,7 +3102,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 { if self.web_autosave_in_flight || self.pending_web_thumbnail_save.is_some() { @@ -3044,7 +3132,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, version), + )); } if pending.is_empty() { return Task::none(); @@ -3053,10 +3145,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 diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 8820eb5ab..23f2652e6 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1143,26 +1143,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)) => { use crate::command::CadCommand; @@ -1255,9 +1275,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) @@ -1291,13 +1315,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(), ); @@ -8205,6 +8238,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(), diff --git a/src/io/mod.rs b/src/io/mod.rs index 283a2ae16..8710f57bd 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -30,13 +30,19 @@ pub mod xref; pub(crate) mod web_autosave; #[cfg(target_arch = "wasm32")] pub(crate) mod web_drop; +// Minimal in-memory ZIP writer (classic format, flate2 CRC/deflate) used by +// the web ARCHIVE/ETRANSMIT transmittal download. +pub mod zip; use crate::scene::DerivedCaches; use acadrust::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 acadrust::io::dwg::DwgReader; -use acadrust::{ - CadDocument, DwgReadOptions, DwgWriter, DxfReader, DxfReaderConfiguration, DxfWriter, -}; +#[cfg(not(target_arch = "wasm32"))] +use acadrust::{DwgReadOptions, DxfReader, DxfReaderConfiguration}; +use acadrust::{CadDocument, DwgWriter, DxfWriter}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU16, AtomicU32, AtomicU8, Ordering}; use std::sync::Arc; @@ -563,6 +569,10 @@ pub async fn pick_and_load_web(progress: Arc) -> WebOpenOutco let name = handle.file_name(); progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2); let bytes: Arc<[u8]> = Arc::from(handle.read().await); + // 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 @@ -712,9 +722,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(); } @@ -768,6 +785,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 { + 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, +) -> (Vec, usize) { + let pending: Vec = 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 = { + 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 { + 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) -> Result { @@ -819,6 +921,8 @@ pub fn load_bytes_finalized(path: &Path, bytes: Vec) -> 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]; @@ -854,6 +958,10 @@ pub fn load_file(_path: &Path) -> Result { 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>, @@ -950,12 +1058,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: acadrust::DxfError) -> Self { Self { @@ -1016,6 +1126,7 @@ fn merge_read_stats( } } +#[cfg(not(target_arch = "wasm32"))] fn read_file_attempt( path: &Path, progress: Option>, @@ -1043,6 +1154,7 @@ fn read_file_attempt( } } +#[cfg(not(target_arch = "wasm32"))] fn finalize_loaded_outcome( path: &Path, mut outcome: acadrust::ReadOutcome, @@ -1060,6 +1172,7 @@ fn finalize_loaded_outcome( Ok(outcome) } +#[cfg(not(target_arch = "wasm32"))] fn read_dwg_path( path: &Path, progress: Option>, @@ -1070,21 +1183,18 @@ fn read_dwg_path( } else { DwgReadOptions::default() }; - #[cfg(not(target_arch = "wasm32"))] let mut reader = { let mut reader = DwgReader::from_mmap(path).map_err(ReaderFailure::from_reader)?; reader.options = options; reader }; - #[cfg(target_arch = "wasm32")] - let mut reader = - DwgReader::from_file_with_options(path, options).map_err(ReaderFailure::from_reader)?; if let Some(progress) = progress { reader.set_progress_callback(progress); } reader.read_with_stats().map_err(ReaderFailure::from_reader) } +#[cfg(not(target_arch = "wasm32"))] fn read_dxf_path(path: &Path, failsafe: bool) -> Result { DxfReader::from_file(path) .map_err(ReaderFailure::from_reader)? @@ -1157,6 +1267,9 @@ fn normalize_block_origins(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 acadrust::objects::ObjectType; use acadrust::EntityType; @@ -1949,6 +2062,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, @@ -1957,35 +2077,14 @@ pub fn save_to_bytes( ) -> Result, 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, 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(), @@ -1995,6 +2094,65 @@ pub fn save_to_bytes( result } +/// Prepare a document clone for saving: 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), and it mutates only the +/// clone, leaving the live document untouched. 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( + doc: &CadDocument, + version: acadrust::DxfVersion, +) -> CadDocument { + let perf = crate::perf::enabled(); + let clone_started = perf.then(iced::time::Instant::now); + let mut doc = doc.clone(); + let clone_ms = crate::perf::elapsed_ms(clone_started); + 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 clone={:.1} styles={:.1} dimensions={:.1} entities={} objects={}", + clone_ms, + 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, 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 @@ -2695,3 +2853,110 @@ mod corrupt_guard_tests { assert!(!is_entity_corrupt(&EntityType::Insert(i))); } } + +// ── 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 acadrust::entities::{Dimension, DimensionLinear}; + use acadrust::tables::TextStyle; + use acadrust::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 = acadrust::DxfVersion::AC1027; + let prepared = prepare_document_for_save(&doc, acadrust::DxfVersion::AC1032); + assert_eq!(prepared.version, acadrust::DxfVersion::AC1032); + assert_eq!(doc.version, acadrust::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, 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, 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, acadrust::DxfVersion::AC1032).expect("inline"); + let prepared = prepare_document_for_save(&doc, acadrust::DxfVersion::AC1032); + let shared = ocs_save::serialize_document(&prepared, ext).expect("shared"); + assert_eq!(inline, shared, "inline and shared serialize diverge for {ext}"); + } + } +} diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index 77df1ed02..c97702010 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -132,8 +132,11 @@ pub fn register_session_table(name: &str, bytes: Arc>) { } /// The bytes registered under `name` this session, matched -/// case-insensitively the way the folder scan matches file names. -fn session_table(name: &str) -> Option>> { +/// 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>> { session_tables() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) diff --git a/src/io/web_drop.rs b/src/io/web_drop.rs index 78ea01079..5fa8402c5 100644 --- a/src/io/web_drop.rs +++ b/src/io/web_drop.rs @@ -130,6 +130,11 @@ pub async fn open_dropped( bytes: Vec, progress: std::sync::Arc, ) -> 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; diff --git a/src/io/web_worker.rs b/src/io/web_worker.rs index 2a16a884c..88f15f83d 100644 --- a/src/io/web_worker.rs +++ b/src/io/web_worker.rs @@ -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, - color_book_handle: Option, - face_visual_style_handle: Option, - edge_visual_style_handle: Option, - material_flags: u8, - material_handle: Option, - shadow_flags: u8, - plotstyle_flags: u8, - plotstyle_handle: Option, - entity_mode: Option, - 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, @@ -61,7 +47,7 @@ pub(super) async fn parse_document( >, Option, bool, - Vec, + Vec, ) = bincode::deserialize(&bytes) .map_err(|error| super::OpenLoadError::from(error.to_string()))?; if payload.0 != PROTOCOL_VERSION { @@ -72,7 +58,7 @@ pub(super) async fn parse_document( } match payload.1 { Ok(mut outcome) => { - restore_entity_runtime_fields( + ocs_save::restore_entity_runtime_fields( &mut outcome.document, payload.4, ); @@ -142,30 +128,6 @@ pub(super) async fn parse_document( result } -fn restore_entity_runtime_fields( - document: &mut acadrust::CadDocument, - fields: Vec, -) { - let handle = |value: Option| value.map(acadrust::Handle::new); - for fields in fields { - let Some(entity) = document.get_entity_mut(acadrust::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 { let options = WorkerOptions::new(); options.set_type(WorkerType::Module); @@ -233,6 +195,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) -> Result, 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, 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::, String>>(); + let sender = Rc::new(RefCell::new(Some(sender))); + let message_sender = sender.clone(); + let on_message = Closure::::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::::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() diff --git a/src/io/xref.rs b/src/io/xref.rs index fd054a784..548b7da3c 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -7,6 +7,7 @@ use acadrust::types::{Handle, Vector3}; use acadrust::{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,198 @@ pub struct XrefInfo { pub source_sha256: Option, } +/// Every xref whose bytes the browser session holds (picked or dropped), +/// named by its trailing file component. Used to assemble the one-line +/// "unresolved references" warning after a web open. +pub fn unresolved_xref_names(infos: &[XrefInfo]) -> Vec { + 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>>> { + static SOURCES: OnceLock>>>> = OnceLock::new(); + SOURCES.get_or_init(|| Mutex::new(HashMap::default())) +} + +fn parsed_cache() -> &'static Mutex> { + static PARSED: OnceLock>> = 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>) { + 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: acadrust::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 { + 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>> { + 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 { + 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>, +) -> Result { + 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 acadrust::ReadOutcome) { + let doc = &mut outcome.document; + super::normalize_block_origins(doc); + if outcome.stats.source_format == Some(acadrust::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>, + progress: Option>, +) -> Result { + use acadrust::io::dwg::DwgReader; + use acadrust::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. @@ -67,6 +260,10 @@ pub fn resolve_xrefs_with_progress( base_dir: &Path, progress: Option>, ) -> (Vec, usize) { + // Web: resolution is registry-by-name, so the caller's folder is only a + // native concept; reference it so the parameter stays meaningful. + #[cfg(target_arch = "wasm32")] + let _ = base_dir; // Auto-resolve every xref — frustum + LOD culling keep GPU cost bounded. let xref_entries: Vec<(String, String, Handle)> = doc .block_records @@ -103,7 +300,12 @@ pub fn resolve_xrefs_with_progress( .into_par_iter() .enumerate() .map(|(xref_index, (block_name, raw_path, br_handle))| { + #[cfg(not(target_arch = "wasm32"))] let resolved = resolve_path(&raw_path, base_dir); + // Web: there is no filesystem behind `raw_path` — resolve to the + // name-keyed bytes registry instead (trailing-name match). + #[cfg(target_arch = "wasm32")] + let resolved = find_registered_source(&raw_path).map(PathBuf::from); #[cfg(not(target_arch = "wasm32"))] let initial_fingerprint = resolved.as_ref().and_then(|path| { crate::io::edit_lock::FileFingerprint::capture(path).ok() @@ -125,9 +327,16 @@ pub fn resolve_xrefs_with_progress( }); 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)) => { @@ -263,6 +472,9 @@ pub fn resolve_xrefs_with_progress( /// Try to build an absolute path from a raw xref path string. /// Handles absolute paths, relative paths, and Windows-style separators. +/// Web builds never get here — `resolve_xrefs_with_progress` matches the +/// bytes registry by name instead (there is no filesystem to probe). +#[cfg(not(target_arch = "wasm32"))] fn resolve_path(raw: &str, base_dir: &Path) -> Option { let normalised = raw.replace('\\', "/"); let p = PathBuf::from(&normalised); @@ -539,3 +751,112 @@ fn is_sentinel_linetype(name: &str) -> bool { fn set_handle(entity: &mut EntityType, h: Handle) { entity.common_mut().handle = h; } + +#[cfg(test)] +mod tests { + use super::*; + + fn outcome() -> acadrust::ReadOutcome { + acadrust::ReadOutcome::new(CadDocument::new(), acadrust::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()); + } +} diff --git a/src/io/zip.rs b/src/io/zip.rs new file mode 100644 index 000000000..bcae0a250 --- /dev/null +++ b/src/io/zip.rs @@ -0,0 +1,391 @@ +//! Minimal in-memory ZIP writer. +//! +//! The web ARCHIVE/ETRANSMIT packages a transmittal (drawing + registered +//! dependencies) into one browser download; ZIP is the container every +//! recipient can open. This writer emits the classic (pre-zip64) format +//! directly — local file headers, a central directory, and the end record — +//! with CRC32 and DEFLATE from the `flate2` crate the tree already depends +//! on, so no new dependency is pulled in for a handful of fixed-size +//! little-endian records. +//! +//! Constraints of the classic format are enforced with errors instead of +//! wrapping: more than 65535 entries, an entry over 4 GiB, or an archive +//! over 4 GiB cannot be represented and refuse to build. Browser-download +//! transmittals are far below every limit. +//! +//! Every entry is stamped with the same fixed DOS timestamp and flat, +//! separator-free name, so building the same entries twice yields +//! byte-identical output. + +use std::io::Write; + +use flate2::write::DeflateEncoder; +use flate2::Compression; + +/// Local file header signature ("PK\x03\x04"). +const LOCAL_SIG: u32 = 0x0403_4b50; +/// Central directory file header signature ("PK\x01\x02"). +const CENTRAL_SIG: u32 = 0x0201_4b50; +/// End of central directory record signature ("PK\x05\x06"). +const END_SIG: u32 = 0x0605_4b50; + +/// Compression method 0: bytes stored verbatim. +const METHOD_STORE: u16 = 0; +/// Compression method 8: raw DEFLATE (no zlib wrapper). +const METHOD_DEFLATE: u16 = 8; + +/// Version needed to extract (2.0 — the deflate minimum) and the version +/// that "made" the archive (2.0, MS-DOS host so no external-attribute +/// permissions are implied). One constant serves both fields. +const VERSION_2_0: u16 = 20; + +/// General-purpose flag 0x0800: entry names are UTF-8. +const FLAG_UTF8: u16 = 0x0800; + +/// Timestamp stamped on every entry: 2026-01-01 00:00:00 in DOS packed +/// form. Fixed rather than taken from the clock so archives are +/// deterministic (and testable) byte for byte. +const DOS_TIME: u16 = 0; // 00:00:00 +const DOS_DATE: u16 = (2026 - 1980) << 9 | 1 << 5 | 1; // 2026-01-01 + +/// One file to package: a flat name and its bytes. +#[derive(Clone, Debug)] +pub struct ZipEntry { + pub name: String, + pub data: Vec, +} + +impl ZipEntry { + pub fn new(name: impl Into, data: Vec) -> Self { + Self { + name: name.into(), + data, + } + } +} + +/// Build a complete ZIP archive from `entries`, in order. +/// +/// Each entry is DEFLATE-compressed and kept only when that actually +/// shrinks it (drawings compress well; PNG/PDF payloads are stored instead +/// of being pointlessly re-deflated) — the same choice mainstream writers +/// make. Duplicate names are written as given; callers that want one file +/// per name dedupe before building. +pub fn build_zip(entries: &[ZipEntry]) -> Result, String> { + let count = + u16::try_from(entries.len()).map_err(|_| format!("too many entries: {}", entries.len()))?; + let mut out: Vec = Vec::new(); + let mut central: Vec = Vec::new(); + for entry in entries { + let name = validate_name(&entry.name)?; + let name_len = + u16::try_from(name.len()).map_err(|_| format!("entry name too long: {}", name.len()))?; + + let mut crc = flate2::Crc::new(); + crc.update(&entry.data); + let crc = crc.sum(); + + let deflated = deflate(&entry.data); + let (method, payload): (u16, &[u8]) = if deflated.len() < entry.data.len() { + (METHOD_DEFLATE, &deflated) + } else { + (METHOD_STORE, &entry.data) + }; + let compressed_size = u32::try_from(payload.len()) + .map_err(|_| format!("entry {} exceeds the 4 GiB classic-ZIP limit", name))?; + let uncompressed_size = u32::try_from(entry.data.len()) + .map_err(|_| format!("entry {} exceeds the 4 GiB classic-ZIP limit", name))?; + let local_offset = u32::try_from(out.len()) + .map_err(|_| "archive exceeds the 4 GiB classic-ZIP limit".to_string())?; + + // Local file header — immediately followed by name and payload. + out.extend_from_slice(&LOCAL_SIG.to_le_bytes()); + out.extend_from_slice(&VERSION_2_0.to_le_bytes()); + out.extend_from_slice(&FLAG_UTF8.to_le_bytes()); + out.extend_from_slice(&method.to_le_bytes()); + out.extend_from_slice(&DOS_TIME.to_le_bytes()); + out.extend_from_slice(&DOS_DATE.to_le_bytes()); + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(&compressed_size.to_le_bytes()); + out.extend_from_slice(&uncompressed_size.to_le_bytes()); + out.extend_from_slice(&name_len.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // extra field length + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(payload); + + // Central directory record for the entry just written. + central.extend_from_slice(&CENTRAL_SIG.to_le_bytes()); + central.extend_from_slice(&VERSION_2_0.to_le_bytes()); // version made by + central.extend_from_slice(&VERSION_2_0.to_le_bytes()); // version needed + central.extend_from_slice(&FLAG_UTF8.to_le_bytes()); + central.extend_from_slice(&method.to_le_bytes()); + central.extend_from_slice(&DOS_TIME.to_le_bytes()); + central.extend_from_slice(&DOS_DATE.to_le_bytes()); + central.extend_from_slice(&crc.to_le_bytes()); + central.extend_from_slice(&compressed_size.to_le_bytes()); + central.extend_from_slice(&uncompressed_size.to_le_bytes()); + central.extend_from_slice(&name_len.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); // extra field length + central.extend_from_slice(&0u16.to_le_bytes()); // comment length + central.extend_from_slice(&0u16.to_le_bytes()); // disk number start + central.extend_from_slice(&0u16.to_le_bytes()); // internal attributes + central.extend_from_slice(&0u32.to_le_bytes()); // external attributes + central.extend_from_slice(&local_offset.to_le_bytes()); + central.extend_from_slice(name.as_bytes()); + } + + let central_offset = u32::try_from(out.len()) + .map_err(|_| "archive exceeds the 4 GiB classic-ZIP limit".to_string())?; + let central_size = u32::try_from(central.len()) + .map_err(|_| "archive exceeds the 4 GiB classic-ZIP limit".to_string())?; + out.extend_from_slice(¢ral); + + // End of central directory record — no archive comment. + out.extend_from_slice(&END_SIG.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // this disk number + out.extend_from_slice(&0u16.to_le_bytes()); // central directory start disk + out.extend_from_slice(&count.to_le_bytes()); // entries on this disk + out.extend_from_slice(&count.to_le_bytes()); // entries total + out.extend_from_slice(¢ral_size.to_le_bytes()); + out.extend_from_slice(¢ral_offset.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // comment length + Ok(out) +} + +/// Entry names must be flat file names: non-empty, free of path separators +/// and NUL, not a directory dot-name. Keeping the archive flat mirrors the +/// native transmittal folder (drawing and dependencies side by side) and +/// rules out path traversal on extraction. +fn validate_name(name: &str) -> Result<&str, String> { + if name.is_empty() + || name == "." + || name == ".." + || name.contains(['/', '\\', '\0']) + || name.len() > u16::MAX as usize + { + return Err(format!("invalid ZIP entry name: {name:?}")); + } + Ok(name) +} + +/// Raw DEFLATE stream (what method 8 stores — no zlib header/checksum). +/// Infallible in practice: the encoder writes into a `Vec`, which cannot +/// fail; on an impossible error the uncompressed bytes are kept so the +/// caller still receives a usable payload. +fn deflate(data: &[u8]) -> Vec { + let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default()); + if encoder.write_all(data).is_err() { + return data.to_vec(); + } + encoder.finish().unwrap_or_else(|_| data.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::read::DeflateDecoder; + use std::io::Read; + + /// The CRC-32 (IEEE 802.3) check value for "123456789" — the standard + /// reference every CRC implementation is verified against. + const CRC_CHECK_VALUE: u32 = 0xCBF4_3926; + + /// One entry as read back from the central directory. + struct CentralEntry { + name: String, + method: u16, + crc: u32, + compressed_size: u32, + uncompressed_size: u32, + local_offset: u32, + } + + fn u16_at(zip: &[u8], at: usize) -> u16 { + u16::from_le_bytes([zip[at], zip[at + 1]]) + } + fn u32_at(zip: &[u8], at: usize) -> u32 { + u32::from_le_bytes([zip[at], zip[at + 1], zip[at + 2], zip[at + 3]]) + } + + /// Parse the end record (always the last 22 bytes — the writer never + /// emits a comment) and walk the central directory it points at. + fn parse_central_directory(zip: &[u8]) -> (Vec, u16, u32, u32) { + assert!(zip.len() >= 22, "archive must hold an end record"); + let eocd = zip.len() - 22; + assert_eq!(&zip[eocd..eocd + 4], &END_SIG.to_le_bytes()); + assert_eq!(u16_at(zip, eocd + 4), 0, "disk number"); + assert_eq!(u16_at(zip, eocd + 6), 0, "central directory start disk"); + let on_disk = u16_at(zip, eocd + 8); + let total = u16_at(zip, eocd + 10); + let size = u32_at(zip, eocd + 12); + let offset = u32_at(zip, eocd + 16); + assert_eq!(u16_at(zip, eocd + 20), 0, "comment length"); + assert_eq!(on_disk, total, "single-disk archive"); + + let mut entries = Vec::with_capacity(total as usize); + let mut cursor = offset as usize; + for _ in 0..total { + assert_eq!(&zip[cursor..cursor + 4], &CENTRAL_SIG.to_le_bytes()); + let method = u16_at(zip, cursor + 10); + let crc = u32_at(zip, cursor + 16); + let compressed_size = u32_at(zip, cursor + 20); + let uncompressed_size = u32_at(zip, cursor + 24); + let name_len = u16_at(zip, cursor + 28) as usize; + assert_eq!(u16_at(zip, cursor + 30), 0, "extra length"); + assert_eq!(u16_at(zip, cursor + 32), 0, "comment length"); + assert_eq!(u16_at(zip, cursor + 34), 0, "disk number start"); + let local_offset = u32_at(zip, cursor + 42); + let name = String::from_utf8(zip[cursor + 46..cursor + 46 + name_len].to_vec()) + .expect("entry names are UTF-8"); + entries.push(CentralEntry { + name, + method, + crc, + compressed_size, + uncompressed_size, + local_offset, + }); + cursor += 46 + name_len; + } + assert_eq!(cursor - offset as usize, size as usize, "recorded size"); + assert_eq!(cursor, eocd, "central directory ends at the end record"); + (entries, total, size, offset) + } + + /// Read one entry's payload back through its local header, inflating + /// method-8 entries — the round trip an extractor performs. + fn read_entry_payload(zip: &[u8], entry: &CentralEntry) -> Vec { + let at = entry.local_offset as usize; + assert_eq!(&zip[at..at + 4], &LOCAL_SIG.to_le_bytes()); + let name_len = u16_at(zip, at + 26) as usize; + assert_eq!(u16_at(zip, at + 28), 0, "extra length"); + let name = String::from_utf8(zip[at + 30..at + 30 + name_len].to_vec()).unwrap(); + assert_eq!(name, entry.name, "local and central names agree"); + let start = at + 30 + name_len; + let payload = &zip[start..start + entry.compressed_size as usize]; + match entry.method { + METHOD_STORE => payload.to_vec(), + METHOD_DEFLATE => { + let mut inflated = Vec::new(); + DeflateDecoder::new(payload) + .read_to_end(&mut inflated) + .expect("deflate payload inflates"); + inflated + } + other => panic!("unexpected method {other}"), + } + } + + /// Deterministic poorly-compressible bytes (an LCG keystream) so the + /// STORE branch is exercised without a fixture file. + fn incompressible(len: usize) -> Vec { + let mut state = 0x2545_F491_u32; + (0..len) + .map(|_| { + state = state.wrapping_mul(1_103_515_245).wrapping_add(12_345); + (state >> 24) as u8 + }) + .collect() + } + + #[test] + fn empty_archive_is_a_bare_end_record() { + let zip = build_zip(&[]).expect("empty archive builds"); + assert_eq!(zip.len(), 22); + let (entries, total, size, offset) = parse_central_directory(&zip); + assert_eq!(entries.len(), 0); + assert_eq!(total, 0); + assert_eq!(size, 0); + assert_eq!(offset, 0); + } + + #[test] + fn central_directory_round_trips_known_contents() { + let drawing = b"0\nSECTION\n2\nENTITIES\n0\nENDSEC\n0\nEOF\n".repeat(40); + let entries = vec![ + ZipEntry::new("plan.dwg", drawing.clone()), + ZipEntry::new("site.png", incompressible(512)), + ZipEntry::new("notes.txt", Vec::new()), + ]; + let zip = build_zip(&entries).expect("archive builds"); + + let (central, total, size, offset) = parse_central_directory(&zip); + assert_eq!(total as usize, entries.len()); + assert_eq!(central.len(), entries.len()); + assert!(size > 0 && (offset as usize) < zip.len()); + + let names: Vec<&str> = central.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, ["plan.dwg", "site.png", "notes.txt"]); + + for (entry, parsed) in entries.iter().zip(¢ral) { + let payload = read_entry_payload(&zip, parsed); + assert_eq!(payload, entry.data, "{} round-trips", entry.name); + assert_eq!(parsed.uncompressed_size as usize, entry.data.len()); + let mut crc = flate2::Crc::new(); + crc.update(&entry.data); + assert_eq!(parsed.crc, crc.sum(), "{} CRC matches flate2", entry.name); + assert!(parsed.compressed_size as usize <= entry.data.len()); + } + + // The repetitive drawing DEFLATES; the keystream image does not. + assert_eq!(central[0].method, METHOD_DEFLATE); + assert!(central[0].compressed_size < central[0].uncompressed_size); + assert_eq!(central[1].method, METHOD_STORE); + assert_eq!(central[1].compressed_size, central[1].uncompressed_size); + // An empty entry has nothing to shrink: stored, zero sizes. + assert_eq!(central[2].method, METHOD_STORE); + assert_eq!(central[2].compressed_size, 0); + assert_eq!(central[2].uncompressed_size, 0); + } + + #[test] + fn stored_crc_matches_the_reference_check_value() { + let zip = build_zip(&[ZipEntry::new("check.bin", b"123456789".to_vec())]) + .expect("archive builds"); + let (central, _, _, _) = parse_central_directory(&zip); + assert_eq!(central[0].crc, CRC_CHECK_VALUE); + } + + #[test] + fn local_offsets_and_determinism_hold() { + let entries = vec![ + ZipEntry::new("a.dwg", incompressible(64)), + ZipEntry::new("b.png", b"same bytes".to_vec()), + ]; + let zip = build_zip(&entries).expect("archive builds"); + let (central, _, _, _) = parse_central_directory(&zip); + // Offsets advance past each header+payload, first entry at zero. + assert_eq!(central[0].local_offset, 0); + assert!(central[1].local_offset > central[0].local_offset); + // Identical input rebuilds byte-identical output (fixed timestamp). + assert_eq!(build_zip(&entries).unwrap(), zip); + } + + #[test] + fn utf8_names_round_trip() { + let entries = vec![ZipEntry::new("πλάνη.dwg", "γ".repeat(100).into_bytes())]; + let zip = build_zip(&entries).expect("archive builds"); + let (central, _, _, _) = parse_central_directory(&zip); + assert_eq!(central[0].name, "πλάνη.dwg"); + assert_eq!(read_entry_payload(&zip, ¢ral[0]), entries[0].data); + } + + #[test] + fn invalid_names_are_rejected() { + let long = "x".repeat(u16::MAX as usize + 1); + for name in ["", ".", "..", "a/b", "a\\b", "a\0b", &long] { + let err = build_zip(&[ZipEntry::new(name, Vec::new())]) + .expect_err("invalid name must be rejected"); + assert!(err.contains("invalid ZIP entry name"), "{name:?}: {err}"); + } + } + + #[test] + fn entry_count_over_the_classic_limit_is_rejected() { + let entries: Vec = (0..=u16::MAX as usize + 1) + .map(|i| ZipEntry::new(format!("f{i}"), Vec::new())) + .collect(); + let err = build_zip(&entries).expect_err("too many entries must be rejected"); + assert!(err.contains("too many entries"), "{err}"); + } +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 5fc6bd296..f3a829579 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3153,6 +3153,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "XREF Reloaded \"{}\"" => Some(("insert", "xref-reloaded")), "XREF Save the drawing first to resolve relative XREF paths." => Some(("insert", "xref-save-the-drawing-first-to-resolve-relativ")), "XREF Unloaded (skipped): \"{}\"" => Some(("common", "xref-unloaded-skipped")), + "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")), @@ -3190,11 +3191,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")), @@ -3543,6 +3547,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Save Block As" => Some(("common", "save-block-as")), "Save Data Extraction" => Some(("common", "save-data-extraction")), "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")), diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index e646825ca..b751e5a1f 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -465,6 +465,19 @@ fn registered_bytes_by_name(path: &str) -> Option>> { .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>> { + 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. diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index 4f2aa9188..4a283097b 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -30,6 +30,18 @@ fn source_cache() -> &'static Mutex>>> { 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>> { + 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>) { diff --git a/src/ui/window/annotation_data.rs b/src/ui/window/annotation_data.rs index 63e965511..ea58f859d 100644 --- a/src/ui/window/annotation_data.rs +++ b/src/ui/window/annotation_data.rs @@ -564,6 +564,17 @@ pub fn data_extraction_view(state: &DataExtractionState, sizing: crate::ui::moda 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(t!("Begin"), column![ diff --git a/web/ocs-parse-worker.js b/web/ocs-parse-worker.js index ab347affa..c38ddd8aa 100644 --- a/web/ocs-parse-worker.js +++ b/web/ocs-parse-worker.js @@ -1,7 +1,7 @@ -import init, { parse_document, sha256_document } from "./worker_pkg/ocs_web_worker.js?v=4"; +import init, { parse_document, serialize_document, sha256_document } from "./worker_pkg/ocs_web_worker.js?v=5"; const ready = init( - new URL("./worker_pkg/ocs_web_worker_bg.wasm?v=4", import.meta.url), + new URL("./worker_pkg/ocs_web_worker_bg.wasm?v=5", import.meta.url), ); self.onmessage = async ({ data }) => { @@ -13,6 +13,18 @@ self.onmessage = async ({ data }) => { self.postMessage({ ok: true, digest }); return; } + if (data.action === "serialize") { + // SAVE/AUTOSAVE: decode the prepared document, run the DWG/DXF writer + // here, and hand the encoded result buffer back. Mirrors the parse + // branch's copy-before-transfer (wasm-bindgen returns a view into + // WebAssembly.Memory, which must not detach). + const encoded = serialize_document(new Uint8Array(data.document), (next) => { + stage = next; + }); + const transferable = encoded.slice(); + self.postMessage({ ok: true, data: transferable.buffer }, [transferable.buffer]); + return; + } const encoded = parse_document( data.name, new Uint8Array(data.bytes),