feat(thumbnails): embed DWG previews and wire cross-platform file thumbnails

Round-trip a raster preview through the DWG's embedded preview slot and
surface it to the OS file managers, so drawings show their contents in
Explorer / Finder / Nautilus instead of a generic document icon.

OCS:
* io::thumbnail — rasterize the scene to a BMP DIB on save (embedded via
  acadrust's new Preview type) and read it back on open for the Start
  page recent list; `--dwg-thumbnail IN OUT SIZE` CLI extracts a badged
  PNG for external thumbnailers.
* io::file_association::install_thumbnailer — self-install the OS
  integration on startup: Linux writes a .thumbnailer + hicolor mimetype
  icons; Windows registers the IThumbnailProvider DLL under HKCU.

Shared core:
* crates/dwg-thumbnailer — lean (image-only) preview extractor + the
  `badge_dwg` full-width "DWG" band, used by every platform so the
  ribbon is single-sourced. Ships an rlib+staticlib.
* crates/dwg-thumbnailer-win — IThumbnailProvider COM in-proc server
  (cfg(windows), CI-built as a workspace member).
* macos/ — QuickLook thumbnail extension (Swift + C-ABI bridge to the
  core staticlib), assembled into a .appex.

Icons & packaging (single SVG source -> per-platform assets in CI):
* assets/mimetypes/image-vnd.{dwg,dxf}.svg — themed file icons.
* packaging: WiX ships dwg/dxf.ico + the thumbnail DLL and points the
  ProgIds at them; Info.plist gets CFBundleTypeIconFile + the QuickLook
  extension in Contents/PlugIns.
* release.yml — generate .ico (Windows) and .icns (macOS) from the SVGs,
  build the QuickLook .appex, and bundle everything.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-11 01:27:17 +03:00
1 parent 5c4ba97c5f
commit ba3b3202e8
20 files changed
+1085 -76

No files matched your search

+5
View File
@@ -67,6 +67,11 @@ pub struct Cli {
/// Internal: run as the plugin runner child process.
#[arg(long, value_names = ["SOCKET", "CDYLIB"], num_args = 2, hide = true)]
pub ocs_plugin_runner: Option<Vec<String>>,
/// Internal: write a DWG's embedded preview to a PNG for the OS file-manager
/// thumbnailer (`<IN> <OUT> <SIZE>`). Handled before the GUI starts.
#[arg(long, value_names = ["IN", "OUT", "SIZE"], num_args = 3, hide = true)]
pub dwg_thumbnail: Option<Vec<String>>,
}
/// GUI startup configuration, handed from `main` to `app::boot` out-of-band
+124
View File
@@ -80,6 +80,26 @@ pub fn unregister_handler() -> Result<(), String> {
}
}
/// Register (or refresh) the OS file-manager integration for DWG/DXF, so files
/// show OCS thumbnails (embedded preview) or, when there is none, a distinct
/// OCS DWG/DXF icon. Idempotent, best-effort, silent. On Linux this writes a
/// freedesktop `.thumbnailer` (pointing at this binary's `--dwg-thumbnail` mode)
/// plus the mimetype icons under `$XDG_DATA_HOME`; on Windows it registers the
/// bundled thumbnail-provider DLL.
pub fn install_thumbnailer() {
#[cfg(target_os = "linux")]
{
let _ = linux_impl::install_thumbnailer();
let _ = linux_impl::install_mime_icons();
}
#[cfg(target_os = "windows")]
{
let _ = windows_impl::install_thumbnailer();
}
// macOS: the QuickLook thumbnail extension is bundled inside the .app and
// registered by LaunchServices automatically — nothing to do at runtime.
}
/// Try to make this app the default handler for .dwg and .dxf. Returns a short
/// human-readable status string on success, or an error message on failure.
pub async fn set_default_app() -> Result<String, String> {
@@ -233,6 +253,16 @@ mod windows_impl {
RegDeleteTreeW(HKEY_CURRENT_USER, w.as_ptr());
}
}
// Also drop the DWG thumbnail-provider registration.
for key in [
format!(r"Software\Classes\CLSID\{THUMB_CLSID}"),
format!(r"Software\Classes\.dwg\ShellEx\{THUMB_IID}"),
] {
let w = wide(&key);
unsafe {
RegDeleteTreeW(HKEY_CURRENT_USER, w.as_ptr());
}
}
Ok(())
}
@@ -276,6 +306,41 @@ mod windows_impl {
Ok(())
}
/// CLSID of the DWG thumbnail provider (must match `dwg-thumbnailer-win`).
const THUMB_CLSID: &str = "{8F2A9C41-3B6E-4E2D-9C7A-1E0B5D6F42AA}";
/// The shell interface id Explorer looks up under `.dwg\ShellEx`.
const THUMB_IID: &str = "{e357fccd-a995-4576-b01f-234630154e96}";
/// Register the bundled DWG thumbnail provider DLL under `HKCU` — the same
/// keys `regsvr32` would write, but per-user (no admin) and driven by this
/// app on startup. No-op if `dwg_thumbnailer_win.dll` isn't shipped next to
/// the exe. Idempotent (the values are stable across launches).
pub(super) fn install_thumbnailer() -> Result<(), String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let dll = exe
.parent()
.ok_or("no executable directory")?
.join("dwg_thumbnailer_win.dll");
if !dll.exists() {
return Ok(()); // provider DLL not bundled — nothing to register
}
let dll = dll.to_string_lossy();
let clsid_base = format!(r"Software\Classes\CLSID\{THUMB_CLSID}");
set_string(&clsid_base, None, "OpenCADStudio DWG Thumbnail Provider")?;
set_string(&format!(r"{clsid_base}\InprocServer32"), None, &dll)?;
set_string(
&format!(r"{clsid_base}\InprocServer32"),
Some("ThreadingModel"),
"Apartment",
)?;
set_string(
&format!(r"Software\Classes\.dwg\ShellEx\{THUMB_IID}"),
None,
THUMB_CLSID,
)?;
Ok(())
}
/// Create a per-user ProgID (`HKCU\Software\Classes\<progid>`) with an icon
/// and an open command, mirroring one of the MSI's per-machine ProgIDs.
fn register_progid(exe: &str, progid: &str, description: &str) -> Result<(), String> {
@@ -372,6 +437,33 @@ mod linux_impl {
use super::APP_ID;
use std::path::{Path, PathBuf};
/// Write (idempotently) a `.thumbnailer` that points this binary's
/// `--dwg-thumbnail` mode at DWG MIME types, so file managers render the
/// embedded preview. `~/.local/bin` need not be on PATH — the entry uses the
/// binary's absolute path (or `$APPIMAGE`).
pub(super) fn install_thumbnailer() -> Result<(), String> {
let exec = std::env::var_os("APPIMAGE")
.map(PathBuf::from)
.or_else(|| std::env::current_exe().ok())
.ok_or("could not determine the executable path")?;
let exec = exec.to_string_lossy();
let dir = data_home()?.join("thumbnailers");
let path = dir.join(format!("{APP_ID}-dwg.thumbnailer"));
let contents = format!(
"[Thumbnailer Entry]\n\
TryExec={exec}\n\
Exec={exec} --dwg-thumbnail %i %o %s\n\
MimeType=image/vnd.dwg;image/x-dwg;application/x-dwg;application/acad;application/x-autocad;\n"
);
// Unchanged since last launch → skip the write.
if std::fs::read_to_string(&path).ok().as_deref() == Some(contents.as_str()) {
return Ok(());
}
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
std::fs::write(&path, &contents).map_err(|e| e.to_string())?;
Ok(())
}
/// Write a user-level .desktop file pointing at the running binary, then
/// refresh the MIME cache so file managers list us under "Open with".
/// Skipped when a system package already registered the app, so we don't
@@ -542,6 +634,38 @@ mod linux_impl {
Ok(())
}
/// Install the OCS DWG/DXF file-type icons into the user's hicolor theme
/// (`mimetypes/image-vnd.dwg.svg`, `...dxf.svg`), so file managers show a
/// distinct icon for DWG/DXF files that have no thumbnail. Idempotent.
pub(super) fn install_mime_icons() -> Result<(), String> {
static DWG_ICON: &[u8] = include_bytes!("../../assets/mimetypes/image-vnd.dwg.svg");
static DXF_ICON: &[u8] = include_bytes!("../../assets/mimetypes/image-vnd.dxf.svg");
let dir = data_home()?.join("icons/hicolor/scalable/mimetypes");
let mut changed = false;
for (name, bytes) in [
("image-vnd.dwg.svg", DWG_ICON),
("image-vnd.dxf.svg", DXF_ICON),
] {
let path = dir.join(name);
if std::fs::read(&path).ok().as_deref() == Some(bytes) {
continue;
}
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
changed = true;
}
if changed {
let hicolor = data_home()
.map(|d| d.join("icons/hicolor"))
.unwrap_or_default();
let _ = std::process::Command::new("gtk-update-icon-cache")
.args(["--force", "--quiet", &hicolor.to_string_lossy()])
.status();
}
Ok(())
}
fn data_home() -> Result<PathBuf, String> {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
+41 -72
View File
@@ -1,9 +1,12 @@
//! CPU wire-raster thumbnail for the DWG preview image.
//! DWG preview thumbnails.
//!
//! Rasterizes the current layout's wire set into a small bitmap and returns it
//! as an [`acadrust::Preview`] (a Windows DIB, universally accepted as a DWG
//! preview across versions). Embedded on save so OCS drawings show a thumbnail
//! in file browsers and other CAD applications.
//! - [`from_scene`] / [`from_wires`] rasterize the current layout's wires into a
//! small DIB [`acadrust::Preview`], embedded on save so OCS drawings show a
//! thumbnail in file browsers and other CAD apps.
//! - [`read_handle`] / [`extract_to_png`] read a DWG's *embedded* preview back
//! for the Start page and the OS file-manager thumbnailer. Extraction lives in
//! the shared [`dwg_thumbnailer`] core crate (also used by the Windows/macOS
//! thumbnail handlers).
use acadrust::{Preview, PreviewFormat};
use image::{ImageFormat, Rgb, RgbImage};
@@ -114,76 +117,31 @@ fn to_rgb(c: [f32; 4]) -> [u8; 3] {
]
}
/// Cheaply read ONLY the embedded preview from a DWG (file header + preview
/// bytes, no full document parse) and decode it to an iced image handle.
/// `None` for DXF/other files, a missing or empty preview, or a format the
/// `image` crate can't decode (WMF). Used to show recent-file thumbnails.
pub fn read_handle(path: &std::path::Path) -> Option<iced::widget::image::Handle> {
decode(&read_preview(path)?)
}
/// Read the preview image bytes straight from the file's raw preview offset.
fn read_preview(path: &std::path::Path) -> Option<Preview> {
use std::io::{Read, Seek, SeekFrom};
let mut f = std::fs::File::open(path).ok()?;
let mut ver = [0u8; 6];
f.read_exact(&mut ver).ok()?;
if &ver[..2] != b"AC" {
return None; // not a DWG (DXF has no thumbnail)
}
// Preview seeker: absolute file offset at header byte 0x0D.
f.seek(SeekFrom::Start(0x0D)).ok()?;
let mut a = [0u8; 4];
f.read_exact(&mut a).ok()?;
let base = i32::from_le_bytes(a);
if base <= 0 {
return None;
}
let base = base as u64;
f.seek(SeekFrom::Start(base)).ok()?;
let mut head = [0u8; 20];
f.read_exact(&mut head).ok()?;
let overall = acadrust::io::dwg::preview::overall_size(&head)?;
if overall == 0 || overall > 64 * 1024 * 1024 {
return None;
}
let total = acadrust::io::dwg::preview::container_len(overall);
f.seek(SeekFrom::Start(base)).ok()?;
let mut buf = vec![0u8; total];
f.read_exact(&mut buf).ok()?;
acadrust::io::dwg::preview::parse_preview(&buf, base)
}
/// Decode a stored preview to an iced RGBA handle.
fn decode(p: &Preview) -> Option<iced::widget::image::Handle> {
let img = match p.format {
PreviewFormat::Png => image::load_from_memory_with_format(&p.data, ImageFormat::Png).ok()?,
PreviewFormat::Bmp => {
image::load_from_memory_with_format(&dib_to_bmp(&p.data), ImageFormat::Bmp).ok()?
/// Read a DWG's embedded preview and write it as a PNG at `output`, scaled so
/// its longest edge is at most `size`. Returns `false` on any failure (no
/// preview, undecodable, write error) so the OS thumbnailer falls back to a
/// generic icon. Backs the hidden `--dwg-thumbnail` mode the installed
/// freedesktop `.thumbnailer` invokes. Extraction lives in the shared
/// [`dwg_thumbnailer`] core (also used by the Windows/macOS handlers).
pub fn extract_to_png(input: &std::path::Path, output: &std::path::Path, size: u32) -> bool {
match dwg_thumbnailer::extract(input, size) {
Some(mut img) => {
// Bottom-left "DWG" ribbon so the format reads at a glance in the
// file manager (the Start-page `read_handle` stays unbadged).
dwg_thumbnailer::badge_dwg(&mut img);
img.save_with_format(output, ImageFormat::Png).is_ok()
}
PreviewFormat::Wmf => return None,
};
let rgba = img.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
Some(iced::widget::image::Handle::from_rgba(w, h, rgba.into_raw()))
None => false,
}
}
/// Reconstruct a full in-memory BMP from a stored DIB (prepend the 14-byte
/// `BITMAPFILEHEADER`) so a BMP decoder can read it.
fn dib_to_bmp(dib: &[u8]) -> Vec<u8> {
if dib.len() < 16 {
return Vec::new();
}
let bi_size = u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]) as usize;
let bpp = u16::from_le_bytes([dib[14], dib[15]]) as usize;
let palette = if (1..=8).contains(&bpp) { (1usize << bpp) * 4 } else { 0 };
let mut v = Vec::with_capacity(14 + dib.len());
v.extend_from_slice(b"BM");
v.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
v.extend_from_slice(&0u32.to_le_bytes());
v.extend_from_slice(&((14 + bi_size + palette) as u32).to_le_bytes());
v.extend_from_slice(dib);
v
/// Read a DWG's embedded preview and decode it to an iced image handle for the
/// Start page's recent-file thumbnails. `None` for DXF/other files, a missing
/// preview, or an undecodable format (WMF).
pub fn read_handle(path: &std::path::Path) -> Option<iced::widget::image::Handle> {
let img = dwg_thumbnailer::extract(path, MAX_DIM)?;
let (w, h) = (img.width(), img.height());
Some(iced::widget::image::Handle::from_rgba(w, h, img.into_raw()))
}
#[cfg(test)]
@@ -198,6 +156,17 @@ mod tests {
}
}
/// Prepend a `BITMAPFILEHEADER` to a 24-bit DIB so `image` can decode it.
fn dib_to_bmp(dib: &[u8]) -> Vec<u8> {
let mut v = Vec::with_capacity(14 + dib.len());
v.extend_from_slice(b"BM");
v.extend_from_slice(&((14 + dib.len()) as u32).to_le_bytes());
v.extend_from_slice(&0u32.to_le_bytes());
v.extend_from_slice(&54u32.to_le_bytes()); // 14 + 40, no palette (24-bit)
v.extend_from_slice(dib);
v
}
#[test]
fn empty_input_yields_none() {
assert!(from_wires(&[], [0.0, 0.0, 0.0, 1.0]).is_none());
+20
View File
@@ -51,6 +51,19 @@ fn main() -> iced::Result {
return Ok(());
}
// Thumbnail mode: the OS file-manager thumbnailer invokes us as
// `--dwg-thumbnail <in> <out> <size>`. Extract the DWG's embedded
// preview to a PNG and exit — never touch the GUI.
if let Some(a) = &args.dwg_thumbnail {
let size = a.get(2).and_then(|s| s.parse().ok()).unwrap_or(256);
let ok = io::thumbnail::extract_to_png(
std::path::Path::new(&a[0]),
std::path::Path::new(&a[1]),
size,
);
std::process::exit(if ok { 0 } else { 1 });
}
// Opt-in logging. `--log LEVEL` seeds RUST_LOG; the subscriber then
// surfaces wgpu / iced / winit diagnostics that are otherwise silent.
if let Some(level) = &args.log {
@@ -110,6 +123,13 @@ fn main() -> iced::Result {
read_only: args.read_only,
script_lines,
});
// Register (or refresh) the freedesktop DWG thumbnailer so file managers
// show OCS-embedded previews. Idempotent, best-effort, no consent step —
// it only points a `.thumbnailer` at this same binary's `--dwg-thumbnail`
// mode. Silently ignored on failure or non-Linux.
io::file_association::install_thumbnailer();
app::run()
}
}