diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86ce62b4..50c88047 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,17 @@ jobs: -define icon:auto-resize=16,24,32,48,64,128,256 ` 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 env: OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} @@ -237,14 +248,60 @@ jobs: done 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 run: | APP=OpenCADStudio.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" chmod +x "$APP/Contents/MacOS/OpenCADStudio" 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. VERSION="${{ github.ref_name }}" VERSION="${VERSION#v}" diff --git a/Cargo.lock b/Cargo.lock index 7f80eda2..9fc3736c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,7 @@ dependencies = [ "clap", "console_error_panic_hook", "cosmic-text", + "dwg-thumbnailer", "env_logger", "flate2", "fontdb", @@ -1414,6 +1415,21 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "ecb" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 57e16802..a0071cfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,15 @@ edition = "2021" build = "build.rs" [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, # 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). env_logger = "0.11" acadrust = "0.4" +# Shared DWG embedded-preview extraction (Start-page + file-manager thumbnails). +dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } inventory = "0.3" diff --git a/assets/mimetypes/image-vnd.dwg.svg b/assets/mimetypes/image-vnd.dwg.svg new file mode 100644 index 00000000..64f4f704 --- /dev/null +++ b/assets/mimetypes/image-vnd.dwg.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + DWG + diff --git a/assets/mimetypes/image-vnd.dxf.svg b/assets/mimetypes/image-vnd.dxf.svg new file mode 100644 index 00000000..988f8831 --- /dev/null +++ b/assets/mimetypes/image-vnd.dxf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + DXF + diff --git a/crates/dwg-thumbnailer-win/Cargo.toml b/crates/dwg-thumbnailer-win/Cargo.toml new file mode 100644 index 00000000..9750103e --- /dev/null +++ b/crates/dwg-thumbnailer-win/Cargo.toml @@ -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", +] } diff --git a/crates/dwg-thumbnailer-win/src/lib.rs b/crates/dwg-thumbnailer-win/src/lib.rs new file mode 100644 index 00000000..07f8310d --- /dev/null +++ b/crates/dwg-thumbnailer-win/src/lib.rs @@ -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>, +} + +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 { + let (w, h) = (img.width() as i32, img.height() as i32); + let bi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() 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 { + 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 { + 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(()) +} diff --git a/crates/dwg-thumbnailer/Cargo.toml b/crates/dwg-thumbnailer/Cargo.toml new file mode 100644 index 00000000..d47191b6 --- /dev/null +++ b/crates/dwg-thumbnailer/Cargo.toml @@ -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"] } diff --git a/crates/dwg-thumbnailer/README.md b/crates/dwg-thumbnailer/README.md new file mode 100644 index 00000000..ca61acd1 --- /dev/null +++ b/crates/dwg-thumbnailer/README.md @@ -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. diff --git a/crates/dwg-thumbnailer/assets/dwg-label.png b/crates/dwg-thumbnailer/assets/dwg-label.png new file mode 100644 index 00000000..b479a5ad Binary files /dev/null and b/crates/dwg-thumbnailer/assets/dwg-label.png differ diff --git a/crates/dwg-thumbnailer/macos/Info.plist b/crates/dwg-thumbnailer/macos/Info.plist new file mode 100644 index 00000000..43b03fe6 --- /dev/null +++ b/crates/dwg-thumbnailer/macos/Info.plist @@ -0,0 +1,42 @@ + + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + DWG Thumbnails + CFBundleExecutable + DWGThumbnail + + CFBundleIdentifier + io.github.HakanSeven12.OpenCadStudio.DWGThumbnail + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + DWGThumbnail + CFBundlePackageType + XPC! + CFBundleShortVersionString + __VERSION__ + CFBundleVersion + __VERSION__ + NSExtension + + NSExtensionPointIdentifier + com.apple.quicklook.thumbnail + NSExtensionPrincipalClass + DWGThumbnail.ThumbnailProvider + NSExtensionAttributes + + QLSupportedContentTypes + + com.autodesk.dwg + + QLThumbnailMinimumDimension + 0 + + + + diff --git a/crates/dwg-thumbnailer/macos/ThumbnailProvider.swift b/crates/dwg-thumbnailer/macos/ThumbnailProvider.swift new file mode 100644 index 00000000..62065560 --- /dev/null +++ b/crates/dwg-thumbnailer/macos/ThumbnailProvider.swift @@ -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? = 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) + } +} diff --git a/crates/dwg-thumbnailer/macos/dwg_thumbnailer.h b/crates/dwg-thumbnailer/macos/dwg_thumbnailer.h new file mode 100644 index 00000000..144ff5dc --- /dev/null +++ b/crates/dwg-thumbnailer/macos/dwg_thumbnailer.h @@ -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 +#include +#include + +/* 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 */ diff --git a/crates/dwg-thumbnailer/src/lib.rs b/crates/dwg-thumbnailer/src/lib.rs new file mode 100644 index 00000000..3d8f27fb --- /dev/null +++ b/crates/dwg-thumbnailer/src/lib.rs @@ -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 { + 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)> { + 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 { + 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 { + 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 +} diff --git a/packaging/Info.plist b/packaging/Info.plist index a7f7913f..ecfb4d33 100644 --- a/packaging/Info.plist +++ b/packaging/Info.plist @@ -35,6 +35,8 @@ AutoCAD Drawing CFBundleTypeRole Editor + CFBundleTypeIconFile + DWG LSItemContentTypes com.autodesk.dwg @@ -47,6 +49,8 @@ AutoCAD DXF CFBundleTypeRole Editor + CFBundleTypeIconFile + DXF LSItemContentTypes com.autodesk.dxf diff --git a/packaging/windows/main.wxs b/packaging/windows/main.wxs index ec91c907..66ec8de9 100644 --- a/packaging/windows/main.wxs +++ b/packaging/windows/main.wxs @@ -84,6 +84,20 @@ Advertise='yes' /> + + + + + + +