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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-11 01:27:17 +03:00
commit ba3b3202e8
20 changed files with 1085 additions and 76 deletions

View file

@ -93,6 +93,17 @@ jobs:
-define icon:auto-resize=16,24,32,48,64,128,256 ` -define icon:auto-resize=16,24,32,48,64,128,256 `
packaging/windows/AppIcon.ico packaging/windows/AppIcon.ico
- name: Convert DWG/DXF file icons to ICO
shell: pwsh
run: |
# DWG/DXF Explorer file icons, from the single-source mimetype SVGs.
magick assets/mimetypes/image-vnd.dwg.svg `
-define icon:auto-resize=16,24,32,48,64,128,256 `
packaging/windows/dwg.ico
magick assets/mimetypes/image-vnd.dxf.svg `
-define icon:auto-resize=16,24,32,48,64,128,256 `
packaging/windows/dxf.ico
- name: Build - name: Build
env: env:
OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }}
@ -237,14 +248,60 @@ jobs:
done done
iconutil -c icns OpenCADStudio.iconset -o AppIcon.icns iconutil -c icns OpenCADStudio.iconset -o AppIcon.icns
- name: Build DWG/DXF document .icns from the same SVG sources
run: |
# Finder document icons (Info.plist CFBundleTypeIconFile = DWG/DXF),
# generated from the single-source mimetype SVGs — never hand-drawn.
for T in dwg dxf; do
mkdir -p "$T.iconset"
for SIZE in 16 32 64 128 256 512 1024; do
rsvg-convert -w $SIZE -h $SIZE "assets/mimetypes/image-vnd.$T.svg" \
-o "$T.iconset/icon_${SIZE}x${SIZE}.png"
done
for BASE in 16 32 128 256 512; do
cp "$T.iconset/icon_$((BASE*2))x$((BASE*2)).png" \
"$T.iconset/icon_${BASE}x${BASE}@2x.png"
done
iconutil -c icns "$T.iconset" -o "$(echo "$T" | tr a-z A-Z).icns"
done
- name: Build DWG QuickLook thumbnail extension (.appex)
run: |
# A QuickLook thumbnail App Extension, built without an Xcode project:
# swiftc compiles the provider, links the Rust core static lib (its C
# ABI) + the system frameworks, and we hand-assemble the .appex bundle.
# `cargo build` already produced libdwg_thumbnailer.a (workspace member,
# crate-type staticlib). The whole app is ad-hoc signed below, which
# deep-signs the embedded extension too.
set -e
VERSION="${{ github.ref_name }}"; VERSION="${VERSION#v}"; [ -z "$VERSION" ] && VERSION="0.0.0"
EXT=DWGThumbnail.appex
rm -rf "$EXT"; mkdir -p "$EXT/Contents/MacOS"
swiftc \
-sdk "$(xcrun --sdk macosx --show-sdk-path)" \
-target arm64-apple-macos11 \
-O -parse-as-library -application-extension \
-module-name DWGThumbnail \
-import-objc-header crates/dwg-thumbnailer/macos/dwg_thumbnailer.h \
crates/dwg-thumbnailer/macos/ThumbnailProvider.swift \
-L target/aarch64-apple-darwin/release -ldwg_thumbnailer \
-framework QuickLookThumbnailing -framework CoreGraphics \
-framework ImageIO -framework Foundation \
-Xlinker -e -Xlinker _NSExtensionMain \
-o "$EXT/Contents/MacOS/DWGThumbnail"
sed "s/__VERSION__/$VERSION/g" crates/dwg-thumbnailer/macos/Info.plist > "$EXT/Contents/Info.plist"
- name: Assemble .app bundle - name: Assemble .app bundle
run: | run: |
APP=OpenCADStudio.app APP=OpenCADStudio.app
rm -rf "$APP" rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/PlugIns"
cp target/aarch64-apple-darwin/release/OpenCADStudio "$APP/Contents/MacOS/OpenCADStudio" cp target/aarch64-apple-darwin/release/OpenCADStudio "$APP/Contents/MacOS/OpenCADStudio"
chmod +x "$APP/Contents/MacOS/OpenCADStudio" chmod +x "$APP/Contents/MacOS/OpenCADStudio"
cp AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" cp AppIcon.icns "$APP/Contents/Resources/AppIcon.icns"
cp DWG.icns DXF.icns "$APP/Contents/Resources/"
# QuickLook thumbnail extension.
cp -R DWGThumbnail.appex "$APP/Contents/PlugIns/"
# Substitute version into Info.plist. # Substitute version into Info.plist.
VERSION="${{ github.ref_name }}" VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}" VERSION="${VERSION#v}"

16
Cargo.lock generated
View file

@ -21,6 +21,7 @@ dependencies = [
"clap", "clap",
"console_error_panic_hook", "console_error_panic_hook",
"cosmic-text", "cosmic-text",
"dwg-thumbnailer",
"env_logger", "env_logger",
"flate2", "flate2",
"fontdb", "fontdb",
@ -1414,6 +1415,21 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b"
[[package]]
name = "dwg-thumbnailer"
version = "0.1.0"
dependencies = [
"image",
]
[[package]]
name = "dwg-thumbnailer-win"
version = "0.1.0"
dependencies = [
"dwg-thumbnailer",
"windows 0.58.0",
]
[[package]] [[package]]
name = "ecb" name = "ecb"
version = "0.1.2" version = "0.1.2"

View file

@ -5,7 +5,15 @@ edition = "2021"
build = "build.rs" build = "build.rs"
[workspace] [workspace]
members = ["crates/ocs_plugin_api"] # `dwg-thumbnailer-win` is a Windows-only COM DLL (`#![cfg(windows)]`): it
# compiles to an empty cdylib on Linux/macOS and to the real handler on Windows,
# where the `build-windows` CI job (`cargo build --release`) compiles and thus
# verifies it.
members = [
"crates/ocs_plugin_api",
"crates/dwg-thumbnailer",
"crates/dwg-thumbnailer-win",
]
# Embed the application icon into the Windows .exe so Explorer, the taskbar, # Embed the application icon into the Windows .exe so Explorer, the taskbar,
# the Start-menu tile and file-association entries show it (issue #107). # the Start-menu tile and file-association entries show it (issue #107).
@ -50,6 +58,8 @@ clap = { version = "4", features = ["derive"] }
# Opt-in logging via --log / RUST_LOG (surfaces wgpu / iced / winit diagnostics). # Opt-in logging via --log / RUST_LOG (surfaces wgpu / iced / winit diagnostics).
env_logger = "0.11" env_logger = "0.11"
acadrust = "0.4" acadrust = "0.4"
# Shared DWG embedded-preview extraction (Start-page + file-manager thumbnails).
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1" flate2 = "1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
inventory = "0.3" inventory = "0.3"

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- OpenCADStudio DWG file-type icon (freedesktop mimetypes/image-vnd.dwg). -->
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<!-- Page -->
<path d="M12 3 H30 L40 13 V43 Q40 45 38 45 H12 Q10 45 10 43 V5 Q10 3 12 3 Z"
fill="#F4F4F5" stroke="#C4C4C8" stroke-width="0.8"/>
<!-- Folded corner -->
<path d="M30 3 V13 H40 Z" fill="#D9D9DC"/>
<path d="M30 3 V13 H40" fill="none" stroke="#C4C4C8" stroke-width="0.8"/>
<!-- Format band -->
<rect x="7" y="27" width="34" height="13" rx="2" fill="#B03020"/>
<text x="24" y="36.5" font-family="'DejaVu Sans','Helvetica',sans-serif"
font-weight="700" font-size="9" letter-spacing="0.5"
fill="#FFFFFF" text-anchor="middle">DWG</text>
</svg>

After

Width:  |  Height:  |  Size: 788 B

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- OpenCADStudio DXF file-type icon (freedesktop mimetypes/image-vnd.dxf). -->
<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<!-- Page -->
<path d="M12 3 H30 L40 13 V43 Q40 45 38 45 H12 Q10 45 10 43 V5 Q10 3 12 3 Z"
fill="#F4F4F5" stroke="#C4C4C8" stroke-width="0.8"/>
<!-- Folded corner -->
<path d="M30 3 V13 H40 Z" fill="#D9D9DC"/>
<path d="M30 3 V13 H40" fill="none" stroke="#C4C4C8" stroke-width="0.8"/>
<!-- Format band -->
<rect x="7" y="27" width="34" height="13" rx="2" fill="#2C6FB0"/>
<text x="24" y="36.5" font-family="'DejaVu Sans','Helvetica',sans-serif"
font-weight="700" font-size="9" letter-spacing="0.5"
fill="#FFFFFF" text-anchor="middle">DXF</text>
</svg>

After

Width:  |  Height:  |  Size: 788 B

View file

@ -0,0 +1,26 @@
[package]
name = "dwg-thumbnailer-win"
version = "0.1.0"
edition = "2021"
description = "Windows Explorer thumbnail handler (IThumbnailProvider) for DWG files"
license = "MIT OR Apache-2.0"
# A COM in-proc server DLL. Off Windows this compiles to an empty cdylib so the
# workspace still builds on Linux/macOS; the real handler is cfg(windows) only.
[lib]
crate-type = ["cdylib"]
[dependencies]
dwg_thumbnailer = { package = "dwg-thumbnailer", path = "../dwg-thumbnailer" }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = [
"implement",
"Win32_Foundation",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Registry",
"Win32_UI_Shell",
"Win32_UI_Shell_PropertiesSystem",
"Win32_Graphics_Gdi",
] }

View file

@ -0,0 +1,279 @@
//! Windows Explorer thumbnail handler for DWG files.
//!
//! Implements `IThumbnailProvider` (+ `IInitializeWithFile`) as a COM in-proc
//! server. Explorer instantiates it for `.dwg` files, hands it the path, then
//! calls `GetThumbnail`, which extracts the DWG's embedded preview via the
//! shared [`dwg_thumbnailer`] core and returns it as an HBITMAP.
//!
//! ## Build & register (on Windows, elevated)
//! ```text
//! cargo build -p dwg-thumbnailer-win --release
//! regsvr32 dwg_thumbnailer_win.dll :: register
//! regsvr32 /u dwg_thumbnailer_win.dll :: unregister
//! ```
//! Then restart Explorer (or run `ie4uinit.exe -show`) to refresh thumbnails.
//!
//! NOTE: this module is `cfg(windows)`-only and was authored on a Linux host,
//! so it has NOT been compiled or tested. Build and test it on Windows; minor
//! fixups may be needed for your exact `windows` crate version.
#![cfg(windows)]
use std::cell::RefCell;
use std::ffi::c_void;
use windows::core::{implement, IUnknown, Interface, GUID, HRESULT, PCWSTR};
use windows::Win32::Foundation::{
CLASS_E_CLASSNOTAVAILABLE, CLASS_E_NOAGGREGATION, E_FAIL, E_INVALIDARG, HMODULE, S_OK,
WIN32_ERROR,
};
use windows::Win32::Graphics::Gdi::{
CreateDIBSection, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, HBITMAP, HDC,
};
use windows::Win32::System::Com::{IClassFactory, IClassFactory_Impl};
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
use windows::Win32::System::Registry::{
RegCloseKey, RegCreateKeyExW, RegDeleteTreeW, RegSetValueExW, HKEY, HKEY_CLASSES_ROOT,
KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ,
};
use windows::Win32::System::SystemServices::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH};
use windows::Win32::UI::Shell::PropertiesSystem::{IInitializeWithFile, IInitializeWithFile_Impl};
use windows::Win32::UI::Shell::{
IThumbnailProvider, IThumbnailProvider_Impl, SHChangeNotify, SHCNE_ASSOCCHANGED, SHCNF_IDLIST,
WTS_ALPHATYPE, WTSAT_ARGB,
};
/// CLSID of this thumbnail provider (stable; used in the registry keys).
/// {8F2A9C41-3B6E-4E2D-9C7A-1E0B5D6F42AA}
const CLSID_DWG_THUMB: GUID = GUID::from_u128(0x8F2A9C41_3B6E_4E2D_9C7A_1E0B5D6F42AA);
/// The interface id Explorer looks up under `.dwg\ShellEx`.
const IID_ITHUMBNAILPROVIDER: &str = "{e357fccd-a995-4576-b01f-234630154e96}";
/// Our own module handle, captured in `DllMain` — needed to write the DLL path
/// into `InprocServer32` during registration.
static mut SELF_HMODULE: HMODULE = HMODULE(std::ptr::null_mut());
#[no_mangle]
extern "system" fn DllMain(hinst: HMODULE, reason: u32, _reserved: *mut c_void) -> bool {
if reason == DLL_PROCESS_ATTACH {
unsafe { SELF_HMODULE = hinst };
} else if reason == DLL_PROCESS_DETACH {
}
true
}
// ── The provider COM object ──────────────────────────────────────────────────
#[implement(IThumbnailProvider, IInitializeWithFile)]
#[derive(Default)]
struct DwgThumbProvider {
path: RefCell<Option<String>>,
}
impl IInitializeWithFile_Impl for DwgThumbProvider_Impl {
fn Initialize(&self, pszfilepath: &PCWSTR, _grfmode: u32) -> windows::core::Result<()> {
let path = unsafe { pszfilepath.to_string() }.map_err(|_| windows::core::Error::from(E_INVALIDARG))?;
*self.path.borrow_mut() = Some(path);
Ok(())
}
}
impl IThumbnailProvider_Impl for DwgThumbProvider_Impl {
fn GetThumbnail(
&self,
cx: u32,
phbmp: *mut HBITMAP,
pdwalpha: *mut WTS_ALPHATYPE,
) -> windows::core::Result<()> {
let path = self.path.borrow().clone().ok_or(windows::core::Error::from(E_FAIL))?;
let mut img = dwg_thumbnailer::extract(std::path::Path::new(&path), cx.max(1))
.ok_or(windows::core::Error::from(E_FAIL))?;
dwg_thumbnailer::badge_dwg(&mut img); // full-width "DWG" band
let hbmp = unsafe { rgba_to_hbitmap(&img)? };
unsafe {
*phbmp = hbmp;
*pdwalpha = WTSAT_ARGB;
}
Ok(())
}
}
/// Build a 32-bit top-down BGRA `HBITMAP` from an RGBA image.
unsafe fn rgba_to_hbitmap(img: &image::RgbaImage) -> windows::core::Result<HBITMAP> {
let (w, h) = (img.width() as i32, img.height() as i32);
let bi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: w,
biHeight: -h, // negative → top-down rows
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
..Default::default()
},
..Default::default()
};
let mut bits: *mut c_void = std::ptr::null_mut();
let hbmp = CreateDIBSection(HDC::default(), &bi, DIB_RGB_COLORS, &mut bits, None, 0)?;
if bits.is_null() {
return Err(E_FAIL.into());
}
let dst = std::slice::from_raw_parts_mut(bits as *mut u8, (w * h * 4) as usize);
for (i, px) in img.pixels().enumerate() {
let [r, g, b, a] = px.0;
dst[i * 4] = b;
dst[i * 4 + 1] = g;
dst[i * 4 + 2] = r;
dst[i * 4 + 3] = a;
}
Ok(hbmp)
}
// ── Class factory ────────────────────────────────────────────────────────────
#[implement(IClassFactory)]
struct Factory;
impl IClassFactory_Impl for Factory_Impl {
fn CreateInstance(
&self,
punkouter: Option<&IUnknown>,
riid: *const GUID,
ppvobject: *mut *mut c_void,
) -> windows::core::Result<()> {
if punkouter.is_some() {
return Err(CLASS_E_NOAGGREGATION.into());
}
let provider: IUnknown = DwgThumbProvider::default().into();
unsafe { provider.query(&*riid, ppvobject).ok() }
}
fn LockServer(&self, _flock: windows::core::BOOL) -> windows::core::Result<()> {
Ok(())
}
}
// ── DLL exports ──────────────────────────────────────────────────────────────
#[no_mangle]
extern "system" fn DllGetClassObject(
rclsid: *const GUID,
riid: *const GUID,
ppv: *mut *mut c_void,
) -> HRESULT {
unsafe {
if *rclsid != CLSID_DWG_THUMB {
return CLASS_E_CLASSNOTAVAILABLE;
}
let factory: IClassFactory = Factory.into();
factory.query(&*riid, ppv)
}
}
#[no_mangle]
extern "system" fn DllCanUnloadNow() -> HRESULT {
// Simplification: report unloadable only when COM has released everything.
// A conservative always-`S_FALSE` keeps the DLL resident (safe, if leakier).
windows::Win32::Foundation::S_FALSE
}
#[no_mangle]
extern "system" fn DllRegisterServer() -> HRESULT {
match register(true) {
Ok(()) => S_OK,
Err(e) => e.code(),
}
}
#[no_mangle]
extern "system" fn DllUnregisterServer() -> HRESULT {
match register(false) {
Ok(()) => S_OK,
Err(e) => e.code(),
}
}
// ── Registration (HKCR) ──────────────────────────────────────────────────────
fn module_path() -> windows::core::Result<String> {
let mut buf = [0u16; 1024];
let len = unsafe { GetModuleFileNameW(SELF_HMODULE, &mut buf) };
if len == 0 {
return Err(E_FAIL.into());
}
Ok(String::from_utf16_lossy(&buf[..len as usize]))
}
fn register(install: bool) -> windows::core::Result<()> {
let clsid = format!("{{{:?}}}", CLSID_DWG_THUMB); // "{8F2A9C41-...}"
let clsid_key = format!("CLSID\\{clsid}");
let inproc_key = format!("{clsid_key}\\InprocServer32");
let dwg_shellex = format!(".dwg\\ShellEx\\{IID_ITHUMBNAILPROVIDER}");
if install {
let dll = module_path()?;
set_value(&clsid_key, None, "OpenCADStudio DWG Thumbnail Provider")?;
set_value(&inproc_key, None, &dll)?;
set_value(&inproc_key, Some("ThreadingModel"), "Apartment")?;
set_value(&dwg_shellex, None, &clsid)?;
} else {
let _ = delete_tree(&clsid_key);
let _ = delete_tree(&dwg_shellex);
}
unsafe { SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None) };
Ok(())
}
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
fn set_value(sub: &str, name: Option<&str>, value: &str) -> windows::core::Result<()> {
let sub_w = wide(sub);
let mut hkey = HKEY::default();
let rc = unsafe {
RegCreateKeyExW(
HKEY_CLASSES_ROOT,
PCWSTR(sub_w.as_ptr()),
0,
PCWSTR::null(),
REG_OPTION_NON_VOLATILE,
KEY_WRITE,
None,
&mut hkey,
None,
)
};
if rc != WIN32_ERROR(0) {
return Err(E_FAIL.into());
}
let val_w = wide(value);
let bytes =
unsafe { std::slice::from_raw_parts(val_w.as_ptr() as *const u8, val_w.len() * 2) };
let name_w = name.map(wide);
let rc = unsafe {
RegSetValueExW(
hkey,
name_w.as_ref().map_or(PCWSTR::null(), |n| PCWSTR(n.as_ptr())),
0,
REG_SZ,
Some(bytes),
)
};
unsafe {
let _ = RegCloseKey(hkey);
}
if rc != WIN32_ERROR(0) {
return Err(E_FAIL.into());
}
Ok(())
}
fn delete_tree(sub: &str) -> windows::core::Result<()> {
let sub_w = wide(sub);
let rc = unsafe { RegDeleteTreeW(HKEY_CLASSES_ROOT, PCWSTR(sub_w.as_ptr())) };
if rc != WIN32_ERROR(0) {
return Err(E_FAIL.into());
}
Ok(())
}

