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
59
.github/workflows/release.yml
vendored
59
.github/workflows/release.yml
vendored
|
|
@ -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
16
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
12
Cargo.toml
12
Cargo.toml
|
|
@ -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"
|
||||||
|
|
|
||||||
15
assets/mimetypes/image-vnd.dwg.svg
Normal file
15
assets/mimetypes/image-vnd.dwg.svg
Normal 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 |
15
assets/mimetypes/image-vnd.dxf.svg
Normal file
15
assets/mimetypes/image-vnd.dxf.svg
Normal 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 |
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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"' />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
20
src/main.rs
20
src/main.rs
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue