Merge remote-tracking branch 'origin/main' into copy-the-fix

This commit is contained in:
FredApps committed 2026-11-05 05:41:44 +01:00
commit 4f171e8f78
7 files changed
+69 -11

No files matched your search

+7
View File
@@ -191,6 +191,13 @@ pub(super) fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn flip(s: &mut Session, p: &Value) -> Result<Value> {
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
edit_geometry(s, p, "shape.flip", |sh| {
// PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design > Right to Left mirrors it),
// also inside a group: ungrouping would hand the flip to the graphic.
let mut graphic = false;
deckcraft_model::walk(std::slice::from_ref(&*sh), &mut |x, _| graphic |= x.diagram.is_some());
if graphic {
return Err(bad("shape.flip", "Flip is not available for a SmartArt graphic; use `smartArt.layout` to change how it is laid out"));
}
if let Some(x) = sh.xfrm.as_mut() {
if v {
x.flip_v = !x.flip_v;
+24
View File
@@ -6872,6 +6872,30 @@ fn save_to_image_path_is_an_error_not_zip_bytes() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn flip_is_refused_on_a_smartart_graphic_and_leaves_it_a_graphic() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("insert.textBox", &json!({"text": "Goals\nRevenue"})).unwrap();
let gid = s.execute("smartArt.fromText", &json!({"kind": "blockList"})).unwrap()["id"].as_u64().unwrap();
for cmd in ["arrange.flipHorizontal", "arrange.flipVertical", "shape.flip"] {
let e = s.execute(cmd, &json!({"ids": [gid]})).unwrap_err().to_string();
assert!(e.contains("SmartArt") && e.contains("smartArt.layout"), "{cmd}: {e}");
}
// Grouped with another shape it cannot be flipped either: ungrouping would hand it the flip.
let other = s.execute("insert.textBox", &json!({"text": "x"})).unwrap()["id"].as_u64().unwrap();
let group = s.execute("arrange.group", &json!({"ids": [gid, other]})).unwrap()["id"].as_u64().unwrap();
let e = s.execute("shape.flip", &json!({"ids": [group]})).unwrap_err().to_string();
assert!(e.contains("SmartArt"), "{e}");
s.execute("arrange.ungroup", &json!({"ids": [group]})).unwrap();
// Still a SmartArt graphic: smartArt commands keep working.
s.execute("smartArt.layout", &json!({"id": gid, "kind": "cycle"})).unwrap();
// Ordinary shapes still flip.
let b = s.execute("insert.textBox", &json!({"text": "x"})).unwrap();
let id = b["id"].as_u64().unwrap();
assert_eq!(s.execute("arrange.flipHorizontal", &json!({"ids": [id]})).unwrap(), json!(1));
}
/// Table cell commands refuse a `cells` that is not a list of [row, col] cells in the table,
/// instead of dropping the entry or reading the list as absent (which acts on the selection).
#[test]
+2 -1
View File
@@ -207,7 +207,8 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_
let mut budget = 4u32;
let mut draw_slide = |ui: &mut Ui, i: usize, r: Rect| {
let px = (r.width() * ui.ctx().pixels_per_point()).round().clamp(16.0, 1600.0) as u32;
match app.textures.thumb(ui.ctx(), doc, i, px, &mut budget) {
// Print preview matches the actual exported PDF output (always full color).
match app.textures.thumb(ui.ctx(), doc, i, px, false, &mut budget) {
Some(tex) => {
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
+1 -1
View File
@@ -299,7 +299,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
vec2(w as f32 * scale, h as f32 * scale),
);
let mut budget = 2;
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, &mut budget) {
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, app.ui.grayscale, &mut budget) {
painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
+1 -1
View File
@@ -219,7 +219,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
// Only rows in view (plus a little margin) ask for a thumbnail: with every row asking, a
// deck bigger than the thumbnail cache evicts its own thumbnails and never settles (#969).
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * row.height())).intersects(row);
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) } else { None };
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, app.ui.grayscale, &mut budget) } else { None };
match tex {
Some(tex) => {
p.image(
+3 -2
View File
@@ -209,7 +209,8 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
// Only cells in view (plus a little margin) ask for a thumbnail, so a deck bigger
// than the thumbnail cache does not evict its own thumbnails forever (#969).
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * cell.height())).intersects(cell);
let tex = if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) } else { None };
let tex =
if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, app.ui.grayscale, budget) } else { None };
match tex {
Some(tex) => {
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
@@ -391,7 +392,7 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
}
let ppp = ui.ctx().pixels_per_point();
let mut budget = 2;
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, &mut budget) {
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, app.ui.grayscale, &mut budget) {
ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside);
+31 -6
View File
@@ -361,9 +361,19 @@ impl Textures {
self.thumbs.clear();
}
/// A thumbnail texture; renders at most `budget` new thumbnails per frame (returns None when
/// it is over budget, so the caller draws a placeholder and asks for another frame).
pub fn thumb(&mut self, ctx: &egui::Context, p: &Presentation, index: usize, width_px: u32, budget: &mut u32) -> Option<TextureHandle> {
/// A thumbnail texture; renders at most `budget` new thumbnails per frame. Over budget it
/// returns the slide's previous thumbnail if there is one (and asks for another frame), else
/// None, so the caller draws a placeholder.
/// When `grayscale` is true, the thumbnail renders in black-and-white (matching Grayscale view).
pub fn thumb(
&mut self,
ctx: &egui::Context,
p: &Presentation,
index: usize,
width_px: u32,
grayscale: bool,
budget: &mut u32,
) -> Option<TextureHandle> {
let slide = p.slides.get(index)?.clone();
let master = master_of(p, &slide);
let h = (width_px as f64 * p.slide_size.height / p.slide_size.width.max(1.0)).round().max(1.0) as u32;
@@ -372,7 +382,7 @@ impl Textures {
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
size: (width_px, h),
edit: false,
extra: 0,
extra: grayscale as u64,
want: Region::slide(width_px, h),
editing_text: None,
};
@@ -385,7 +395,8 @@ impl Textures {
return Some(e.tex.clone());
}
if *budget == 0 {
// Keep showing the stale thumbnail while the new one waits.
// Keep showing the stale thumbnail while the new one waits; request a repaint to finish rendering.
ctx.request_repaint();
return self.thumbs.get(&key.slide).map(|e| e.tex.clone());
}
*budget -= 1;
@@ -393,7 +404,7 @@ impl Textures {
let scale = tw as f64 / p.slide_size.width.max(1.0);
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() });
self.thumb_renders += 1;
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, false));
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, grayscale));
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
let region = key.want;
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
@@ -433,6 +444,20 @@ mod tests {
assert_eq!(device_pixel_rect(aligned, ppp), aligned);
}
#[test]
fn to_color_image_turns_colors_to_grayscale_when_flag_is_set() {
let img = deckcraft_render::Image { width: 2, height: 1, pixels: vec![0, 0, 255, 255, 255, 0, 0, 255] };
let color = super::to_color_image(&img, false);
assert_eq!(color.pixels[0], egui::Color32::from_rgba_premultiplied(0, 0, 255, 255));
assert_eq!(color.pixels[1], egui::Color32::from_rgba_premultiplied(255, 0, 0, 255));
let gray = super::to_color_image(&img, true);
assert_eq!(gray.pixels[0].r(), gray.pixels[0].g());
assert_eq!(gray.pixels[0].g(), gray.pixels[0].b());
assert_eq!(gray.pixels[1].r(), gray.pixels[1].g());
assert_eq!(gray.pixels[1].g(), gray.pixels[1].b());
}
#[test]
fn a_bad_scale_does_not_move_the_rect() {
let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0));