From ec059c042b34dcac6db4452a27169e441968e73e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 24 Oct 2026 23:11:57 +0200 Subject: [PATCH] Linux: file managers show a .designcraft file's first page through a freedesktop thumbnailer (#1782) --- Cargo.lock | 1 + ROADMAP.md | 1 + apps/designcraft-cli/Cargo.toml | 1 + apps/designcraft-cli/src/main.rs | 40 ++++++++++++- apps/designcraft-cli/tests/it/cli.rs | 60 +++++++++++++++++++ .../ai.storyteller.designcraft.thumbnailer | 8 +++ packaging/linux/package.sh | 1 + 7 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 packaging/linux/ai.storyteller.designcraft.thumbnailer diff --git a/Cargo.lock b/Cargo.lock index 1c361aef2..13a009d2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1340,6 +1340,7 @@ dependencies = [ "designcraft-mcp", "designcraft-pdf", "designcraft-render", + "image", "serde_json", ] diff --git a/ROADMAP.md b/ROADMAP.md index ad3f52a14..807aa5dbf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -405,6 +405,7 @@ Newest first. - **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. - **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change. - **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change. +- **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. diff --git a/apps/designcraft-cli/Cargo.toml b/apps/designcraft-cli/Cargo.toml index 7aaa454cb..ab2464147 100644 --- a/apps/designcraft-cli/Cargo.toml +++ b/apps/designcraft-cli/Cargo.toml @@ -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 } diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 9ce15674c..bf7b6b351 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -16,6 +16,8 @@ //! designcraft-cli bench FILE [--runs N] # the same measurements on one document //! designcraft-cli validate-pdf FILE.pdf [--standard x4|x1a] # the built-in PDF/X-4 or PDF/X-1a checks (default: the //! # file's own GTS_PDFXVersion) +//! 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 //! ``` @@ -40,7 +42,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] @@ -124,6 +126,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..).unwrap_or_default())), + 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}"); @@ -188,6 +191,41 @@ fn validate_pdf(args: &[String]) -> Result<(), String> { if valid { Ok(()) } else { Err(format!("{path} failed {name} 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::().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, size: Option) -> Result, 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> { diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs index 861cb514f..98b4f5edf 100644 --- a/apps/designcraft-cli/tests/it/cli.rs +++ b/apps/designcraft-cli/tests/it/cli.rs @@ -556,3 +556,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); +} diff --git a/packaging/linux/ai.storyteller.designcraft.thumbnailer b/packaging/linux/ai.storyteller.designcraft.thumbnailer new file mode 100644 index 000000000..4642996b3 --- /dev/null +++ b/packaging/linux/ai.storyteller.designcraft.thumbnailer @@ -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; diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index fa24385d5..7b120c525 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -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"