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
@@ -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;@@ -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)) }