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:
parent
5c4ba97c5f
commit
ba3b3202e8
20 changed files with 1085 additions and 76 deletions
26
crates/dwg-thumbnailer-win/Cargo.toml
Normal file
26
crates/dwg-thumbnailer-win/Cargo.toml
Normal 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",
|
||||
] }
|
||||
279
crates/dwg-thumbnailer-win/src/lib.rs
Normal file
279
crates/dwg-thumbnailer-win/src/lib.rs
Normal 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(())
|
||||
}
|
||||
19
crates/dwg-thumbnailer/Cargo.toml
Normal file
19
crates/dwg-thumbnailer/Cargo.toml
Normal 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"] }
|
||||
80
crates/dwg-thumbnailer/README.md
Normal file
80
crates/dwg-thumbnailer/README.md
Normal 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.
|
||||
BIN
crates/dwg-thumbnailer/assets/dwg-label.png
Normal file
BIN
crates/dwg-thumbnailer/assets/dwg-label.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
42
crates/dwg-thumbnailer/macos/Info.plist
Normal file
42
crates/dwg-thumbnailer/macos/Info.plist
Normal 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>
|
||||
49
crates/dwg-thumbnailer/macos/ThumbnailProvider.swift
Normal file
49
crates/dwg-thumbnailer/macos/ThumbnailProvider.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
18
crates/dwg-thumbnailer/macos/dwg_thumbnailer.h
Normal file
18
crates/dwg-thumbnailer/macos/dwg_thumbnailer.h
Normal 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 */
|
||||
247
crates/dwg-thumbnailer/src/lib.rs
Normal file
247
crates/dwg-thumbnailer/src/lib.rs
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue