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