feat(web): show recent thumbnails

Cache previews in OPFS and migrate existing recent entries by reading only their embedded DWG preview data.
This commit is contained in:
Hakan Seven 2026-07-29 01:09:13 +03:00
commit 969a7605b7
4 changed files with 264 additions and 20 deletions

View file

@ -26,6 +26,33 @@ pub fn extract(path: &Path, max_dim: u32) -> Option<RgbaImage> {
Some(downscale(img, max_dim.max(1))) 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<RgbaImage> {
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<RgbaImage> {
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. /// White "DWG" wordmark, composited (centered) onto the format band.
static DWG_LABEL_PNG: &[u8] = include_bytes!("../assets/dwg-label.png"); static DWG_LABEL_PNG: &[u8] = include_bytes!("../assets/dwg-label.png");
/// Format band colour — OCS brand red. /// Format band colour — OCS brand red.
@ -119,20 +146,32 @@ fn read_preview(path: &Path) -> Option<(Fmt, Vec<u8>)> {
f.seek(SeekFrom::Start(base)).ok()?; f.seek(SeekFrom::Start(base)).ok()?;
let mut head = [0u8; 20]; let mut head = [0u8; 20];
f.read_exact(&mut head).ok()?; 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<usize> {
if head.get(..16)? != PREVIEW_SENTINEL {
return None; 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 { if overall == 0 || overall > 64 * 1024 * 1024 {
return None; return None;
} }
// Whole container = sentinel(16) + size(4) + overall + end sentinel(16). // Whole container = sentinel(16) + size(4) + overall + end sentinel(16).
let total = 36 + overall; 36usize.checked_add(overall)
f.seek(SeekFrom::Start(base)).ok()?; }
let mut buf = vec![0u8; total];
f.read_exact(&mut buf).ok()?;
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; let mut off = 21usize;
for _ in 0..count { for _ in 0..count {
if off + 9 > buf.len() { if off + 9 > buf.len() {
@ -155,7 +194,7 @@ fn read_preview(path: &Path) -> Option<(Fmt, Vec<u8>)> {
if size == 0 || end > buf.len() { if size == 0 || end > buf.len() {
continue; continue;
} }
return Some((fmt, buf[rel..end].to_vec())); return Some((fmt, &buf[rel..end]));
} }
None None
} }

View file

@ -16,10 +16,14 @@ impl OpenCADStudio {
/// the background task that decodes its thumbnail. /// the background task that decodes its thumbnail.
pub(super) fn push_recent(&mut self, path: PathBuf) -> iced::Task<crate::app::Message> { pub(super) fn push_recent(&mut self, path: PathBuf) -> iced::Task<crate::app::Message> {
self.recent_files.retain(|r| r != &path); self.recent_files.retain(|r| r != &path);
self.recent_thumbs.remove(&path);
self.recent_files.insert(0, path); self.recent_files.insert(0, path);
let evicted = self let evicted = self
.recent_files .recent_files
.split_off(self.recent_limit.min(self.recent_files.len())); .split_off(self.recent_limit.min(self.recent_files.len()));
for path in &evicted {
self.recent_thumbs.remove(path);
}
remove_cached_copies(evicted); remove_cached_copies(evicted);
self.save_config(); self.save_config();
self.refresh_recent_thumbs() self.refresh_recent_thumbs()
@ -42,11 +46,36 @@ impl OpenCADStudio {
if missing.is_empty() { if missing.is_empty() {
return iced::Task::none(); 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")] #[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"))] #[cfg(not(target_arch = "wasm32"))]
{ {
@ -71,6 +100,7 @@ impl OpenCADStudio {
/// Drop a path from the recents list (manual removal from the Start page). /// Drop a path from the recents list (manual removal from the Start page).
pub(super) fn remove_recent(&mut self, path: &Path) { pub(super) fn remove_recent(&mut self, path: &Path) {
self.recent_files.retain(|r| r.as_path() != path); self.recent_files.retain(|r| r.as_path() != path);
self.recent_thumbs.remove(path);
remove_cached_copies([path.to_path_buf()]); remove_cached_copies([path.to_path_buf()]);
self.save_config(); self.save_config();
} }
@ -82,6 +112,9 @@ impl OpenCADStudio {
let evicted = self let evicted = self
.recent_files .recent_files
.split_off(self.recent_limit.min(self.recent_files.len())); .split_off(self.recent_limit.min(self.recent_files.len()));
for path in &evicted {
self.recent_thumbs.remove(path);
}
remove_cached_copies(evicted); remove_cached_copies(evicted);
self.save_config(); self.save_config();
} }

View file

@ -2829,6 +2829,16 @@ pub(super) fn recent_files_panel<'a>(
.parent() .parent()
.map(|p| p.to_string_lossy().into_owned()) .map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(); .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 // Leading DWG preview thumbnail (fixed box keeps rows aligned even
// when a file has no readable preview). // when a file has no readable preview).

View file

@ -8,12 +8,41 @@ use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
const RECENT_DIRECTORY: &str = "opencadstudio-recent"; 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<u8>,
}
pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> { pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> {
let directory = recent_directory(true).await?; 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(); let options = web_sys::FileSystemGetFileOptions::new();
options.set_create(true); 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 .await
.map_err(js_error)? .map_err(js_error)?
.dyn_into::<web_sys::FileSystemFileHandle>() .dyn_into::<web_sys::FileSystemFileHandle>()
@ -31,28 +60,71 @@ pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> {
pub async fn read(name: &str) -> Result<Vec<u8>, String> { pub async fn read(name: &str) -> Result<Vec<u8>, String> {
let directory = recent_directory(false).await?; 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<Vec<u8>, 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<web_sys::File, String> {
let handle = JsFuture::from(directory.get_file_handle(key))
.await .await
.map_err(js_error)? .map_err(js_error)?
.dyn_into::<web_sys::FileSystemFileHandle>() .dyn_into::<web_sys::FileSystemFileHandle>()
.map_err(js_error)?; .map_err(js_error)?;
let file = JsFuture::from(handle.get_file()) JsFuture::from(handle.get_file())
.await .await
.map_err(js_error)? .map_err(js_error)?
.dyn_into::<web_sys::File>() .dyn_into::<web_sys::File>()
.map_err(js_error)?; .map_err(js_error)
let buffer = JsFuture::from(file.array_buffer()) }
async fn read_blob(blob: &web_sys::Blob) -> Result<Vec<u8>, String> {
let buffer = JsFuture::from(blob.array_buffer())
.await .await
.map_err(js_error)?; .map_err(js_error)?;
Ok(js_sys::Uint8Array::new(&buffer).to_vec()) 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<Option<Thumbnail>, 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> { pub async fn remove(name: &str) -> Result<(), String> {
let directory = recent_directory(false).await?; 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 .await
.map_err(js_error)?; .map_err(js_error);
Ok(()) let _ = JsFuture::from(directory.remove_entry(&thumbnail_key(name))).await;
drawing.map(|_| ())
} }
async fn recent_directory(create: bool) -> Result<web_sys::FileSystemDirectoryHandle, String> { async fn recent_directory(create: bool) -> Result<web_sys::FileSystemDirectoryHandle, String> {
@ -74,12 +146,102 @@ async fn recent_directory(create: bool) -> Result<web_sys::FileSystemDirectoryHa
/// Stable, short OPFS entry name. The original display name remains in /// Stable, short OPFS entry name. The original display name remains in
/// `AppConfig::recent`; only the browser-private cache uses this key. /// `AppConfig::recent`; only the browser-private cache uses this key.
fn cache_key(name: &str) -> String { fn cache_key(name: &str) -> 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; let mut hash = 0xcbf29ce484222325_u64;
for byte in name.as_bytes() { for byte in name.as_bytes() {
hash ^= u64::from(*byte); hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3); hash = hash.wrapping_mul(0x100000001b3);
} }
format!("{hash:016x}.cad") format!("{hash:016x}")
}
fn encode_thumbnail(image: dwg_thumbnailer::RgbaImage) -> Vec<u8> {
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<u8>) -> Option<Thumbnail> {
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<Option<dwg_thumbnailer::RgbaImage>, 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<Vec<u8>, 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 { fn js_error(value: wasm_bindgen::JsValue) -> String {