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