View file

@ -0,0 +1,19 @@
[package]
name = "dwg-thumbnailer"
version = "0.1.0"
edition = "2021"
description = "Extracts the embedded preview image from a DWG file for OS file-manager thumbnails"
license = "MIT OR Apache-2.0"
# Shared extraction core. Consumers: OpenCADStudio (Linux, via its own
# `--dwg-thumbnail` mode), the Windows `IThumbnailProvider` DLL, and the macOS
# QuickLook extension.
# rlib — OCS and the Windows DLL call `extract` directly.
# staticlib — the macOS QuickLook extension links the C ABI (`dwg_thumbnail_png`).
[lib]
name = "dwg_thumbnailer"
path = "src/lib.rs"
crate-type = ["rlib", "staticlib"]
[dependencies]
image = { version = "0.25", default-features = false, features = ["png", "bmp"] }

View file

@ -0,0 +1,80 @@
# DWG thumbnails for OS file managers
`dwg_thumbnailer::extract` reads **only** the embedded preview from a DWG (file
header → preview seeker → parse — never the whole drawing) and returns it as an
`image::RgbaImage`. It's the shared core behind every platform's thumbnail
integration.
```
crates/
dwg-thumbnailer/ this crate — shared core + macOS extension sources
dwg-thumbnailer-win/ Windows IThumbnailProvider COM DLL (depends on the core)
```
---
## Linux (COSMIC, GNOME, Nautilus, Nemo, …) — handled by OpenCADStudio itself
No separate binary. OpenCADStudio embeds this core and, on startup, installs a
freedesktop `.thumbnailer` pointing at its own hidden `--dwg-thumbnail` mode
(see `src/io/file_association.rs::install_thumbnailer`). Launch OCS once and file
managers render DWG thumbnails; clear stale "no thumbnail" cache if needed:
```sh
rm -f ~/.cache/thumbnails/fail/*/*.png
```
---
## Windows (Explorer) — authored, build & test on Windows
A COM in-proc server implementing `IThumbnailProvider`. **Not compiled/tested on
the Linux dev host** — build and verify on Windows.
```bat
cd crates\dwg-thumbnailer-win && cargo build --release
regsvr32 target\release\dwg_thumbnailer_win.dll :: register (elevated)
regsvr32 /u target\release\dwg_thumbnailer_win.dll :: unregister
ie4uinit.exe -show :: refresh thumbnails
```
If `regsvr32`'s self-registration needs adjusting, the equivalent registry keys
are (replace the path):
```reg
Windows Registry Editor Version 5.00
[HKEY_CLASSES_ROOT\CLSID\{8F2A9C41-3B6E-4E2D-9C7A-1E0B5D6F42AA}]
@="OpenCADStudio DWG Thumbnail Provider"
[HKEY_CLASSES_ROOT\CLSID\{8F2A9C41-3B6E-4E2D-9C7A-1E0B5D6F42AA}\InprocServer32]
@="C:\\path\\to\\dwg_thumbnailer_win.dll"
"ThreadingModel"="Apartment"
[HKEY_CLASSES_ROOT\.dwg\ShellEx\{e357fccd-a995-4576-b01f-234630154e96}]
@="{8F2A9C41-3B6E-4E2D-9C7A-1E0B5D6F42AA}"
```
---
## macOS (Finder) — sources provided, build in Xcode
A QuickLook **Thumbnail Extension** (`macos/ThumbnailProvider.swift`) calls the
Rust core's C ABI, linked as a static library. Requires Xcode + a host app.
1. Build the core as a static lib for your arch(s):
```sh
cargo build -p dwg-thumbnailer --release --target aarch64-apple-darwin
# → target/aarch64-apple-darwin/release/libdwg_thumbnailer.a
```
2. In Xcode, add a **Thumbnail Extension** target to a host app.
- Use `macos/Info.plist` (lists the `com.autodesk.dwg` UTI).
- Add `macos/ThumbnailProvider.swift`.
- Add a bridging header that `#include`s `macos/dwg_thumbnailer.h`.
- Link `libdwg_thumbnailer.a` (+ system frameworks it needs).
3. Sign, install the host app, and Finder picks up the extension.
C ABI (see `dwg_thumbnailer.h`):
`dwg_thumbnail_png(path, max_dim, &ptr, &len)` / `dwg_thumbnail_free(ptr, len)`.
---
Formats: DWG BMP-DIB and PNG embedded previews decode. WMF previews and files
without a preview (or DXF) produce no thumbnail — the file manager falls back.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Info.plist for the DWG QuickLook Thumbnail Extension (.appex). -->
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>DWG Thumbnails</string>
<key>CFBundleExecutable</key>
<string>DWGThumbnail</string>
<!-- Must be prefixed with the host app id so it registers as its extension. -->
<key>CFBundleIdentifier</key>
<string>io.github.HakanSeven12.OpenCadStudio.DWGThumbnail</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>DWGThumbnail</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>__VERSION__</string>
<key>CFBundleVersion</key>
<string>__VERSION__</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.quicklook.thumbnail</string>
<key>NSExtensionPrincipalClass</key>
<string>DWGThumbnail.ThumbnailProvider</string>
<key>NSExtensionAttributes</key>
<dict>
<key>QLSupportedContentTypes</key>
<array>
<string>com.autodesk.dwg</string>
</array>
<key>QLThumbnailMinimumDimension</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View file

@ -0,0 +1,49 @@
// macOS QuickLook thumbnail extension for DWG files.
//
// Calls the Rust core (`dwg_thumbnail_png`, linked from libdwg_thumbnailer.a)
// to extract the embedded preview as PNG, then hands it to QuickLook.
//
// Add this file to a "Thumbnail Extension" target, expose the C ABI through a
// bridging header that #includes `dwg_thumbnailer.h`, and link the static lib
// (see README). QLSupportedContentTypes must list the DWG UTI (com.autodesk.dwg).
import QuickLookThumbnailing
import CoreGraphics
import ImageIO
import Foundation
final class ThumbnailProvider: QLThumbnailProvider {
override func provideThumbnail(
for request: QLFileThumbnailRequest,
_ handler: @escaping (QLThumbnailReply?, Error?) -> Void
) {
let maxDim = UInt32(max(request.maximumSize.width, request.maximumSize.height))
var ptr: UnsafeMutablePointer<UInt8>? = nil
var len: Int = 0
let ok = request.fileURL.path.withCString { cpath in
dwg_thumbnail_png(cpath, maxDim, &ptr, &len)
}
guard ok, let p = ptr, len > 0 else {
handler(nil, nil) // no preview QuickLook falls back
return
}
let data = Data(bytes: p, count: len)
dwg_thumbnail_free(p, len)
guard
let src = CGImageSourceCreateWithData(data as CFData, nil),
let cg = CGImageSourceCreateImageAtIndex(src, 0, nil)
else {
handler(nil, nil)
return
}
let size = CGSize(width: cg.width, height: cg.height)
let reply = QLThumbnailReply(contextSize: size) { (ctx: CGContext) -> Bool in
ctx.draw(cg, in: CGRect(origin: .zero, size: size))
return true
}
handler(reply, nil)
}
}

View file

@ -0,0 +1,18 @@
/* Bridging header for the macOS QuickLook extension.
* Exposes the Rust core's C ABI (built as `libdwg_thumbnailer.a`). */
#ifndef DWG_THUMBNAILER_H
#define DWG_THUMBNAILER_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* Extract a DWG preview, encoded as PNG. On success writes a malloc'd buffer to
* *out_ptr / *out_len (free with dwg_thumbnail_free) and returns true. */
bool dwg_thumbnail_png(const char *path_utf8, uint32_t max_dim,
uint8_t **out_ptr, size_t *out_len);
/* Free a buffer returned by dwg_thumbnail_png. */
void dwg_thumbnail_free(uint8_t *ptr, size_t len);
#endif /* DWG_THUMBNAILER_H */

View file

@ -0,0 +1,247 @@
//! Extract the embedded preview image from a DWG file, for OS file-manager
//! thumbnails.
//!
//! Every DWG version stores an (uncompressed) preview at the raw file offset
//! recorded in the file header's preview seeker (byte `0x0D`). This crate reads
//! *only* that — no full document parse — and decodes it to an RGBA image. The
//! preview container is a fixed byte format, so this crate depends only on
//! `image` (no CAD library). Shared by OpenCADStudio (Linux, via its
//! `--dwg-thumbnail` mode), the Windows `IThumbnailProvider`, and the macOS
//! QuickLook extension.
use std::path::Path;
use image::{ImageFormat, RgbaImage};
/// Read the DWG at `path`, extract its embedded preview, and scale it so the
/// longest edge is at most `max_dim` pixels (aspect preserved). Returns `None`
/// for a DXF/other file, a missing or empty preview, or a preview in a format
/// this crate can't decode (WMF).
pub fn extract(path: &Path, max_dim: u32) -> Option<RgbaImage> {
let (format, data) = read_preview(path)?;
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.
const BAND_RGBA: [u8; 4] = [0xB0, 0x30, 0x20, 0xFF];
/// Append a full-width "DWG" band below a thumbnail (rectangular, spanning the
/// whole width) so DWG files read at a glance in the file manager. Grows the
/// image height by the band. Only DWG files ever produce a thumbnail (DXF has no
/// embedded preview and falls back to its file-type icon), so the label is
/// always "DWG".
pub fn badge_dwg(img: &mut RgbaImage) {
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 {
return;
}
// Band height ~20% of the thumbnail width (min 18 px).
let band_h = ((w as f32 * 0.20) as u32).max(18);
// New canvas prefilled with the band colour; the thumbnail covers the top,
// leaving the bottom `band_h` rows as the band.
let mut out = RgbaImage::from_pixel(w, h + band_h, image::Rgba(BAND_RGBA));
image::imageops::overlay(&mut out, img, 0, 0);
// Centre the white "DWG" wordmark in the band (~55% of the band height).
if let Ok(label) = image::load_from_memory_with_format(DWG_LABEL_PNG, ImageFormat::Png) {
let label = label.to_rgba8();
if label.width() > 0 && label.height() > 0 {
let mut target_h = ((band_h as f32 * 0.72) as u32).max(1);
let mut target_w = ((label.width() as f32 * target_h as f32 / label.height() as f32)
as u32)
.max(1);
// Don't let a wide wordmark spill past the thumbnail edges.
let max_w = ((w as f32 * 0.90) as u32).max(1);
if target_w > max_w {
target_w = max_w;
target_h = ((label.height() as f32 * target_w as f32 / label.width() as f32) as u32)
.max(1);
}
let label = image::imageops::thumbnail(&label, target_w, target_h);
let x = ((w as i64 - target_w as i64) / 2).max(0);
let y = h as i64 + (band_h as i64 - target_h as i64) / 2;
image::imageops::overlay(&mut out, &label, x, y);
}
}
*img = out;
}
// ── Preview extraction ───────────────────────────────────────────────────────
#[derive(Clone, Copy)]
enum Fmt {
Bmp,
Png,
}
/// DWG preview container start sentinel — the same 16 bytes across all versions.
const PREVIEW_SENTINEL: [u8; 16] = [
0x1F, 0x25, 0x6D, 0x07, 0xD4, 0x36, 0x28, 0x28, 0x9D, 0x57, 0xCA, 0x3F, 0x9D, 0x44, 0x10, 0x2B,
];
/// Image descriptor codes (1 = header, 3 = WMF — both skipped).
const CODE_BMP: u8 = 2;
const CODE_PNG: u8 = 6;
/// Parse the preview container at the file's raw preview offset and return the
/// first BMP/PNG image. Self-contained byte parsing — the container is a fixed
/// DWG format, so this needs no CAD-library dependency (only `image`).
///
/// Layout: `[sentinel 16][overall_size RL][count RC] count×[code RC, start RL,
/// size RL] [image data][end sentinel 16]`, where `start` is an ABSOLUTE file
/// offset.
fn read_preview(path: &Path) -> Option<(Fmt, Vec<u8>)> {
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/other)
}
// 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;
// Sentinel + overall size, to learn the container length.
f.seek(SeekFrom::Start(base)).ok()?;
let mut head = [0u8; 20];
f.read_exact(&mut head).ok()?;
if head[..16] != PREVIEW_SENTINEL {
return None;
}
let overall = u32::from_le_bytes([head[16], head[17], head[18], head[19]]) 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()?;
let count = buf[20] as usize;
let mut off = 21usize;
for _ in 0..count {
if off + 9 > buf.len() {
break;
}
let code = buf[off];
let start =
u32::from_le_bytes([buf[off + 1], buf[off + 2], buf[off + 3], buf[off + 4]]) as u64;
let size =
u32::from_le_bytes([buf[off + 5], buf[off + 6], buf[off + 7], buf[off + 8]]) as usize;
off += 9;
let fmt = match code {
CODE_BMP => Fmt::Bmp,
CODE_PNG => Fmt::Png,
_ => continue, // header / WMF / unknown
};
// `start` is absolute; translate to a slice offset within `buf`.
let rel = start.checked_sub(base)? as usize;
let end = rel.checked_add(size)?;
if size == 0 || end > buf.len() {
continue;
}
return Some((fmt, buf[rel..end].to_vec()));
}
None
}
// ── Decode + scale ───────────────────────────────────────────────────────────
fn decode(format: Fmt, data: &[u8]) -> Option<RgbaImage> {
let img = match format {
Fmt::Png => image::load_from_memory_with_format(data, ImageFormat::Png).ok()?,
Fmt::Bmp => image::load_from_memory_with_format(&dib_to_bmp(data), ImageFormat::Bmp).ok()?,
};
Some(img.to_rgba8())
}
fn downscale(img: RgbaImage, max_dim: u32) -> RgbaImage {
let (w, h) = (img.width(), img.height());
if w <= max_dim && h <= max_dim {
return img;
}
let (nw, nh) = if w >= h {
(max_dim, ((h * max_dim) / w).max(1))
} else {
(((w * max_dim) / h).max(1), max_dim)
};
image::imageops::thumbnail(&img, nw, nh)
}
// ── C ABI (for the macOS QuickLook extension and other FFI consumers) ────────
/// Extract a DWG preview and encode it as a PNG. Writes a freshly-allocated
/// buffer to `*out_ptr` / `*out_len`; free it with [`dwg_thumbnail_free`].
/// Returns `true` on success. `path_utf8` is a NUL-terminated UTF-8 path.
///
/// # Safety
/// `path_utf8` must be a valid NUL-terminated string; `out_ptr`/`out_len` must
/// be valid, writable pointers.
#[no_mangle]
pub unsafe extern "C" fn dwg_thumbnail_png(
path_utf8: *const std::os::raw::c_char,
max_dim: u32,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> bool {
if path_utf8.is_null() || out_ptr.is_null() || out_len.is_null() {
return false;
}
let cstr = std::ffi::CStr::from_ptr(path_utf8);
let Ok(path) = cstr.to_str() else { return false };
let Some(mut img) = extract(Path::new(path), max_dim) else {
return false;
};
badge_dwg(&mut img); // full-width "DWG" band, same as every file-manager path
let mut buf = std::io::Cursor::new(Vec::new());
if img.write_to(&mut buf, ImageFormat::Png).is_err() {
return false;
}
let mut bytes = buf.into_inner().into_boxed_slice();
*out_ptr = bytes.as_mut_ptr();
*out_len = bytes.len();
std::mem::forget(bytes);
true
}
/// Free a buffer returned by [`dwg_thumbnail_png`].
///
/// # Safety
/// `ptr`/`len` must be exactly the values written by a prior successful
/// `dwg_thumbnail_png`, and must be freed at most once.
#[no_mangle]
pub unsafe extern "C" fn dwg_thumbnail_free(ptr: *mut u8, len: usize) {
if !ptr.is_null() && len != 0 {
drop(Box::from_raw(std::slice::from_raw_parts_mut(ptr, len)));
}
}
/// Prepend the 14-byte `BITMAPFILEHEADER` a stored DIB lacks 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
}

