Browse / github.com/storytold/designcraft / patch
Linux: file managers show a .designcraft file's first page through a freedesktop thumbnailer (#1782)
Feature Request: Add Dolphin Thumbnail Preview Provider for `.designcraft` Files ## Summary Requesting the integration of a thumbnail provider extension/plugin for KDE Dolphin so that `.designcraft` files render visual previews of document contents instead of a generic file icon[cite: 1]. ## Problem / Motivation When managing creative projects in KDE Dolphin, `.designcraft` files display only as generic document icons[cite: 1]. This makes it difficult to quickly identify or distinguish between different project files without opening each one individually. ## Proposed Solution Implement a KIO thumbnail generator plugin (or freedesktop thumbnailer spec integration) for `.d
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- marcoarnulfo (imported from pull request #1792, open)
- Base
21a3f8bbb1933cee3ceb668db51977b5ac1c5e1c- Size
- +111 −1 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
c4caff87424e4741f22086e2
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock ROADMAP.md apps/designcraft-cli/Cargo.toml apps/designcraft-cli/src/main.rs apps/designcraft-cli/tests/it/cli.rs packaging/linux/ai.storyteller.designcraft.thumbnailer packaging/linux/package.sh
@@ -975,6 +975,7 @@ dependencies = [ "designcraft-mcp", "designcraft-pdf", "designcraft-render",+ "image", "serde_json", ] @@ -358,6 +358,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Linux file managers show a `.designcraft` file's first page instead of a blank icon: the packages install a freedesktop thumbnailer (`share/thumbnailers/ai.storyteller.designcraft.thumbnailer`) that runs `designcraft-cli thumbnail FILE OUT.png SIZE`, which writes the `preview.png` saving already puts in the file, fitted to the size asked for and never enlarged. A file without a preview fails, so the file manager keeps the file type's icon (#1782). No readiness percentage change. - **2026-10-18** · Repeated placed-PDF layer variants are prepared once per asset and hidden-name set, with recovery warnings retained. All-visible named layers keep their original source bytes, including PDFs whose cross-reference tables the reader repairs, in plain, PDF/X-4, booklet and book-merge output. Layer changes that require writing still reject an unusable cross-reference table with a file-named damage error; the first placement error is retained (#906). No readiness percentage change. - **2026-10-18** · Placed PDF optional-content defaults and per-frame overrides survive plain and PDF/X-4 vector export, including repeated placements, booklet output and book merging (#906). Source usage-application arrays are accepted; groups without a catalog configuration stay visible. Missing or unreadable default configurations and unsupported base states use all-ON defaults with a file-named export warning, retaining explicit frame overrides. Readable groups omitted from the catalog list are added with a distinct file-named warning, preserving their default ON/OFF states. Concurrent recovery warnings are combined. Malformed ON data is recoverable when a known ON base and empty OFF list prove it cannot hide artwork; missing display names are generated without changing known visibility when no override needs name matching. Contradictory states, unresolved references and ambiguous name overrides still fail export. Print-usage adjustments and IDML layer overrides remain follow-ups. No readiness percentage change. @@ -22,4 +22,5 @@ designcraft-compose = { workspace = true } designcraft-format = { workspace = true } designcraft-pdf = { workspace = true } designcraft-mcp = { workspace = true }+image = { workspace = true } serde_json = { workspace = true }@@ -15,6 +15,8 @@ //! designcraft-cli perf [--pages N] [--frames N] [--chars N] [--images N] [--runs N] [--strict] # budgets on a synthetic stress document //! designcraft-cli bench FILE [--runs N] # the same measurements on one document //! designcraft-cli validate-pdf FILE.pdf # run the built-in PDF/X-4 conformance checks+//! designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the file's first-page preview, fitted to SIZE+//! # (the freedesktop thumbnailer for Linux file managers) //! designcraft-cli links # Discord, website, app page and GitHub links //! designcraft-cli --version # print the version //! ```@@ -39,7 +41,7 @@ use designcraft_engine::Session; use serde_json::{Value, json}; /// The usage block. Shared by `--help` (stdout, success) and an unknown command (stderr, failure).-const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli links\n designcraft-cli --version";+const USAGE: &str = "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n (--page, --scale and --pdf-options apply to the exports that follow them)\n designcraft-cli commands [FILTER]\n designcraft-cli describe COMMAND\n designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]\n designcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]\n designcraft-cli mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...\n the roots confine every file the agent's commands read (open, placed images, data sources, libraries) and\n write (save, export, package) to those folders, links followed. Each may be given more than once; a kind\n left out grants none of its access.\n They run the in-process server, so they conflict with --connect (a running app is not confined by them).\n designcraft-cli perf [--pages N] [--runs N] [--strict]\n designcraft-cli bench FILE [--runs N]\n designcraft-cli validate-pdf FILE.pdf # built-in PDF/X-4 checks (including visible unembedded fonts)\n designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE] # the saved first-page preview, at most SIZE px\n designcraft-cli links\n designcraft-cli --version"; /// `script --help`: the script syntax, otherwise only in `describe script.run` and docs/agents.md. const SCRIPT_HELP: &str = r#"usage: designcraft-cli script [FILE|-] [--in FILE | --sample] [--connect PORT] [--save OUT] [--export OUT] [--keep-going]@@ -123,6 +125,7 @@ fn main() -> ExitCode { Some("perf") => report(perf::perf(&args[1..])), Some("bench") => report(perf::bench(&args[1..])), Some("validate-pdf") => report(validate_pdf(args.get(1).map(String::as_str))),+ Some("thumbnail") => report(thumbnail(&args[1..])), Some("links") => { use designcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, ISSUES, WEBSITE}; outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}\nIssues {ISSUES}");@@ -163,6 +166,41 @@ fn validate_pdf(path: Option<&str>) -> Result<(), String> { if valid { Ok(()) } else { Err(format!("{path} failed PDF/X-4 validation")) } } +/// `thumbnail FILE OUT [SIZE]`: the first-page preview saved in a `.designcraft` file, as a PNG+/// whose longer side is at most SIZE pixels (never enlarged). It is the freedesktop thumbnailer+/// (`packaging/linux/ai.storyteller.designcraft.thumbnailer`), so it only reads the preview: a file+/// without one fails, and the file manager keeps the file type's icon.+fn thumbnail(args: &[String]) -> Result<(), String> {+ const USAGE: &str = "usage: designcraft-cli thumbnail FILE.designcraft OUT.png [SIZE]";+ let [input, out, rest @ ..] = args else { return Err(USAGE.into()) };+ let size = match rest {+ [] => None,+ [s] => Some(s.parse::<u32>().ok().filter(|s| *s > 0).ok_or_else(|| format!("bad SIZE `{s}`: expected a positive number of pixels"))?),+ _ => return Err(USAGE.into()),+ };+ let file = std::fs::File::open(input).map_err(|e| format!("{input}: {e}"))?;+ let preview = designcraft_format::read_preview(std::io::BufReader::new(file))+ .ok_or_else(|| format!("{input} has no preview (a DesignCraft file saved before they had one, or not a DesignCraft file)"))?;+ let png = fit_png(preview, size).map_err(|e| format!("{input}: the preview can't be read: {e}"))?;+ designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &png).map_err(|e| format!("{out}: {e}"))+}++/// `png` with its longer side brought down to `size`. A picture already that small is returned+/// as it is; it is decoded anyway, so a broken one is an error rather than an unreadable thumbnail.+fn fit_png(png: Vec<u8>, size: Option<u32>) -> Result<Vec<u8>, String> {+ // The preview comes from the file: it may claim any size.+ let mut limits = image::Limits::default();+ limits.max_image_width = Some(4096);+ limits.max_image_height = Some(4096);+ let mut reader = image::ImageReader::with_format(std::io::Cursor::new(&png), image::ImageFormat::Png);+ reader.limits(limits);+ let img = reader.decode().map_err(|e| e.to_string())?;+ let Some(size) = size.filter(|s| img.width().max(img.height()) > *s) else { return Ok(png) };+ let mut out = std::io::Cursor::new(Vec::new());+ img.resize(size, size, image::imageops::FilterType::Triangle).write_to(&mut out, image::ImageFormat::Png).map_err(|e| e.to_string())?;+ Ok(out.into_inner())+}+ /// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app /// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. fn mcp(args: &[String]) -> Result<(), String> {@@ -510,3 +510,63 @@ fn usage_lists_the_automation_root_flags() { let usage = String::from_utf8_lossy(&out.stdout); assert!(usage.contains("--automation-read-root DIR") && usage.contains("--automation-write-root DIR"), "{usage}"); }++/// Width and height from a PNG's IHDR chunk.+fn png_size(png: &[u8]) -> (u32, u32) {+ assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"), "not a PNG");+ let be = |at: usize| u32::from_be_bytes(png[at..at + 4].try_into().unwrap());+ (be(16), be(20))+}++/// Linux file managers (Dolphin, Nautilus, Thunar) draw a `.designcraft` file's icon through the+/// freedesktop thumbnailer, which runs `designcraft-cli thumbnail %i %o %s` (#1782). The picture is+/// the first-page preview the file already holds, fitted to the size asked for and never enlarged.+#[test]+fn thumbnail_writes_the_saved_preview_fitted_to_the_size() {+ let dir = std::env::temp_dir().join(format!("dc-cli-thumb-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let doc = dir.join("a.designcraft");+ make_document(&doc);+ let preview = designcraft_format::read_preview(std::fs::File::open(&doc).unwrap()).unwrap();+ let (pw, ph) = png_size(&preview);+ assert!(pw.max(ph) > 128, "the saved preview is {pw}×{ph}");++ let small = dir.join("small.png");+ let out = cli().arg("thumbnail").arg(&doc).arg(&small).arg("128").output().unwrap();+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));+ let (w, h) = png_size(&std::fs::read(&small).unwrap());+ assert_eq!(w.max(h), 128, "{w}×{h} from a {pw}×{ph} preview");+ // The page's proportions stay.+ let ratio = |a: u32, b: u32| f64::from(a) / f64::from(b);+ assert!((ratio(w, h) - ratio(pw, ph)).abs() < 0.02, "{w}×{h} from a {pw}×{ph} preview");++ let large = dir.join("large.png");+ let out = cli().arg("thumbnail").arg(&doc).arg(&large).arg("1024").output().unwrap();+ assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));+ assert_eq!(std::fs::read(&large).unwrap(), preview, "a preview smaller than the size is written as saved");+ let _ = std::fs::remove_dir_all(&dir);+}++/// A file saved without a preview (by an older version, or not by DesignCraft) has no picture: the+/// thumbnailer fails and writes nothing, so the file manager keeps the file type's icon.+#[test]+fn thumbnail_of_a_file_without_a_preview_fails_and_writes_nothing() {+ let dir = std::env::temp_dir().join(format!("dc-cli-thumb-none-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let with = dir.join("with.designcraft");+ make_document(&with);+ let doc = designcraft_format::load(&std::fs::read(&with).unwrap()).unwrap();+ let without = dir.join("without.designcraft");+ std::fs::write(&without, designcraft_format::save(&doc).unwrap()).unwrap();+ let text = dir.join("notes.designcraft");+ std::fs::write(&text, "not a document").unwrap();+ for input in [&without, &text] {+ let png = dir.join("out.png");+ let out = cli().arg("thumbnail").arg(input).arg(&png).arg("256").output().unwrap();+ let err = String::from_utf8_lossy(&out.stderr);+ assert!(!out.status.success(), "{}", input.display());+ assert!(err.contains("no preview"), "{}: {err}", input.display());+ assert!(!png.exists(), "{}", input.display());+ }+ let _ = std::fs::remove_dir_all(&dir);+}new file mode 100644@@ -0,0 +1,8 @@+# Installed as share/thumbnailers/ai.storyteller.designcraft.thumbnailer (package.sh). File managers+# that read freedesktop thumbnailers (Nautilus, Thunar, Dolphin through KIO) run it for .designcraft+# files: %i is the file, %o the PNG to write, %s the size in pixels. It writes the first-page preview+# saved in the file; a file without one keeps the file type's icon.+[Thumbnailer Entry]+TryExec=designcraft-cli+Exec=designcraft-cli thumbnail %i %o %s+MimeType=application/x-designcraft;@@ -61,6 +61,7 @@ install -Dm755 "$BIN/designcraft-cli" "$STAGE/usr/bin/designcraft-cli" "${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/designcraft" "$STAGE/usr/bin/designcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml"+install -Dm644 "$HERE/$APP_ID.thumbnailer" "$STAGE/usr/share/thumbnailers/$APP_ID.thumbnailer" mkdir -p "$STAGE/usr/share/metainfo" sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$DESIGNCRAFT_BUILD_DATE/g" \ "$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"