diff --git a/ROADMAP.md b/ROADMAP.md index b47272c3..dda5d270 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -200,6 +200,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-14 | Presenter view pauses and resets its timer (T, R) and changes notes size (A-/A+, brackets). The clock shows local time. Second-display placement stays with #433. Breadth stays 79% | | 2026-10-14 | See All Slides and zoom during a show: a thumbnail grid (G, or All), click to jump, and click-to-zoom at 2× (Z). Presenter view has Black, White, All slides and Zoom. Breadth stays 79% | | 2026-10-13 | The command palette skips commands that need a value, so searching for Export runs the export dialog (#79) | +| 2026-10-14 | PNG and JPEG slide export record their resolution. The default 2 pixels per point is 144 dpi, so Preview and print show the slide's real size instead of twice that | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | | 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting | diff --git a/apps/deckcraft-cli/src/main.rs b/apps/deckcraft-cli/src/main.rs index e480375f..107861d8 100644 --- a/apps/deckcraft-cli/src/main.rs +++ b/apps/deckcraft-cli/src/main.rs @@ -358,7 +358,7 @@ fn render_transition(args: &[String]) -> Result<(), String> { let name = format!("{stem}-t{t:.3}.png"); p.with_file_name(name).to_string_lossy().into_owned() }; - std::fs::write(&path, img.to_png()).map_err(|e| format!("{path}: {e}"))?; + std::fs::write(&path, img.to_png_at(render::dpi_for_scale(opts.scale))).map_err(|e| format!("{path}: {e}"))?; outln!("{path} ({}×{}) {} {} t={t}", img.width, img.height, tr.kind, tr.option); } Ok(()) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index bc80f260..d7356b23 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -382,9 +382,18 @@ pub fn image_scale(doc: &Presentation, scale: f64) -> f64 { /// Render slide `index` (current when absent) to PNG bytes. pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec, u32, u32) { - let img = - deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale: image_scale(doc, scale), edit, ..Default::default() }); - (img.to_png(), img.width, img.height) + let scale = image_scale(doc, scale); + let img = deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale, edit, ..Default::default() }); + (img.to_png_at(deckcraft_render::dpi_for_scale(scale)), img.width, img.height) +} + +fn finite_scale(scale: f64) -> f64 { + if scale.is_finite() { scale.clamp(0.01, 16.0) } else { 1.0 } +} + +fn picture_bytes(img: &deckcraft_render::Image, format: &str, scale: f64) -> Vec { + let dpi = deckcraft_render::dpi_for_scale(scale); + if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) } } fn render(s: &mut Session, p: &Value) -> Result { @@ -424,7 +433,7 @@ fn export(s: &mut Session, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string(); let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string(); let st = s.doc()?; - let scale = f64_or(p, "scale", 2.0); + let scale = finite_scale(f64_or(p, "scale", 2.0)); match format.as_str() { "png" | "jpeg" | "jpg" => { let slides: Vec = match usize_param(p, "slide") { @@ -440,7 +449,7 @@ fn export(s: &mut Session, p: &Value) -> Result { let mut written = vec![]; for (k, i) in slides.iter().enumerate() { let img = deckcraft_render::render_slide(&st.doc, *i, &deckcraft_render::RenderOpts { scale, ..Default::default() }); - let bytes = if format == "png" { img.to_png() } else { img.to_jpeg(92) }; + let bytes = picture_bytes(&img, &format, scale); let out = if slides.len() == 1 { path.clone() } else { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c144110d..7bf11e86 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2249,3 +2249,31 @@ fn notes_master_view_edits_and_saves() { s.execute("view.closeMaster", &json!({})).unwrap(); assert_eq!(s.execute("view.notesMaster", &json!({})).unwrap()["created"], false); } + +/// Default picture export is 2 px per point. The file says 144 dpi, so Preview shows the slide's size. +#[test] +fn a_png_export_records_the_slide_resolution() { + let mut s = session(); + let dir = std::env::temp_dir().join(format!("deckcraft-dpi-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("slide.png"); + s.execute("file.export", &json!({"path": png.to_str().unwrap(), "format": "png"})).unwrap(); + let bytes = std::fs::read(&png).unwrap(); + assert_eq!(deckcraft_render::image_dpi(&bytes), Some((144.0, 144.0))); + assert_eq!(png_size(&bytes), Some((1920, 1080)), "960×540 pt at 2 px per point"); + let one = dir.join("one.png"); + s.execute("file.export", &json!({"path": one.to_str().unwrap(), "format": "png", "scale": 1})).unwrap(); + let one_bytes = std::fs::read(&one).unwrap(); + assert_eq!(deckcraft_render::image_dpi(&one_bytes), Some((72.0, 72.0))); + assert_eq!(png_size(&one_bytes), Some((960, 540))); + let jpg = dir.join("slide.jpg"); + s.execute("file.export", &json!({"path": jpg.to_str().unwrap(), "format": "jpeg"})).unwrap(); + assert_eq!(deckcraft_render::image_dpi(&std::fs::read(&jpg).unwrap()), Some((144.0, 144.0))); + let _ = std::fs::remove_dir_all(&dir); +} + +fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { + let w: [u8; 4] = bytes.get(16..20)?.try_into().ok()?; + let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?; + Some((u32::from_be_bytes(w), u32::from_be_bytes(h))) +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 483fc181..a6cea8f9 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -108,7 +108,7 @@ impl Headless { i, &deckcraft_engine::render::RenderOpts { scale, edit: p.get("edit").and_then(Value::as_bool).unwrap_or(false), ..Default::default() }, ); - let png = img.to_png(); + let png = img.to_png_at(deckcraft_engine::render::dpi_for_scale(scale)); if let Some(path) = s(p, "path") { std::fs::write(path, &png).map_err(|e| format!("{path}: {e}"))?; return Ok(json!({"path": path, "width": img.width, "height": img.height})); diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 24e6da08..9bd6c421 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -226,6 +226,97 @@ pub fn dpi(bytes: &[u8]) -> Option<(f64, f64)> { None } +/// A copy of a PNG with a `pHYs` chunk saying `dpi` dots per inch. +/// +/// Anything that is not a PNG, or a resolution the reader would reject, is returned unchanged. +/// A file that already has `pHYs` is left alone. +pub(crate) fn stamp_png_dpi(png: &[u8], dpi: f64) -> Vec { + let Some(ppm) = pixels_per_metre(dpi) else { return png.to_vec() }; + let Some(end) = ihdr_end(png) else { return png.to_vec() }; + if has_phys(png) { + return png.to_vec(); + } + let mut body = Vec::with_capacity(13); + body.extend_from_slice(b"pHYs"); + body.extend_from_slice(&ppm.to_be_bytes()); + body.extend_from_slice(&ppm.to_be_bytes()); + body.push(1); + let crc = png_crc(&body); + let mut out = Vec::with_capacity(png.len() + 21); + out.extend_from_slice(png.get(..end).unwrap_or(png)); + out.extend_from_slice(&9u32.to_be_bytes()); + out.extend_from_slice(&body); + out.extend_from_slice(&crc.to_be_bytes()); + if let Some(rest) = png.get(end..) { + out.extend_from_slice(rest); + } + out +} + +/// Dots per inch as pixels per metre, the unit a PNG `pHYs` chunk stores. +fn pixels_per_metre(dpi: f64) -> Option { + if !dpi.is_finite() || !(1.0..=10_000.0).contains(&dpi) { + return None; + } + let ppm = (dpi / 0.0254).round(); + if !ppm.is_finite() || !(1.0..=u32::MAX as f64).contains(&ppm) { + return None; + } + Some(ppm as u32) +} + +fn ihdr_end(png: &[u8]) -> Option { + if !png.starts_with(b"\x89PNG\r\n\x1a\n") { + return None; + } + let len = be_u32(png, 8)? as usize; + if png.get(12..16) != Some(b"IHDR".as_slice()) { + return None; + } + let end = 8usize.checked_add(12)?.checked_add(len)?; + (end <= png.len()).then_some(end) +} + +fn has_phys(png: &[u8]) -> bool { + let mut i = 8usize; + for _ in 0..64 { + let len = match be_u32(png, i) { + Some(n) => n as usize, + None => return false, + }; + let Some(kind) = png.get(i + 4..i + 8) else { return false }; + if kind == b"pHYs" { + return true; + } + if kind == b"IDAT" || kind == b"IEND" { + return false; + } + i = match i.checked_add(12).and_then(|n| n.checked_add(len)) { + Some(n) if n <= png.len() => n, + _ => return false, + }; + } + false +} + +fn be_u32(bytes: &[u8], i: usize) -> Option { + let s = bytes.get(i..i + 4)?; + let s: [u8; 4] = s.try_into().ok()?; + Some(u32::from_be_bytes(s)) +} + +fn png_crc(data: &[u8]) -> u32 { + let mut c = 0xFFFF_FFFFu32; + for &b in data { + c ^= u32::from(b); + for _ in 0..8 { + let mask = 0u32.wrapping_sub(c & 1); + c = (c >> 1) ^ (0xEDB8_8320 & mask); + } + } + !c +} + /// A premultiplied pixmap of the picture, downsampled so its longer side is near `want` px. pub fn decode_for(bytes: &Arc>, want: f64, adj: &PictureAdjust) -> Option> { let addr = Arc::as_ptr(bytes) as usize; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8c1ffe82..7f1b099d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -106,12 +106,30 @@ impl Image { } buf } + + /// PNG of this image whose `pHYs` chunk says `dpi` dots per inch. + /// + /// Slide space is points, 72 per inch, so a render at `scale` pixels per point is + /// [`dpi_for_scale`]`(scale)`. Without the chunk, Preview treats the pixels as 72 dpi and a + /// Retina (2×) export opens at twice the slide's size. + pub fn to_png_at(&self, dpi: f64) -> Vec { + images::stamp_png_dpi(&self.to_png(), dpi) + } + pub fn to_jpeg(&self, quality: u8) -> Vec { + self.to_jpeg_at(quality, None) + } + + /// JPEG of this image. `dpi` is stored as JFIF density in dots per inch when it is in range. + pub fn to_jpeg_at(&self, quality: u8, dpi: Option) -> Vec { let straight = self.to_straight(); let rgb: Vec = straight.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); let Some(img) = image::RgbImage::from_raw(self.width, self.height, rgb) else { return Vec::new() }; let mut buf = Vec::new(); - let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); + if let Some(d) = dpi.and_then(dpi_u16) { + enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(d)); + } if let Err(e) = img.write_with_encoder(enc) { log::error!("JPEG encode: {e}"); return Vec::new(); @@ -128,6 +146,29 @@ impl Image { } } +/// Dots per inch for a slide drawn at `pixels_per_point`. +/// +/// A point is 1/72 inch. The default picture export uses 2 pixels per point, which is 144 dpi: +/// the same pixels, at the slide's real size, on a Retina display. +pub fn dpi_for_scale(pixels_per_point: f64) -> f64 { + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return 72.0; + } + (pixels_per_point * 72.0).clamp(1.0, 10_000.0) +} + +fn dpi_u16(dpi: f64) -> Option { + if !dpi.is_finite() || !(1.0..=f64::from(u16::MAX)).contains(&dpi) { + return None; + } + Some(dpi.round() as u16) +} + +/// Resolution a PNG (`pHYs`) or JPEG (JFIF) records, in whole dots per inch. +pub fn image_dpi(bytes: &[u8]) -> Option<(f64, f64)> { + images::dpi(bytes) +} + /// Animation state of one shape during a slide show or preview. #[derive(Clone, Debug)] pub struct ShapeState { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index de42531a..39d65bd7 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -193,9 +193,28 @@ fn png_encodes() { let img = render_slide(&p, 0, &RenderOpts { scale: 0.1, ..Default::default() }); let png = img.to_png(); assert!(png.starts_with(&[0x89, b'P', b'N', b'G'])); + assert_eq!(image_dpi(&png), None, "an untagged screenshot stays without a resolution"); assert!(!img.to_jpeg(80).is_empty()); } +#[test] +fn a_retina_export_records_144_dpi_and_still_decodes() { + assert_eq!(dpi_for_scale(2.0), 144.0); + assert_eq!(dpi_for_scale(1.0), 72.0); + assert_eq!(dpi_for_scale(f64::NAN), 72.0); + let img = Image { width: 4, height: 2, pixels: [255, 0, 0, 255].repeat(8) }; + let png = img.to_png_at(144.0); + assert_eq!(image_dpi(&png), Some((144.0, 144.0))); + let decoded = image::load_from_memory(&png).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (4, 2)); + let again = images::stamp_png_dpi(&png, 72.0); + assert_eq!(again.len(), png.len(), "a second pHYs chunk is not written"); + assert_eq!(image_dpi(&again), Some((144.0, 144.0))); + let jpeg = img.to_jpeg_at(90, Some(144.0)); + assert_eq!(image_dpi(&jpeg), Some((144.0, 144.0))); + assert_eq!(image_dpi(&img.to_jpeg(90)), None); +} + #[test] fn crop_dest_math() { let r = crop_dest(Rect::new(0.0, 0.0, 100.0, 100.0), [0.5, 0.0, 0.0, 0.0]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3f90a816..12e8f2ee 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -461,8 +461,10 @@ impl SlideApp { let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false }; let bytes = match format { "png" | "jpeg" => { - let img = deckcraft_render::render_slide(&doc, slide, &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() }); - Ok(if format == "png" { img.to_png() } else { img.to_jpeg(92) }) + let scale = 2.0; + let img = deckcraft_render::render_slide(&doc, slide, &deckcraft_render::RenderOpts { scale, ..Default::default() }); + let dpi = deckcraft_render::dpi_for_scale(scale); + Ok(if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) }) } "pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra), other => deckcraft_engine::cmd::file::save_bytes(&doc, other), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 47f37e7e..59fb7a44 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -10,6 +10,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (3-D model parts and animation bounce end round-trip; `corpus_media.rs` covers SmartArt and OLE XML, media bookmarks and trims, sections, fields, ink and timing) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines round-trip; previously trivial: groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for tables, charts, animations, media, sections and masters; handout-master text kept; previous: trivial, groups with a small legacy child coordinate space) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a PNG or JPEG slide export records its resolution, so the default 2× picture is 144 dpi; previous: groups with a small legacy child coordinate space draw outlines at their own width, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -40,6 +41,8 @@ PowerPoint opens and saves; Viewer = opens only). | PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests | | Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` outline; `.md` as Markdown (headings are slides, list items bullets) (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | | PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer | render tests | +| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt`/`.md` outline (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | +| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size | render and engine tests | | TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none | | Animated GIF of the deck | export | n/a | no | | none | | MP4 / MOV video of the deck | export | n/a | no | Needs timeline render + H.264 encode | none | @@ -138,6 +141,7 @@ the round trip even though DeckCraft doesn't model it. | 2026-10-13 | minor | Add-in tags on the presentation, slides and shapes read and written | | 2026-10-13 | minor | Custom XML parts, the sensitivity label and custom properties kept on save | | 2026-10-13 | minor | Owned corpus round-trip for tables, charts, animations, media, sections and masters. Handout-master header, footer, date and slide-number text is written back | +| 2026-10-14 | minor | PNG and JPEG slide export record their resolution (`scale` × 72 dpi; the default 2× picture is 144 dpi) | | 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read | | 2026-10-12 | minor | Text warps rendered; their adjust values read and written | | 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 5ba34cfc..67bc8639 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME input, #101; previous: major, created from the renderer, media and windowing code) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (PNG and JPEG slide export record 144 dpi at the default 2 pixels per point; previous: IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too @@ -14,6 +14,7 @@ because it mostly follows from hardware use. | Slide rasterization | GPU | **CPU** (`vello_cpu`) | CPU | CPU | CPU (wasm) | | Transitions and Morph frames | GPU | CPU-rendered slide images composited as textured quads on the GPU | same | same | same | | High-DPI / Retina | yes | yes | yes | yes | yes | +| Picture export resolution | 2 px per point, tagged so the file is the slide's size | PNG `pHYs` and JPEG density, 144 dpi at the default 2 px per point | same | same | same | | Wide gamut / HDR display | partly (P3 images) | no (sRGB) | no | no | no | | Startup on all GPUs | yes | yes | **no**: crashes in some Intel/AMD drivers (#40, #59) | not reported | n/a | | Software fallback when no GPU | yes (WARP on Windows) | no | no | no | no | @@ -71,5 +72,6 @@ outlines every frame (#67, fix open), and the decoders run in software. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | PNG and JPEG slide export record 144 dpi at the default 2 pixels per point, so a Retina picture opens at the slide's size | | 2026-10-12 | minor | IME input methods (#101) | | 2026-10-10 | major | Created: rendering, media, displays, input and performance against PowerPoint 16.113 |