View file

@ -35,6 +35,8 @@
<string>AutoCAD Drawing</string> <string>AutoCAD Drawing</string>
<key>CFBundleTypeRole</key> <key>CFBundleTypeRole</key>
<string>Editor</string> <string>Editor</string>
<key>CFBundleTypeIconFile</key>
<string>DWG</string>
<key>LSItemContentTypes</key> <key>LSItemContentTypes</key>
<array> <array>
<string>com.autodesk.dwg</string> <string>com.autodesk.dwg</string>
@ -47,6 +49,8 @@
<string>AutoCAD DXF</string> <string>AutoCAD DXF</string>
<key>CFBundleTypeRole</key> <key>CFBundleTypeRole</key>
<string>Editor</string> <string>Editor</string>
<key>CFBundleTypeIconFile</key>
<string>DXF</string>
<key>LSItemContentTypes</key> <key>LSItemContentTypes</key>
<array> <array>
<string>com.autodesk.dxf</string> <string>com.autodesk.dxf</string>

View file

@ -84,6 +84,20 @@
Advertise='yes' /> Advertise='yes' />
</File> </File>
<!-- DWG/DXF file-type icons, generated from the mimetype SVGs
(CI: magick image-vnd.{dwg,dxf}.svg -> {dwg,dxf}.ico). -->
<File Id='DwgIco' Name='dwg.ico' DiskId='1' Source='packaging\windows\dwg.ico' />
<File Id='DxfIco' Name='dxf.ico' DiskId='1' Source='packaging\windows\dxf.ico' />
<!-- DWG thumbnail provider (IThumbnailProvider). Ships beside the
exe; OpenCADStudio self-registers it under HKCU on startup
(see file_association::install_thumbnailer). -->
<File
Id='DwgThumbDll'
Name='dwg_thumbnailer_win.dll'
DiskId='1'
Source='target\release\dwg_thumbnailer_win.dll' />
<!-- <!--
File associations. Two ProgIDs (one per extension) so the File associations. Two ProgIDs (one per extension) so the
`<Verb Id='open'>` rows don't collide on the same registry `<Verb Id='open'>` rows don't collide on the same registry
@ -93,7 +107,7 @@
<ProgId <ProgId
Id='OpenCADStudio.DWG' Id='OpenCADStudio.DWG'
Description='DWG Drawing' Description='DWG Drawing'
Icon='OpenCADStudioEXE' Icon='DwgIco'
IconIndex='0'> IconIndex='0'>
<Extension Id='dwg' ContentType='image/vnd.dwg'> <Extension Id='dwg' ContentType='image/vnd.dwg'>
<Verb Id='open' Command='Open' TargetFile='OpenCADStudioEXE' Argument='"%1"' /> <Verb Id='open' Command='Open' TargetFile='OpenCADStudioEXE' Argument='"%1"' />
@ -103,7 +117,7 @@
<ProgId <ProgId
Id='OpenCADStudio.DXF' Id='OpenCADStudio.DXF'
Description='DXF Drawing' Description='DXF Drawing'
Icon='OpenCADStudioEXE' Icon='DxfIco'
IconIndex='0'> IconIndex='0'>
<Extension Id='dxf' ContentType='image/vnd.dxf'> <Extension Id='dxf' ContentType='image/vnd.dxf'>
<Verb Id='open' Command='Open' TargetFile='OpenCADStudioEXE' Argument='"%1"' /> <Verb Id='open' Command='Open' TargetFile='OpenCADStudioEXE' Argument='"%1"' />

View file

@ -67,6 +67,11 @@ pub struct Cli {
/// Internal: run as the plugin runner child process. /// Internal: run as the plugin runner child process.
#[arg(long, value_names = ["SOCKET", "CDYLIB"], num_args = 2, hide = true)] #[arg(long, value_names = ["SOCKET", "CDYLIB"], num_args = 2, hide = true)]
pub ocs_plugin_runner: Option<Vec<String>>, 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 /// GUI startup configuration, handed from `main` to `app::boot` out-of-band

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 /// 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. /// human-readable status string on success, or an error message on failure.
pub async fn set_default_app() -> Result<String, String> { pub async fn set_default_app() -> Result<String, String> {
@ -233,6 +253,16 @@ mod windows_impl {
RegDeleteTreeW(HKEY_CURRENT_USER, w.as_ptr()); 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(()) Ok(())
} }
@ -276,6 +306,41 @@ mod windows_impl {
Ok(()) 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 /// 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. /// and an open command, mirroring one of the MSI's per-machine ProgIDs.
fn register_progid(exe: &str, progid: &str, description: &str) -> Result<(), String> { fn register_progid(exe: &str, progid: &str, description: &str) -> Result<(), String> {
@ -372,6 +437,33 @@ mod linux_impl {
use super::APP_ID; use super::APP_ID;
use std::path::{Path, PathBuf}; 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 /// Write a user-level .desktop file pointing at the running binary, then
/// refresh the MIME cache so file managers list us under "Open with". /// 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 /// Skipped when a system package already registered the app, so we don't
@ -542,6 +634,38 @@ mod linux_impl {
Ok(()) 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> { fn data_home() -> Result<PathBuf, String> {
std::env::var_os("XDG_DATA_HOME") std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from) .map(PathBuf::from)

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 //! - [`from_scene`] / [`from_wires`] rasterize the current layout's wires into a
//! as an [`acadrust::Preview`] (a Windows DIB, universally accepted as a DWG //! small DIB [`acadrust::Preview`], embedded on save so OCS drawings show a
//! preview across versions). Embedded on save so OCS drawings show a thumbnail //! thumbnail in file browsers and other CAD apps.
//! in file browsers and other CAD applications. //! - [`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 acadrust::{Preview, PreviewFormat};
use image::{ImageFormat, Rgb, RgbImage}; 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 /// Read a DWG's embedded preview and write it as a PNG at `output`, scaled so
/// bytes, no full document parse) and decode it to an iced image handle. /// its longest edge is at most `size`. Returns `false` on any failure (no
/// `None` for DXF/other files, a missing or empty preview, or a format the /// preview, undecodable, write error) so the OS thumbnailer falls back to a
/// `image` crate can't decode (WMF). Used to show recent-file thumbnails. /// generic icon. Backs the hidden `--dwg-thumbnail` mode the installed
pub fn read_handle(path: &std::path::Path) -> Option<iced::widget::image::Handle> { /// freedesktop `.thumbnailer` invokes. Extraction lives in the shared
decode(&read_preview(path)?) /// [`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) {
/// Read the preview image bytes straight from the file's raw preview offset. Some(mut img) => {
fn read_preview(path: &std::path::Path) -> Option<Preview> { // Bottom-left "DWG" ribbon so the format reads at a glance in the
use std::io::{Read, Seek, SeekFrom}; // file manager (the Start-page `read_handle` stays unbadged).
let mut f = std::fs::File::open(path).ok()?; dwg_thumbnailer::badge_dwg(&mut img);
let mut ver = [0u8; 6]; img.save_with_format(output, ImageFormat::Png).is_ok()
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()?
} }
PreviewFormat::Wmf => return None, None => false,
}; }
let rgba = img.to_rgba8();
let (w, h) = (rgba.width(), rgba.height());
Some(iced::widget::image::Handle::from_rgba(w, h, rgba.into_raw()))
} }
/// Reconstruct a full in-memory BMP from a stored DIB (prepend the 14-byte /// Read a DWG's embedded preview and decode it to an iced image handle for the
/// `BITMAPFILEHEADER`) so a BMP decoder can read it. /// Start page's recent-file thumbnails. `None` for DXF/other files, a missing
fn dib_to_bmp(dib: &[u8]) -> Vec<u8> { /// preview, or an undecodable format (WMF).
if dib.len() < 16 { pub fn read_handle(path: &std::path::Path) -> Option<iced::widget::image::Handle> {
return Vec::new(); let img = dwg_thumbnailer::extract(path, MAX_DIM)?;
} let (w, h) = (img.width(), img.height());
let bi_size = u32::from_le_bytes([dib[0], dib[1], dib[2], dib[3]]) as usize; Some(iced::widget::image::Handle::from_rgba(w, h, img.into_raw()))
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
} }
#[cfg(test)] #[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] #[test]
fn empty_input_yields_none() { fn empty_input_yields_none() {
assert!(from_wires(&[], [0.0, 0.0, 0.0, 1.0]).is_none()); assert!(from_wires(&[], [0.0, 0.0, 0.0, 1.0]).is_none());

View file

@ -51,6 +51,19 @@ fn main() -> iced::Result {
return Ok(()); 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 // Opt-in logging. `--log LEVEL` seeds RUST_LOG; the subscriber then
// surfaces wgpu / iced / winit diagnostics that are otherwise silent. // surfaces wgpu / iced / winit diagnostics that are otherwise silent.
if let Some(level) = &args.log { if let Some(level) = &args.log {
@ -110,6 +123,13 @@ fn main() -> iced::Result {
read_only: args.read_only, read_only: args.read_only,
script_lines, 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() app::run()
} }
} }