diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1134,6 +1134,7 @@ dependencies = [
  "designcraft-fonts",
  "image",
  "log",
+ "png",
  "resvg",
  "tiff",
  "zune-core",
@@ -1207,6 +1208,7 @@ dependencies = [
  "designcraft-images",
  "image",
  "log",
+ "png",
  "serde",
  "thiserror 2.0.21",
  "vello_common 0.2.0",
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -61,6 +61,7 @@ vello_common = { version = "0.2", default-features = false }
 image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
 # CMYK TIFF samples for PDF export (`image` decodes them to RGB); the version `image` uses.
 tiff = "0.11"
+png = "0.18"
 # SVG (same usvg as krilla-svg).
 resvg = { version = "0.47", default-features = false, features = ["text"] }
 krilla-svg = "=0.8.1"
diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -3,7 +3,7 @@
 **Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
 - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
 
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (PNG export resolution options, physical-size metadata and resolution-aware PNG placement; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
 - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
 
 - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
@@ -346,6 +346,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
 
 ## Progress log
 
+- **2026-10-23** · PNG exports retain print size through embedded resolution metadata. Export Page as PNG offers a resolution field and pixel/mm preview; CLI scale sets matching metadata. New PNG placements honour absolute resolution without changing saved frame geometry. No readiness percentage change.
+
 Newest first.
 
 - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs
--- a/apps/designcraft-cli/src/main.rs
+++ b/apps/designcraft-cli/src/main.rs
@@ -396,7 +396,13 @@ fn export(s: &mut Session, out: &str, page: usize, scale: f64) -> Result<(), Str
         .render_page(&st.doc, &s.cache, page, scale, true, &designcraft_render::RenderOptions { printing_only: true, ..Default::default() })
         .ok_or("no such page")?;
     let lower = out.to_lowercase();
-    let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() };
+    let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") {
+        img.to_jpeg(90)
+    } else {
+        let ppi = scale * 72.0;
+        let ppm = (ppi / 0.0254).round();
+        if ppi.is_finite() && (1.0..=f64::from(u32::MAX)).contains(&ppm) { img.to_png_with_resolution(ppi)? } else { img.to_png() }
+    };
     designcraft_engine::atomic::write_atomic(std::path::Path::new(out), &bytes).map_err(|e| format!("{out}: {e}"))?;
     eprintln!("wrote {out} ({}×{}, {:.1} ms, {} glyphs)", img.width, img.height, t.elapsed().as_secs_f64() * 1000.0, r.stats.glyphs);
     Ok(())
diff --git a/apps/designcraft-cli/tests/it/cli.rs b/apps/designcraft-cli/tests/it/cli.rs
--- a/apps/designcraft-cli/tests/it/cli.rs
+++ b/apps/designcraft-cli/tests/it/cli.rs
@@ -7,6 +7,47 @@ fn cli() -> Command {
     Command::new(env!("CARGO_BIN_EXE_designcraft-cli"))
 }
 
+#[test]
+fn png_export_preserves_tiny_positive_scales() {
+    let dir = std::env::temp_dir().join(format!("dc-cli-png-tiny-{}", std::process::id()));
+    std::fs::create_dir_all(&dir).unwrap();
+    let path = dir.join("page.png");
+    for scale in ["0.0001", "1e-100"] {
+        let out = cli().args(["run", "--sample", "--scale", scale, "--export", path.to_str().unwrap()]).output().unwrap();
+        assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+        let bytes = std::fs::read(&path).unwrap();
+        assert_eq!(designcraft_render::image_size(&bytes), Some((1, 1)));
+    }
+    std::fs::remove_dir_all(dir).unwrap();
+}
+
+#[test]
+fn png_export_scale_sets_physical_resolution() {
+    let dir = std::env::temp_dir().join(format!("dc-cli-png-resolution-{}", std::process::id()));
+    std::fs::create_dir_all(&dir).unwrap();
+    let path = dir.join("page.png");
+    for (scale, pixels) in [("2", (144, 72)), ("4.166666666666667", (300, 150))] {
+        let out = cli()
+            .args([
+                "run",
+                "--cmd",
+                "layout.documentSetup={\"width\":72,\"height\":36,\"bleed\":0}",
+                "--scale",
+                scale,
+                "--export",
+                path.to_str().unwrap(),
+            ])
+            .output()
+            .unwrap();
+        assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
+        let bytes = std::fs::read(&path).unwrap();
+        assert_eq!(designcraft_render::image_size(&bytes), Some(pixels));
+        let (w, h) = designcraft_render::natural_size(&bytes, 0).unwrap();
+        assert!((w - 72.0).abs() < 0.02 && (h - 36.0).abs() < 0.02, "{w}, {h}");
+    }
+    std::fs::remove_dir_all(dir).unwrap();
+}
+
 #[test]
 fn script_from_stdin_with_references_saves_and_exports() {
     let dir = std::env::temp_dir().join(format!("dc-cli-{}", std::process::id()));
diff --git a/crates/images/Cargo.toml b/crates/images/Cargo.toml
--- a/crates/images/Cargo.toml
+++ b/crates/images/Cargo.toml
@@ -12,6 +12,7 @@ workspace = true
 designcraft-color = { workspace = true }
 designcraft-fonts = { workspace = true }
 image = { workspace = true }
+png = { workspace = true }
 tiff = { workspace = true }
 resvg = { workspace = true }
 log = { workspace = true }
diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs
--- a/crates/images/src/lib.rs
+++ b/crates/images/src/lib.rs
@@ -212,8 +212,22 @@ pub fn svg_tree(bytes: &[u8]) -> Option<usvg::Tree> {
     usvg::Tree::from_data(bytes, svg_options()).ok()
 }
 
-/// Natural size of a placed graphic in points: rasters at 72 ppi (one pixel per point), PSDs
-/// likewise, SVGs from their width/height (CSS pixels), PDFs are the caller's.
+/// PNG resolution in pixels per metre when both axes have a nonzero absolute unit.
+/// Reads metadata only, with a bounded metadata allocation; no image pixels are decoded.
+pub fn png_pixels_per_metre(bytes: &[u8]) -> Option<(u32, u32)> {
+    if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
+        return None;
+    }
+    let mut decoder = png::Decoder::new_with_limits(std::io::Cursor::new(bytes), png::Limits { bytes: 1 << 20 });
+    decoder.set_ignore_text_chunk(true);
+    decoder.set_ignore_iccp_chunk(true);
+    let reader = decoder.read_info().ok()?;
+    let dims = reader.info().pixel_dims?;
+    (dims.unit == png::Unit::Meter && dims.xppu > 0 && dims.yppu > 0).then_some((dims.xppu, dims.yppu))
+}
+
+/// Natural size of a placed graphic in points: PNGs with absolute resolution use it;
+/// other rasters default to 72 ppi, SVGs use CSS pixels. PDFs are the caller's.
 pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> {
     if is_svg(bytes) {
         let s = svg_tree(bytes)?.size();
@@ -224,6 +238,9 @@ pub fn natural_size(bytes: &[u8]) -> Option<(f64, f64)> {
         return Some((bb[2] - bb[0], bb[3] - bb[1])).filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0);
     }
     let (w, h) = pixel_size(bytes)?;
+    if let Some((x, y)) = png_pixels_per_metre(bytes) {
+        return Some((f64::from(w) * 72.0 / (f64::from(x) * 0.0254), f64::from(h) * 72.0 / (f64::from(y) * 0.0254)));
+    }
     Some((w as f64, h as f64))
 }
 
@@ -497,6 +514,40 @@ pub fn render_svg(bytes: &[u8], max_side: u32) -> Option<(Vec<u8>, u32, u32)> {
 mod tests {
     use super::*;
 
+    fn png_with_density(density: Option<png::PixelDimensions>) -> Vec<u8> {
+        let mut bytes = Vec::new();
+        {
+            let mut enc = png::Encoder::new(&mut bytes, 144, 72);
+            enc.set_color(png::ColorType::Rgba);
+            enc.set_depth(png::BitDepth::Eight);
+            enc.set_pixel_dims(density);
+            enc.write_header().unwrap().write_image_data(&vec![255; 144 * 72 * 4]).unwrap();
+        }
+        bytes
+    }
+
+    #[test]
+    fn png_natural_size_uses_absolute_resolution_and_keeps_pixel_dimensions() {
+        let bytes = png_with_density(Some(png::PixelDimensions { xppu: 5669, yppu: 2835, unit: png::Unit::Meter }));
+        assert_eq!(pixel_size(&bytes), Some((144, 72)));
+        let (w, h) = natural_size(&bytes).unwrap();
+        assert!((w - 72.0).abs() < 0.02 && (h - 72.0).abs() < 0.02, "independent resolution axes: {w}, {h}");
+    }
+
+    #[test]
+    fn png_unknown_zero_or_missing_resolution_keeps_legacy_size() {
+        for density in [
+            None,
+            Some(png::PixelDimensions { xppu: 5669, yppu: 5669, unit: png::Unit::Unspecified }),
+            Some(png::PixelDimensions { xppu: 0, yppu: 5669, unit: png::Unit::Meter }),
+        ] {
+            let bytes = png_with_density(density);
+            assert_eq!(natural_size(&bytes), Some((144.0, 72.0)));
+        }
+        assert_eq!(png_pixels_per_metre(b"not png"), None);
+        assert_eq!(png_pixels_per_metre(b"\x89PNG\r\n\x1a\n"), None);
+    }
+
     const SVG: &str = r##"<?xml version="1.0"?>
 <svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">
   <rect x="0" y="0" width="100" height="100" fill="#ff0000"/>
diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml
--- a/crates/render/Cargo.toml
+++ b/crates/render/Cargo.toml
@@ -17,6 +17,7 @@ designcraft-compose = { workspace = true }
 vello_cpu = { workspace = true }
 vello_common = { workspace = true }
 image = { workspace = true }
+png = { workspace = true }
 craft-pdf = { workspace = true, features = ["raster"] }
 designcraft-images = { workspace = true }
 log = { workspace = true }
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
@@ -70,20 +70,43 @@ impl Rendered {
         out
     }
     /// PNG bytes (empty, with the error logged, if the image can't be encoded).
+    /// Thumbnails and screen captures have no physical resolution.
     pub fn to_png(&self) -> Vec<u8> {
-        let mut buf = Vec::new();
-        let mut px = self.to_straight();
-        // `pixels` is public: a mismatched buffer is padded or cut to the stated size.
-        px.resize(self.width as usize * self.height as usize * 4, 0);
-        let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else {
-            log::error!("PNG encode: {}×{} image is too large", self.width, self.height);
-            return Vec::new();
-        };
-        if let Err(e) = img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) {
+        self.encode_png(None).unwrap_or_else(|e| {
             log::error!("PNG encode: {e}");
-            return Vec::new();
+            Vec::new()
+        })
+    }
+
+    /// Export a page with its physical resolution, so consumers can recover its print size.
+    pub fn to_png_with_resolution(&self, ppi: f64) -> Result<Vec<u8>, String> {
+        let ppm = (ppi / 0.0254).round();
+        if !ppi.is_finite() || ppm < 1.0 || ppm > f64::from(u32::MAX) {
+            return Err("PNG resolution must be positive, finite and representable in pixels per metre".into());
         }
-        buf
+        self.encode_png(Some(ppm as u32))
+    }
+
+    fn encode_png(&self, ppm: Option<u32>) -> Result<Vec<u8>, String> {
+        let len = u64::from(self.width) * u64::from(self.height);
+        if self.width == 0 || self.height == 0 || len > MAX_PAGE_PIXELS {
+            return Err("PNG dimensions are empty or too large".into());
+        }
+        let len = usize::try_from(len).ok().and_then(|n| n.checked_mul(4)).ok_or("PNG buffer is too large")?;
+        let mut px = self.to_straight();
+        // Public buffers may be short or long: retain the existing padding/cutting behaviour.
+        px.resize(len, 0);
+        let mut buf = Vec::new();
+        {
+            let mut encoder = png::Encoder::new(&mut buf, self.width, self.height);
+            encoder.set_color(png::ColorType::Rgba);
+            encoder.set_depth(png::BitDepth::Eight);
+            encoder.set_pixel_dims(ppm.map(|ppm| png::PixelDimensions { xppu: ppm, yppu: ppm, unit: png::Unit::Meter }));
+            let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
+            writer.write_image_data(&px).map_err(|e| e.to_string())?;
+            writer.finish().map_err(|e| e.to_string())?;
+        }
+        Ok(buf)
     }
     pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
         let rgba = self.to_straight();
@@ -1218,7 +1241,8 @@ pub fn image_size(bytes: &[u8]) -> Option<(u32, u32)> {
 }
 
 /// Natural size of a placed graphic, unrounded: a PDF page (crop box) and an SVG in points, a raster
-/// in pixels, an EPS its bounding box independently of preview pixels. Placed graphics are drawn at
+/// in points using PNG absolute resolution when present (otherwise pixels), an EPS its bounding box
+/// independently of preview pixels. Placed graphics are drawn at
 /// this size; `image_size` rounds it to whole pixels for display.
 pub fn natural_size(bytes: &[u8], page: usize) -> Option<(f64, f64)> {
     if is_pdf(bytes) {
@@ -1560,6 +1584,21 @@ pub fn image_mime(bytes: &[u8]) -> &'static str {
 
 #[cfg(test)]
 mod tests {
+    #[test]
+    fn png_export_resolution_retains_print_size_and_straight_alpha() {
+        let rendered = Rendered { width: 144, height: 72, pixels: [64, 32, 16, 128].repeat(144 * 72) };
+        let bytes = rendered.to_png_with_resolution(144.0).unwrap();
+        assert_eq!(designcraft_images::png_pixels_per_metre(&bytes), Some((5669, 5669)));
+        let size = designcraft_images::natural_size(&bytes).unwrap();
+        assert!((size.0 - 72.0).abs() < 0.02 && (size.1 - 36.0).abs() < 0.02);
+        let rgba = image::load_from_memory(&bytes).unwrap().to_rgba8();
+        assert_eq!(rgba.get_pixel(0, 0).0, [128, 64, 32, 128]);
+        assert_eq!(designcraft_images::png_pixels_per_metre(&rendered.to_png()), None, "screenshots remain unitless");
+        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::MAX, 0.0001] {
+            assert!(rendered.to_png_with_resolution(bad).is_err(), "{bad}");
+        }
+    }
+
     use super::*;
     use designcraft_doc::build::NewDocument;
     use designcraft_doc::{Fill, ParaFormat};
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -384,7 +384,21 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
             if id == "findChange" || app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange") {
                 app.session.reset_find_scope();
             }
-            let d = crate::dialogs::Dialog::new(id, p.get("fields").cloned().unwrap_or(Value::Null));
+            let fields = p.get("fields").cloned().unwrap_or(Value::Null);
+            let d = if id == "pngExport" {
+                // Seeded like File › Export › PNG (page, ppi, the document it exports); the
+                // caller's fields go on top, except the document, which is always the active one.
+                if let Err(e) = crate::dialogs::open_png_export(app) {
+                    return err(e);
+                }
+                let Some(mut d) = app.ui.dialog.take() else { return err("PNG export did not open") };
+                if let Value::Object(m) = fields {
+                    d.fields.extend(m.into_iter().filter(|(k, _)| k != "documentUid"));
+                }
+                d
+            } else {
+                crate::dialogs::Dialog::new(id, fields)
+            };
             let v = serde_json::to_value(&d).unwrap_or_default();
             app.ui.dialog = Some(d);
             ok(v)
@@ -506,6 +520,27 @@ mod automation_contract_tests {
         app.run("style.paragraph.edit", json!({"name":designcraft_doc::BASIC_PARAGRAPH,"chars":{"size":18}})).unwrap();
         assert_eq!(app.session.doc().unwrap().history.undo.len(), history + 1);
     }
+    /// `ui.dialog.open` with `pngExport` seeds the dialog like the menu item, so Confirm exports.
+    #[test]
+    fn opening_png_export_by_id_seeds_it_like_the_menu_item() {
+        let mut app = app();
+        let ctx = egui::Context::default();
+        let response = call(&mut app, &ctx, "ui.dialog.open", json!({"id":"pngExport","fields":{"ppi":300,"documentUid":12345}}));
+        assert_eq!(response["ok"], true);
+        let d = app.ui.dialog.as_ref().unwrap();
+        assert_eq!(d.id, "pngExport");
+        assert_eq!(d.fields["ppi"], 300, "the caller's fields go on top");
+        assert_eq!(d.fields["documentUid"], json!(app.session.active().map(|st| st.uid)), "always the active document");
+        assert!(d.fields.contains_key("page"));
+        app.services.write = Some(Box::new(|_, _| Ok(())));
+        app.services.pick_save = Some(Box::new(|_| Some("/tmp/page.png".into())));
+        let confirmed = call(&mut app, &ctx, "ui.dialog.confirm", json!({}));
+        assert_eq!(confirmed["ok"], true, "{confirmed}");
+
+        let mut empty = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+        assert_eq!(call(&mut empty, &ctx, "ui.dialog.open", json!({"id":"pngExport"}))["ok"], false, "no document to export");
+        assert!(empty.ui.dialog.is_none());
+    }
     fn app() -> DesignApp {
         let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
         app.session.execute("file.new", &json!({"pages":2})).unwrap();
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -337,6 +337,46 @@ pub fn open_print(app: &mut DesignApp) {
     ));
 }
 
+/// Export the current page, with an explicit print resolution and size preview.
+pub fn open_png_export(app: &mut DesignApp) -> Result<Value, String> {
+    let options = crate::menus::png_export_options(app, &json!({}))?;
+    app.ui.dialog = Some(Dialog::new("pngExport", json!({"page": options.page, "ppi": 144, "documentUid": app.session.active().map(|st| st.uid)})));
+    Ok(Value::Null)
+}
+
+fn png_export(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
+    ui.horizontal(|ui| {
+        crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Resolution (ppi):"));
+        text_field(ui, d, "ppi", 90.0);
+        for ppi in [72, 144, 150, 300, 600] {
+            if ui.button(ppi.to_string()).clicked() {
+                d.fields.insert("ppi".into(), json!(ppi));
+            }
+        }
+    });
+    match png_export_params(app, d).and_then(|p| crate::menus::png_export_options(app, &p)) {
+        Ok(o) => {
+            crate::rtl::label(ui, format!("{} × {} px", o.width, o.height));
+            crate::rtl::label(ui, format!("{:.1} × {:.1} mm", o.width_mm, o.height_mm));
+        }
+        Err(e) => {
+            crate::rtl::label(ui, e);
+        }
+    }
+    if !d.s("error").is_empty() {
+        crate::rtl::label(ui, d.s("error"));
+    }
+}
+
+fn png_export_params(app: &DesignApp, d: &Dialog) -> Result<Value, String> {
+    let uid = d.fields.get("documentUid").and_then(Value::as_u64);
+    if uid.is_none() || uid != app.session.active().map(|st| st.uid) {
+        return Err("The active document changed; reopen PNG export for this document".into());
+    }
+    let ppi = d.n("ppi").filter(|n| n.is_finite()).ok_or("Resolution must be a number")?;
+    Ok(json!({"page": d.fields.get("page"), "ppi": ppi}))
+}
+
 /// File › Export PDF…: the "Export PDF" options dialog, seeded from the persisted settings
 /// (`UiState.pdf_export`) and the active document. `pages` is per-job (not persisted); `flatten`
 /// falls back to the global flattener preset when the persisted value is empty.
@@ -2086,6 +2126,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
         "print" => crate::i18n::tr(&app.ui.language, "Print"),
         "pdfImport" => crate::i18n::tr(&app.ui.language, "Place PDF"),
         "pdfExport" => crate::i18n::tr(&app.ui.language, "Export PDF"),
+        "pngExport" => crate::i18n::tr(&app.ui.language, "Export Page as PNG"),
         "colorSettings" => crate::i18n::tr(&app.ui.language, "Color Settings"),
         "layerOptions" => crate::i18n::tr(&app.ui.language, "Object Layer Options"),
         "keyboardShortcuts" => crate::i18n::tr(&app.ui.language, "Keyboard Shortcuts"),
@@ -2805,6 +2846,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
                     }
                     "numberingSection" => crate::section_options::body(app, ui, &mut d),
                     "pdfExport" => pdf_export(app, ui, &mut d),
+                    "pngExport" => png_export(app, ui, &mut d),
                     "alert" => {
                         let file = d.s("file");
                         if !file.is_empty() {
@@ -3047,6 +3089,16 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
         app.cancel_pdf_import();
     }
     match d.id.as_str() {
+        "pngExport" => {
+            let result = png_export_params(app, &d).and_then(|p| crate::menus::export_png(app, &p));
+            if let Err(e) = &result {
+                let mut d = d;
+                d.fields.insert("error".into(), json!(e));
+                app.ui.dialog = Some(d);
+            }
+            result
+        }
+
         "colorTheme" => {
             let result = app.run("swatch.addColorTheme", json!({"name": d.s("name"), "colors": d.fields.get("colors")}));
             if let Err(error) = &result {
@@ -9489,6 +9541,55 @@ mod tests {
         assert_eq!(d.fields, before);
     }
 
+    #[test]
+    fn png_export_dialog_rejects_a_different_document() {
+        let mut app = test_app();
+        app.services.pick_save = Some(Box::new(|_| panic!("must reject before picking a file")));
+        open_png_export(&mut app).unwrap();
+        app.session.execute("file.new", &json!({})).unwrap();
+        let error = confirm(&mut app).unwrap_err();
+        assert!(error.contains("active document changed"), "{error}");
+        assert!(app.ui.dialog.is_some());
+    }
+
+    #[test]
+    fn png_export_dialog_validates_and_writes_resolution_metadata() {
+        use std::sync::{Arc, Mutex};
+        let mut app = test_app();
+        app.run("layout.documentSetup", json!({"width":72,"height":36})).unwrap();
+        let writes = Arc::new(Mutex::new(Vec::<Vec<u8>>::new()));
+        let sink = writes.clone();
+        app.services.write = Some(Box::new(move |_, bytes| {
+            sink.lock().unwrap().push(bytes.to_vec());
+            Ok(())
+        }));
+        app.services.pick_save = Some(Box::new(|_| Some("out.png".into())));
+        open_png_export(&mut app).unwrap();
+        app.ui.dialog.as_mut().unwrap().fields.insert("ppi".into(), json!("bad"));
+        assert!(confirm(&mut app).is_err());
+        assert!(app.ui.dialog.is_some() && writes.lock().unwrap().is_empty());
+        app.ui.dialog.as_mut().unwrap().fields.insert("ppi".into(), json!("300"));
+        let out = confirm(&mut app).unwrap();
+        assert_eq!((out["width"].as_u64(), out["height"].as_u64()), (Some(300), Some(150)));
+        let bytes = writes.lock().unwrap();
+        assert_eq!(bytes.len(), 1);
+        // Reuse the engine's placement path to check that export and import agree on print size.
+        let mut session = designcraft_engine::Session::new();
+        session.execute("file.new", &json!({"width":1000,"height":1000})).unwrap();
+        let placed = session
+            .execute("file.place", &json!({"name":"export.png","base64":designcraft_engine::cmd::base64_encode(&bytes[0]),"x":0,"y":0}))
+            .unwrap();
+        let id = designcraft_doc::ItemId(placed["id"].as_u64().unwrap());
+        let bounds = session.active().unwrap().doc.item(id).unwrap().bounds();
+        assert!((bounds.width() - 72.0).abs() < 0.02 && (bounds.height() - 36.0).abs() < 0.02, "{bounds:?}");
+        drop(bytes);
+        // Canceling the save picker produces no additional output.
+        app.services.pick_save = Some(Box::new(|_| None));
+        open_png_export(&mut app).unwrap();
+        assert!(confirm(&mut app).unwrap().is_null());
+        assert_eq!(writes.lock().unwrap().len(), 1);
+    }
+
     #[test]
     fn confirm_pdf_export_writes_file_and_persists_settings() {
         use std::sync::{Arc, Mutex};
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -41,7 +41,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
     ),
     ("app.packageDialog", "Package…", Some("Cmd+Alt+Shift+P"), "{} — choose the package folder to create (file.package)"),
     ("app.saveSwatches", "Save Swatches for Exchange…", None, "{path?} — write the colour swatches as .ase"),
-    ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"),
+    (
+        "app.exportPng",
+        "Export Page as PNG…",
+        Some("Cmd+E"),
+        "{path?, page?: 0-based, ppi?: 144, scale?: pixels per point (legacy alternative to ppi)} — empty params open resolution options; exports PNG with physical resolution",
+    ),
     ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"),
     ("app.exportEpub", "Export EPUB…", None, "{path?} — reflowable EPUB 3 (with the first page as its cover)"),
     (
@@ -1087,7 +1092,13 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
             }
             Ok(Value::Null)
         }
-        "app.exportPng" => export_png(app, p),
+        "app.exportPng" => {
+            if p.is_null() || p.as_object().is_some_and(|m| m.is_empty()) {
+                crate::dialogs::open_png_export(app)
+            } else {
+                export_png(app, p)
+            }
+        }
         "app.exportIdml" => export_idml(app, p),
         "app.exportEpub" => {
             let mut q = if p.is_object() { p.clone() } else { json!({}) };
@@ -1991,10 +2002,59 @@ fn download_document(app: &mut DesignApp) -> Result<Value, String> {
     app.run("file.saveAs", json!({"path": name}))
 }
 
-fn export_png(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
+pub(crate) struct PngExportOptions {
+    pub page: usize,
+    pub ppi: f64,
+    pub width: u32,
+    pub height: u32,
+    pub width_mm: f64,
+    pub height_mm: f64,
+}
+
+pub(crate) fn png_export_options(app: &DesignApp, p: &Value) -> Result<PngExportOptions, String> {
     let st = app.session.active().ok_or("no document")?;
     let page = crate::control::page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count())?;
-    let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(2.0);
+    let ppi = p.get("ppi").filter(|v| !v.is_null());
+    let scale = p.get("scale").filter(|v| !v.is_null());
+    if ppi.is_some() && scale.is_some() {
+        return Err("Specify ppi or scale, not both".into());
+    }
+    let ppi = match (ppi, scale) {
+        (Some(v), _) => v.as_f64().ok_or("ppi must be a number")?,
+        (_, Some(v)) => v.as_f64().ok_or("scale must be a number")? * 72.0,
+        _ => 144.0,
+    };
+    let ppm = (ppi / 0.0254).round();
+    if !ppi.is_finite() || ppm < 1.0 || ppm > f64::from(u32::MAX) {
+        return Err("Resolution must be positive, finite and representable in pixels per metre".into());
+    }
+    let (si, pi) = st.doc.page_loc(page).ok_or("no such page")?;
+    let pg = st.doc.spreads.get(si).and_then(|s| s.pages.get(pi)).ok_or("no such page")?;
+    let width = (pg.width * ppi / 72.0).round().max(1.0);
+    let height = (pg.height * ppi / 72.0).round().max(1.0);
+    if !width.is_finite()
+        || !height.is_finite()
+        || width > f64::from(designcraft_render::MAX_SIDE)
+        || height > f64::from(designcraft_render::MAX_SIDE)
+        || width * height > designcraft_render::MAX_PAGE_PIXELS as f64
+    {
+        return Err("Resolution would exceed the page renderer's pixel limits; choose a lower resolution".into());
+    }
+    Ok(PngExportOptions {
+        page,
+        ppi,
+        width: width as u32,
+        height: height as u32,
+        width_mm: pg.width * 25.4 / 72.0,
+        height_mm: pg.height * 25.4 / 72.0,
+    })
+}
+
+pub(crate) fn export_png(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
+    let options = png_export_options(app, p)?;
+    let PngExportOptions { page, ppi, .. } = options;
+    let st = app.session.active().ok_or("no document")?;
+    let scale = ppi / 72.0;
     let mut r = designcraft_render::Renderer::new();
     let img = r
         .render_page(
@@ -2006,7 +2066,7 @@ fn export_png(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
             &designcraft_render::RenderOptions { printing_only: true, rich_black: app.session.prefs.rich_black_output, ..Default::default() },
         )
         .ok_or("no such page")?;
-    let png = img.to_png();
+    let png = img.to_png_with_resolution(ppi)?;
     let path = match p.get("path").and_then(Value::as_str) {
         Some(s) => Some(s.to_string()),
         None => {
@@ -2016,7 +2076,7 @@ fn export_png(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
     };
     let Some(path) = path else { return Ok(Value::Null) };
     match app.services.write.as_mut() {
-        Some(w) => w(&path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height})),
+        Some(w) => w(&path, &png).map(|_| json!({"path": path, "width": img.width, "height": img.height, "ppi": ppi, "widthMm": options.width_mm, "heightMm": options.height_mm})),
         None => Err("no writer".into()),
     }
 }
@@ -3515,6 +3575,26 @@ mod tests {
         }
     }
 
+    #[test]
+    fn png_export_options_keep_a3_size_at_144_and_300_ppi() {
+        let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+        app.run("file.new", json!({"width": 420.0 * 72.0 / 25.4, "height": 297.0 * 72.0 / 25.4})).unwrap();
+        let defaults = png_export_options(&app, &json!({})).unwrap();
+        assert_eq!((defaults.width, defaults.height), (2381, 1684));
+        let print = png_export_options(&app, &json!({"ppi":300})).unwrap();
+        assert_eq!((print.width, print.height), (4961, 3508));
+        assert!((print.width_mm - 420.0).abs() < 0.001 && (print.height_mm - 297.0).abs() < 0.001);
+        let legacy = png_export_options(&app, &json!({"scale":2})).unwrap();
+        assert_eq!((legacy.width, legacy.height), (defaults.width, defaults.height));
+        for p in
+            [json!({"ppi":0}), json!({"ppi":-1}), json!({"ppi":"300"}), json!({"ppi":1e30}), json!({"ppi":300,"scale":2}), json!({"scale":false})]
+        {
+            assert!(png_export_options(&app, &p).is_err(), "{p}");
+        }
+        app.run("app.exportPng", json!({})).unwrap();
+        assert_eq!(app.ui.dialog.as_ref().unwrap().id, "pngExport");
+    }
+
     #[test]
     fn pdf_export_menu_item_opens_the_dialog_or_exports() {
         // No params: the menu item opens the options dialog.
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -107,3 +107,18 @@ Table panel. `ui.set` with `closePanel` closes the last explicitly opened panel;
 `dockExpanded:false` hides the root dock while floating groups remain visible.
 The icon rail can reopen a collapsed dock through any root panel. Activating a
 floating panel also raises its group. These operations change only UI layout.
+
+### PNG print resolution
+
+`app.export` (or `engine.execute` with `command: "app.exportPng"`) accepts
+`{path, page?, ppi?}`. `page` is 0-based and `ppi` defaults to 144. The legacy
+`scale` argument is pixels per point, so `scale: 2` means 144 ppi. Supply `ppi`
+or `scale`, not both. The response includes `width`, `height`, `ppi`, `widthMm`
+and `heightMm`. PNGs carry absolute pixels-per-metre metadata; its integer
+rounding introduces a small print-size tolerance. Empty parameters open the
+resolution dialog. `ui.render` and screenshots remain unitless preview images.
+
+CLI `run --scale 4.166666666666667 --export page.png` exports at 300 ppi.
+Exported pixels are rounded to whole pixels. A landscape A3 page produces
+4961 × 3508 pixels at 300 ppi and 2381 × 1684 pixels at 144 ppi. No artwork
+fidelity is implied by these size checks.
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
 # File format parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (PNG export resolution and physical-size metadata; PNG placement reads absolute resolution; IDML liquid guide types, #872; .indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
 
 Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The
 InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`,
@@ -43,7 +43,7 @@ layer survive interchange; literal XML and round-trip tests are in
 |---|---|---|---|---|---|
 | PDF (print) | `.pdf` | ✓ all presets, PDF/X-1a/3/4, PDF/A, PDF/UA | ✓ krilla: real text with subset fonts, DeviceCMYK/RGB, spot Separations, bleed/slug, marks, spreads, compression options, remembered settings (#196); **PDF/X-4** with output intent and a validator (#223, #200); **PDF/A-2b**; tagged PDF | 53 tests in `crates/pdf`; no PDF/X-1a or X-3 (needs flattening to PDF 1.3/1.4); no presets / `.joboptions`; no external validator (veraPDF / callas) in CI | 18% |
 | PDF (interactive) | `.pdf` | ✓ | ✓ links, bookmarks, buttons, AcroForm fields, page transitions, object states | | 2% |
-| JPEG / PNG | `.jpg`, `.png` | ✓ | PNG from the UI; JPEG from the CLI only | no resolution / colour space options dialog for JPEG | 3% |
+| JPEG / PNG | `.jpg`, `.png` | ✓ | PNG resolution options, physical-size preview and embedded print resolution from UI/CLI; JPEG from the CLI only | no resolution / colour space options dialog for JPEG | 3% |
 | EPUB reflowable | `.epub` | ✓ | ✓ stories in article order, tables, footnotes and endnotes, CSS from styles, images, nav | | 2% |
 | EPUB fixed layout | `.epub` | ✓ (live HTML/CSS) | ~ page images with text kept for search | not live text | 1% |
 | HTML | `.html` | ✓ | ~ one self-contained page (#314) | | 1% |
@@ -60,7 +60,7 @@ layer survive interchange; literal XML and round-trip tests are in
 | Format | Ext | InDesign | DesignCraft | Notes | Weight |
 |---|---|---|---|---|---|
 | PDF / AI (PDF-compatible) | `.pdf`, `.ai` | ✓ page boxes, layers, pages | ✓ rendered with hayro, embedded as vector in PDF export, crop to page boxes | layer visibility depth; editable vectors not offered (#227) | 3% |
-| JPEG, PNG, GIF, BMP, WebP | | ✓ | ✓ embedded ICC profiles applied on screen; CMYK JPEGs (incl. YCCK) through their own profile or the working CMYK space | CMYK JPEG/TIFF keep inks in PDF (#35) | 2% |
+| JPEG, PNG, GIF, BMP, WebP | | ✓ | ✓ PNG absolute resolution used for new placements; embedded ICC profiles applied on screen; CMYK JPEGs (incl. YCCK) through their own profile or the working CMYK space | CMYK JPEG/TIFF keep inks in PDF (#35) | 2% |
 | TIFF | `.tif` | ✓ incl. CMYK, alpha, indexed colour, paths | ✓ incl. CMYK, embedded ICC profiles on screen | clipping paths from 8BIM resources unverified | 1% |
 | PSD | `.psd` | ✓ layers, comps, alpha, paths | ~ composite + Object Layer Options; embedded ICC profile and managed CMYK on screen | layer comps partial | 1% |
 | EPS | `.eps` | ✓ (PostScript) | ~ preview only (no interpreter) | | <1% |
