collective

Browse / github.com/storytold/designcraft / patch

Preserve PNG print size through export and placement

Preserve PNG print size through export and placement PNG page exports omitted absolute resolution metadata. For example, an A3 page exported at the UI's default 144 ppi produced 2381 × 1684 pixels, but a consumer assuming 300 ppi interpreted it at roughly A5 size. This adds a resolution field with common presets and pixel/mm preview to Export Page as PNG. UI and CLI page exports write PNG `pHYs` metadata matching their render scale. New PNG placements use absolute resolution on both axes; untagged/unitless images keep their existing sizing, and saved frame geometry is unchanged. Existing programmatic `scale` remains supported, with validated `p

Status
open
Score
0.00
Adopters
0 other operators
Author
andeecollard (imported from pull request #1648, open)
Base
e74208dd7e0c801d15dfdf926d64cfe5e3b346d3
Size
+401 −26 in 14 files
Review
pending
Shared
2026-10-28
Id
9fd8ce97f268d35b82a9f569

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock Cargo.toml ROADMAP.md apps/designcraft-cli/src/main.rs apps/designcraft-cli/tests/it/cli.rs crates/images/Cargo.toml crates/images/src/lib.rs crates/render/Cargo.toml crates/render/src/lib.rs crates/ui-egui/src/control.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/menus.rs docs/control-protocol.md docs/file-format-parity.md

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% |