mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Export: record slide resolution in PNG and JPEG pictures
A default export is 2 pixels per point. Without a resolution, Preview treats those pixels as 72 dpi and the picture opens at twice the slide's size. PNG pHYs and JPEG JFIF density now say scale times 72, so the default is 144 dpi. Screenshots stay untagged. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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 |
|
||||
|
||||
@@ -232,7 +232,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(())
|
||||
|
||||
@@ -254,9 +254,18 @@ fn save_bytes_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Render slide `index` (current when absent) to PNG bytes.
|
||||
pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec<u8>, u32, u32) {
|
||||
let img =
|
||||
deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), edit, ..Default::default() });
|
||||
(img.to_png(), img.width, img.height)
|
||||
let scale = finite_scale(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<u8> {
|
||||
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<Value> {
|
||||
@@ -273,7 +282,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<usize> = match usize_param(p, "slide") {
|
||||
@@ -286,12 +295,8 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if *i >= st.doc.slides.len() {
|
||||
return Err(bad("file.export", format!("no slide {i}")));
|
||||
}
|
||||
let img = deckcraft_render::render_slide(
|
||||
&st.doc,
|
||||
*i,
|
||||
&deckcraft_render::RenderOpts { scale: scale.clamp(0.01, 16.0), ..Default::default() },
|
||||
);
|
||||
let bytes = if format == "png" { img.to_png() } else { img.to_jpeg(92) };
|
||||
let img = deckcraft_render::render_slide(&st.doc, *i, &deckcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let bytes = picture_bytes(&img, &format, scale);
|
||||
let out = if slides.len() == 1 {
|
||||
path.clone()
|
||||
} else {
|
||||
|
||||
@@ -835,3 +835,31 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
|
||||
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
|
||||
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
|
||||
}
|
||||
|
||||
/// 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)))
|
||||
}
|
||||
|
||||
@@ -106,7 +106,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}));
|
||||
|
||||
@@ -173,6 +173,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<u8> {
|
||||
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<u32> {
|
||||
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<usize> {
|
||||
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<u32> {
|
||||
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<Vec<u8>>, want: f64, adj: &PictureAdjust) -> Option<Arc<Pixmap>> {
|
||||
let addr = Arc::as_ptr(bytes) as usize;
|
||||
|
||||
@@ -89,12 +89,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<u8> {
|
||||
images::stamp_png_dpi(&self.to_png(), dpi)
|
||||
}
|
||||
|
||||
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
|
||||
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<f64>) -> Vec<u8> {
|
||||
let straight = self.to_straight();
|
||||
let rgb: Vec<u8> = 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();
|
||||
@@ -111,6 +129,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<u16> {
|
||||
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 {
|
||||
|
||||
@@ -171,9 +171,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]);
|
||||
|
||||
@@ -388,8 +388,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),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; 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-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**
|
||||
@@ -26,7 +26,7 @@ PowerPoint opens and saves; Viewer = opens only).
|
||||
| `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests |
|
||||
| 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`/`.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 | render 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 |
|
||||
@@ -99,6 +99,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user