diff --git a/crates/dwg-thumbnailer/src/lib.rs b/crates/dwg-thumbnailer/src/lib.rs index 4f62bf62..7927002d 100644 --- a/crates/dwg-thumbnailer/src/lib.rs +++ b/crates/dwg-thumbnailer/src/lib.rs @@ -26,6 +26,33 @@ pub fn extract(path: &Path, max_dim: u32) -> Option { Some(downscale(img, max_dim.max(1))) } +/// Extract an embedded preview from an in-memory DWG. This is the browser +/// counterpart of [`extract`], where the file picker supplies bytes instead of +/// a reusable filesystem path. +pub fn extract_bytes(bytes: &[u8], max_dim: u32) -> Option { + if bytes.get(..2)? != b"AC" { + return None; + } + let base = i32::from_le_bytes(bytes.get(0x0D..0x11)?.try_into().ok()?); + if base <= 0 { + return None; + } + let base = base as u64; + let start = usize::try_from(base).ok()?; + let total = preview_container_len(bytes.get(start..start.checked_add(20)?)?)?; + let container = bytes.get(start..start.checked_add(total)?)?; + extract_container(container, base, max_dim) +} + +/// Decode a preview container read from a DWG at `file_offset`. Exposing this +/// narrow entry point lets browser callers use `Blob.slice()` to migrate old +/// recent files without copying the entire drawing into memory. +pub fn extract_container(container: &[u8], file_offset: u64, max_dim: u32) -> Option { + let (format, data) = parse_preview_container(container, file_offset)?; + let img = decode(format, data)?; + Some(downscale(img, max_dim.max(1))) +} + /// White "DWG" wordmark, composited (centered) onto the format band. static DWG_LABEL_PNG: &[u8] = include_bytes!("../assets/dwg-label.png"); /// Format band colour — OCS brand red. @@ -119,20 +146,32 @@ fn read_preview(path: &Path) -> Option<(Fmt, Vec)> { f.seek(SeekFrom::Start(base)).ok()?; let mut head = [0u8; 20]; f.read_exact(&mut head).ok()?; - if head[..16] != PREVIEW_SENTINEL { + let total = preview_container_len(&head)?; + f.seek(SeekFrom::Start(base)).ok()?; + let mut buf = vec![0u8; total]; + f.read_exact(&mut buf).ok()?; + let (format, data) = parse_preview_container(&buf, base)?; + Some((format, data.to_vec())) +} + +fn preview_container_len(head: &[u8]) -> Option { + if head.get(..16)? != PREVIEW_SENTINEL { return None; } - let overall = u32::from_le_bytes([head[16], head[17], head[18], head[19]]) as usize; + let overall = u32::from_le_bytes(head.get(16..20)?.try_into().ok()?) as usize; if overall == 0 || overall > 64 * 1024 * 1024 { return None; } // Whole container = sentinel(16) + size(4) + overall + end sentinel(16). - let total = 36 + overall; - f.seek(SeekFrom::Start(base)).ok()?; - let mut buf = vec![0u8; total]; - f.read_exact(&mut buf).ok()?; + 36usize.checked_add(overall) +} - let count = buf[20] as usize; +fn parse_preview_container(buf: &[u8], base: u64) -> Option<(Fmt, &[u8])> { + let total = preview_container_len(buf.get(..20)?)?; + if buf.len() < total { + return None; + } + let count = *buf.get(20)? as usize; let mut off = 21usize; for _ in 0..count { if off + 9 > buf.len() { @@ -155,7 +194,7 @@ fn read_preview(path: &Path) -> Option<(Fmt, Vec)> { if size == 0 || end > buf.len() { continue; } - return Some((fmt, buf[rel..end].to_vec())); + return Some((fmt, &buf[rel..end])); } None } diff --git a/src/app/recent.rs b/src/app/recent.rs index 3563cb3c..605d98ce 100644 --- a/src/app/recent.rs +++ b/src/app/recent.rs @@ -16,10 +16,14 @@ impl OpenCADStudio { /// the background task that decodes its thumbnail. pub(super) fn push_recent(&mut self, path: PathBuf) -> iced::Task { self.recent_files.retain(|r| r != &path); + self.recent_thumbs.remove(&path); self.recent_files.insert(0, path); let evicted = self .recent_files .split_off(self.recent_limit.min(self.recent_files.len())); + for path in &evicted { + self.recent_thumbs.remove(path); + } remove_cached_copies(evicted); self.save_config(); self.refresh_recent_thumbs() @@ -42,11 +46,36 @@ impl OpenCADStudio { if missing.is_empty() { return iced::Task::none(); } - // The web build has no spawnable threads and no filesystem previews — - // recents there simply show without thumbnails. #[cfg(target_arch = "wasm32")] { - return iced::Task::none(); + return iced::Task::perform( + async move { + let mut thumbnails = Vec::with_capacity(missing.len()); + for path in missing { + let handle = if let Some(name) = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + { + crate::io::web_recent::read_thumbnail(&name) + .await + .ok() + .flatten() + .map(|thumbnail| { + iced::widget::image::Handle::from_rgba( + thumbnail.width, + thumbnail.height, + thumbnail.rgba, + ) + }) + } else { + None + }; + thumbnails.push((path, handle)); + } + thumbnails + }, + crate::app::Message::RecentThumbsLoaded, + ); } #[cfg(not(target_arch = "wasm32"))] { @@ -71,6 +100,7 @@ impl OpenCADStudio { /// Drop a path from the recents list (manual removal from the Start page). pub(super) fn remove_recent(&mut self, path: &Path) { self.recent_files.retain(|r| r.as_path() != path); + self.recent_thumbs.remove(path); remove_cached_copies([path.to_path_buf()]); self.save_config(); } @@ -82,6 +112,9 @@ impl OpenCADStudio { let evicted = self .recent_files .split_off(self.recent_limit.min(self.recent_files.len())); + for path in &evicted { + self.recent_thumbs.remove(path); + } remove_cached_copies(evicted); self.save_config(); } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 382afb8a..6c0572d4 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2829,6 +2829,16 @@ pub(super) fn recent_files_panel<'a>( .parent() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); + // Browsers intentionally do not reveal the source folder selected + // by the user. The reusable copy lives in origin-private browser + // storage, which is the truthful web equivalent of native's parent + // directory line. + #[cfg(target_arch = "wasm32")] + let dir = if dir.is_empty() { + "Browser storage".to_string() + } else { + dir + }; // Leading DWG preview thumbnail (fixed box keeps rows aligned even // when a file has no readable preview). diff --git a/src/io/web_recent.rs b/src/io/web_recent.rs index 12708cdb..6ae1f81b 100644 --- a/src/io/web_recent.rs +++ b/src/io/web_recent.rs @@ -8,12 +8,41 @@ use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; const RECENT_DIRECTORY: &str = "opencadstudio-recent"; +const THUMBNAIL_MAGIC: &[u8; 4] = b"OCST"; +const THUMBNAIL_MAX_DIM: u32 = 256; + +pub struct Thumbnail { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> { let directory = recent_directory(true).await?; + write_entry(&directory, &cache_key(name), bytes).await?; + + if let Some(image) = dwg_thumbnailer::extract_bytes(bytes, THUMBNAIL_MAX_DIM) { + let thumbnail = encode_thumbnail(image); + // The drawing copy is the durable part of a recent entry. Thumbnail + // caching must not make an otherwise successful open/save fail under + // browser quota pressure. + let _ = write_entry(&directory, &thumbnail_key(name), &thumbnail).await; + } else { + // An overwrite may replace a drawing that had a preview with one that + // does not. Do not leave the old image attached to the new file. + let _ = JsFuture::from(directory.remove_entry(&thumbnail_key(name))).await; + } + Ok(()) +} + +async fn write_entry( + directory: &web_sys::FileSystemDirectoryHandle, + key: &str, + bytes: &[u8], +) -> Result<(), String> { let options = web_sys::FileSystemGetFileOptions::new(); options.set_create(true); - let handle = JsFuture::from(directory.get_file_handle_with_options(&cache_key(name), &options)) + let handle = JsFuture::from(directory.get_file_handle_with_options(key, &options)) .await .map_err(js_error)? .dyn_into::() @@ -31,28 +60,71 @@ pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> { pub async fn read(name: &str) -> Result, String> { let directory = recent_directory(false).await?; - let handle = JsFuture::from(directory.get_file_handle(&cache_key(name))) + read_entry(&directory, &cache_key(name)).await +} + +async fn read_entry( + directory: &web_sys::FileSystemDirectoryHandle, + key: &str, +) -> Result, String> { + let file = get_file(directory, key).await?; + read_blob(file.as_ref()).await +} + +async fn get_file( + directory: &web_sys::FileSystemDirectoryHandle, + key: &str, +) -> Result { + let handle = JsFuture::from(directory.get_file_handle(key)) .await .map_err(js_error)? .dyn_into::() .map_err(js_error)?; - let file = JsFuture::from(handle.get_file()) + JsFuture::from(handle.get_file()) .await .map_err(js_error)? .dyn_into::() - .map_err(js_error)?; - let buffer = JsFuture::from(file.array_buffer()) + .map_err(js_error) +} + +async fn read_blob(blob: &web_sys::Blob) -> Result, String> { + let buffer = JsFuture::from(blob.array_buffer()) .await .map_err(js_error)?; Ok(js_sys::Uint8Array::new(&buffer).to_vec()) } +/// Load a cached preview. Records created before thumbnail sidecars existed are +/// migrated by slicing only the DWG header and preview container from the OPFS +/// file; the potentially large drawing body is never copied or parsed. +pub async fn read_thumbnail(name: &str) -> Result, String> { + let directory = recent_directory(false).await?; + if let Ok(bytes) = read_entry(&directory, &thumbnail_key(name)).await { + if let Some(thumbnail) = decode_thumbnail(bytes) { + return Ok(Some(thumbnail)); + } + } + + let file = get_file(&directory, &cache_key(name)).await?; + let Some(image) = extract_embedded_thumbnail(&file).await? else { + return Ok(None); + }; + let encoded = encode_thumbnail(image); + let thumbnail = decode_thumbnail(encoded.clone()) + .ok_or_else(|| "generated recent thumbnail is invalid".to_string())?; + // Migration caching is best-effort: a readable drawing should still show + // its preview even if quota pressure prevents writing the sidecar. + let _ = write_entry(&directory, &thumbnail_key(name), &encoded).await; + Ok(Some(thumbnail)) +} + pub async fn remove(name: &str) -> Result<(), String> { let directory = recent_directory(false).await?; - JsFuture::from(directory.remove_entry(&cache_key(name))) + let drawing = JsFuture::from(directory.remove_entry(&cache_key(name))) .await - .map_err(js_error)?; - Ok(()) + .map_err(js_error); + let _ = JsFuture::from(directory.remove_entry(&thumbnail_key(name))).await; + drawing.map(|_| ()) } async fn recent_directory(create: bool) -> Result { @@ -74,12 +146,102 @@ async fn recent_directory(create: bool) -> Result String { + format!("{}.cad", name_hash(name)) +} + +fn thumbnail_key(name: &str) -> String { + format!("{}.thumb", name_hash(name)) +} + +fn name_hash(name: &str) -> String { let mut hash = 0xcbf29ce484222325_u64; for byte in name.as_bytes() { hash ^= u64::from(*byte); hash = hash.wrapping_mul(0x100000001b3); } - format!("{hash:016x}.cad") + format!("{hash:016x}") +} + +fn encode_thumbnail(image: dwg_thumbnailer::RgbaImage) -> Vec { + let mut bytes = Vec::with_capacity(12 + image.as_raw().len()); + bytes.extend_from_slice(THUMBNAIL_MAGIC); + bytes.extend_from_slice(&image.width().to_le_bytes()); + bytes.extend_from_slice(&image.height().to_le_bytes()); + bytes.extend_from_slice(image.as_raw()); + bytes +} + +fn decode_thumbnail(bytes: Vec) -> Option { + if bytes.get(..4)? != THUMBNAIL_MAGIC { + return None; + } + let width = u32::from_le_bytes(bytes.get(4..8)?.try_into().ok()?); + let height = u32::from_le_bytes(bytes.get(8..12)?.try_into().ok()?); + let expected = usize::try_from(width) + .ok()? + .checked_mul(usize::try_from(height).ok()?)? + .checked_mul(4)?; + if width == 0 + || height == 0 + || width > THUMBNAIL_MAX_DIM + || height > THUMBNAIL_MAX_DIM + || bytes.len() != 12 + expected + { + return None; + } + Some(Thumbnail { + width, + height, + rgba: bytes[12..].to_vec(), + }) +} + +async fn extract_embedded_thumbnail( + file: &web_sys::File, +) -> Result, String> { + let header = read_file_range(file, 0, 0x11).await?; + if header.get(..2) != Some(b"AC") { + return Ok(None); + } + let Some(offset) = header + .get(0x0D..0x11) + .and_then(|bytes| bytes.try_into().ok()) + .map(i32::from_le_bytes) + .filter(|offset| *offset > 0) + else { + return Ok(None); + }; + let offset = offset as u64; + let container_header = read_file_range(file, offset, offset + 20).await?; + let Some(overall) = container_header + .get(16..20) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + .map(u64::from) + .filter(|size| *size > 0 && *size <= 64 * 1024 * 1024) + else { + return Ok(None); + }; + let Some(end) = offset + .checked_add(36) + .and_then(|end| end.checked_add(overall)) + else { + return Ok(None); + }; + let container = read_file_range(file, offset, end).await?; + Ok(dwg_thumbnailer::extract_container( + &container, + offset, + THUMBNAIL_MAX_DIM, + )) +} + +async fn read_file_range(file: &web_sys::File, start: u64, end: u64) -> Result, String> { + let blob: &web_sys::Blob = file.as_ref(); + let slice = blob + .slice_with_f64_and_f64(start as f64, end as f64) + .map_err(js_error)?; + read_blob(&slice).await } fn js_error(value: wasm_bindgen::JsValue) -> String {