collective

Browse / github.com/storytold/designcraft / patch

Export PNG: every page as its own file, optionally transparent (#1581)

Export PNG: every page as its own file, optionally transparent (#1581) Addresses #1581. PNG export was the active page only, flattened onto white; there is now a `file.exportPng` command that writes one PNG per page, at a chosen resolution, optionally with a transparent background. ## The defect underneath the request `RenderOptions::background` already documents `None` as "transparent", but `render_page` overwrote whatever the caller passed with opaque white — so no caller could ever get a transparent page. Thumbnails, MCP `render_page` and the control server all depend on that white, so rather than change what they get: * `render_page_on(…, background)` take

Status
open
Score
0.00
Adopters
0 other operators
Author
Hamza0101 (imported from pull request #1827, open)
Base
fec0897a5d42a9c8d6d162504b431efde225c547
Size
+160 −1 in 2 files
Review
pending
Shared
2026-10-28
Id
129f8fbd583d8d0d149528a9

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/export.rs crates/render/src/lib.rs

diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs--- a/crates/engine/src/cmd/export.rs+++ b/crates/engine/src/cmd/export.rs@@ -51,6 +51,9 @@ pub fn specs() -> Vec<CommandSpec> {         cmd!(noundo "file.exportPptx", "Export PowerPoint…", ["File"], None,         "{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), lineBreaks?: keep|reflow (keep: DesignCraft's lines, spacing and leading; reflow: one box per frame, PowerPoint sets the lines), rasterize?: bool (default true: objects PowerPoint can't show — placed PDF/EPS, type on a path, some effects — go in as 300 ppi images), embedFonts?: bool (default true: the TrueType fonts whose licences allow it), title?, author?} — every page as a slide of editable shapes, pictures, text boxes and tables → {path, bytes, slides, warnings} (no path: {base64, …})",         has_doc, export_pptx),+        cmd!(noundo "file.exportPng", "Export PNG…", ["File"], None,+            "{path?: \"page.png\" (several pages become page-1.png, page-2.png, …), pages?: \"1-3,5\" | [1,3] (1-based positions; default all), ppi?: number (72..=1200; default 300), transparent?: bool (no canvas or paper fill, so the PNG's alpha is the artwork's), bleed?: bool (include the document bleed)} → {files: [{path, page, bytes, width, height}], pages} (no path: {images: [{page, base64, …}], pages})",+            |_: &Session| Ok(()), export_png),         cmd!(noundo "file.exportText", "Export Text…", ["File"], None,         "{path?, format?: \"txt\"|\"rtf\"|\"tagged\" (Tagged Text; default from the path, else txt), story?, frame?} — the story being edited or of the selected frame → {path, bytes} (no path: {text, bytes})",         has_story_target, export_text),@@ -175,6 +178,73 @@ fn export_pptx(s: &mut Session, p: &Value) -> Result<Value> {     } } +/// `file.exportPng`: each page as its own PNG, optionally with a transparent background (#1581).+///+/// InDesign's Export ▸ PNG writes one file per page with the page number appended, and its+/// Background setting is White or Transparent. Transparent means no canvas fill *and* no paper+/// fill: paper is what makes a page opaque, so a transparent export leaves the alpha of whatever+/// the artwork draws.+fn export_png(s: &mut Session, p: &Value) -> Result<Value> {+    const ID: &str = "file.exportPng";+    const MIN_PPI: f64 = 72.0;+    const MAX_PPI: f64 = 1200.0;+    let st = s.doc()?;+    let count = st.doc.page_count();+    let pages = pages_param(ID, p, count)?.unwrap_or_else(|| (0..count).collect());+    if pages.is_empty() {+        return Err(bad(ID, "no pages to export"));+    }+    let ppi = match p.get("ppi") {+        None | Some(Value::Null) => 300.0,+        Some(v) => v+            .as_f64()+            .filter(|v| v.is_finite() && (MIN_PPI..=MAX_PPI).contains(v))+            .ok_or_else(|| bad(ID, format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}, not {v}")))?,+    };+    let transparent = p.get("transparent").and_then(Value::as_bool).unwrap_or(false);+    let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false);+    let path = str_param(p, "path").map(str::to_string);+    let scale = ppi / 72.0;+    // Paper is artwork: a transparent background means not drawing it either, or every page would+    // be an opaque rectangle of paper colour.+    let opts = designcraft_render::RenderOptions { printing_only: true, paper: !transparent, ..Default::default() };+    let background = (!transparent).then_some([255, 255, 255, 255]);++    let mut renderer = designcraft_render::Renderer::new();+    let mut out = Vec::new();+    for &abs in &pages {+        let img = renderer+            .render_page_on(&st.doc, &s.cache, abs, scale, bleed, &opts, background)+            .ok_or_else(|| bad(ID, format!("page {} is too large to render at {ppi} ppi", abs + 1)))?;+        let (width, height) = (img.width, img.height);+        let bytes = img.to_png();+        match &path {+            Some(path) => {+                let file = page_file_name(path, abs + 1, pages.len() > 1);+                #[cfg(not(target_arch = "wasm32"))]+                crate::io::write_file(&file, &bytes).map_err(EngineError::Other)?;+                out.push(json!({"path": file, "page": abs + 1, "bytes": bytes.len(), "width": width, "height": height}));+            }+            None => out+                .push(json!({"page": abs + 1, "base64": super::file::base64_encode(&bytes), "bytes": bytes.len(), "width": width, "height": height})),+        }+    }+    let key = if path.is_some() { "files" } else { "images" };+    Ok(json!({key: out, "pages": pages.len()}))+}++/// `page.png` for one page, `page-3.png` for page 3 of several: the number goes before the+/// extension, as InDesign's own export names them.+fn page_file_name(path: &str, page: usize, numbered: bool) -> String {+    if !numbered {+        return path.to_string();+    }+    match path.rsplit_once('.') {+        Some((stem, ext)) if !stem.is_empty() && !ext.contains(['/', '\\']) => format!("{stem}-{page}.{ext}"),+        _ => format!("{path}-{page}"),+    }+}+ fn has_story_target(s: &Session) -> std::result::Result<(), String> {     super::text::story_of(s, &Value::Null).map(|_| ()).ok_or_else(|| "edit text or select a text frame".into()) }@@ -1440,6 +1510,77 @@ mod booklet_tests {     } } +#[cfg(test)]+mod export_png_tests {+    use serde_json::json;++    use crate::Session;+    use crate::cmd::base64_decode;++    /// A page of each: one with a black box, one left empty.+    fn two_pages() -> Session {+        let mut s = Session::new();+        s.execute("file.new", &json!({"pages": 2, "facingPages": false, "width": 100, "height": 50})).unwrap();+        s.execute("frame.create", &json!({"rect": [10, 10, 90, 40]})).unwrap();+        s.execute("object.fill", &json!({"swatch": "[Black]"})).unwrap();+        s+    }++    /// The alpha of a corner pixel (away from the artwork) and of the middle (on it).+    fn corner_and_middle(png: &[u8]) -> ((u8, u8), (u8, u8)) {+        let img = designcraft_images::decode_rgba(png).expect("the export is a readable PNG");+        let at = |x: u32, y: u32| {+            let p = img.get_pixel(x, y).0;+            (p[3], p[0])+        };+        (at(2, 2), at(img.width() / 2, img.height() / 2))+    }++    /// Export ▸ PNG wrote the active page only, flattened onto white (#1581). It now writes one+    /// file per page, numbered, at a chosen resolution, and can leave the background transparent.+    #[test]+    fn every_page_is_its_own_png_and_can_be_transparent() {+        let mut s = two_pages();++        // All pages by default, one image each, numbered from 1.+        let r = s.execute("file.exportPng", &json!({"ppi": 72})).unwrap();+        let images = r["images"].as_array().expect("one entry per page").clone();+        assert_eq!((r["pages"].as_u64(), images.len()), (Some(2), 2));+        assert_eq!(images.iter().map(|i| i["page"].as_u64().unwrap()).collect::<Vec<_>>(), vec![1, 2]);+        // 100 x 50 pt at 72 ppi is 100 x 50 px.+        assert_eq!((images[0]["width"].as_u64(), images[0]["height"].as_u64()), (Some(100), Some(50)));++        // The default background is white and opaque, as before.+        let white = base64_decode(images[0]["base64"].as_str().unwrap());+        let ((corner_a, corner_r), (mid_a, mid_r)) = corner_and_middle(&white);+        assert!(corner_a == 255 && corner_r > 200, "the corner is opaque white: alpha {corner_a}, red {corner_r}");+        assert!(mid_a == 255 && mid_r < 60, "the box is drawn: alpha {mid_a}, red {mid_r}");++        // Transparent: the corner has no alpha at all, and the artwork still does.+        let r = s.execute("file.exportPng", &json!({"ppi": 72, "transparent": true, "pages": [1]})).unwrap();+        let clear = base64_decode(r["images"][0]["base64"].as_str().unwrap());+        let ((corner_a, _), (mid_a, mid_r)) = corner_and_middle(&clear);+        assert_eq!(corner_a, 0, "nothing was drawn in the corner, so it is transparent");+        assert!(mid_a == 255 && mid_r < 60, "the box is still opaque black: alpha {mid_a}, red {mid_r}");++        // Resolution: 300 ppi is 300/72 of the pt size.+        let r = s.execute("file.exportPng", &json!({"ppi": 300, "pages": [1]})).unwrap();+        assert_eq!(r["images"][0]["width"].as_u64(), Some((100.0 * 300.0 / 72.0_f64).round() as u64));+        // And a resolution the renderer cannot mean is refused rather than clamped silently.+        assert!(s.execute("file.exportPng", &json!({"ppi": 0})).is_err());+        assert!(s.execute("file.exportPng", &json!({"ppi": 5000})).is_err());+    }++    /// Several pages become numbered files; one page keeps the name it was given.+    #[test]+    fn file_names_carry_the_page_number_only_when_there_are_several() {+        assert_eq!(super::page_file_name("out.png", 3, true), "out-3.png");+        assert_eq!(super::page_file_name("out.png", 1, false), "out.png");+        assert_eq!(super::page_file_name("dir.v2/out", 2, true), "dir.v2/out-2");+        assert_eq!(super::page_file_name("out", 2, true), "out-2");+    }+}+ #[cfg(test)] mod placed_pdf_print_tests {     use serde_json::json;diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -432,6 +432,24 @@ impl Renderer {      /// Render one document page (absolute index) at `scale` px/pt, optionally including bleed.     pub fn render_page(&mut self, doc: &Document, cache: &Cache, abs: usize, scale: f64, bleed: bool, opts: &RenderOptions) -> Option<Rendered> {+        self.render_page_on(doc, cache, abs, scale, bleed, opts, Some([255, 255, 255, 255]))+    }++    /// [`Self::render_page`] with the canvas background the caller chooses. `None` leaves the page+    /// transparent wherever nothing is drawn, which is what a PNG export with a transparent+    /// background needs (#1581); the paper fill is the caller's to turn off through+    /// [`RenderOptions::paper`], since paper is artwork and the canvas is not.+    #[allow(clippy::too_many_arguments)]+    pub fn render_page_on(+        &mut self,+        doc: &Document,+        cache: &Cache,+        abs: usize,+        scale: f64,+        bleed: bool,+        opts: &RenderOptions,+        background: Option<[u8; 4]>,+    ) -> Option<Rendered> {         let (si, pi) = doc.page_loc(abs)?;         let page = &doc.spreads[si].pages[pi];         let mut r = page.bounds();@@ -448,7 +466,7 @@ impl Renderer {         }         let view = Affine::scale(scale) * Affine::translate(-r.origin().to_vec2());         let mut o = opts.clone();-        o.background = Some([255, 255, 255, 255]);+        o.background = background;         Some(self.render(doc, cache, &[Placed { spread: SpreadRef::Doc(si), xf: Affine::translate(Vec2::ZERO) }], w, h, view, &o))     }