From a6000f18fdcca4e6a54dfa0b7bb62269ed3d28a6 Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:51:31 +0200 Subject: [PATCH 01/26] Morph: align an outline morph once per transition, not every frame Follow-up to #27. render_blend built a GeometryMorph (contours, alignment) for every morphing shape on every frame, though only t and the current box change while a transition plays. The Renderer now keeps the aligned morphs, keyed by both geometries and their own sizes, and drops those a frame did not use, so the cache ends with the transition. - render: morph_path::Cache replaces morph_path::geometry; blend sweeps it. - test: one alignment per transition, same geometry as a fresh one. generated with Claude Code --- crates/render/src/lib.rs | 7 +++- crates/render/src/morph_path.rs | 73 +++++++++++++++++++++++++++++---- crates/render/src/tests.rs | 23 +++++++++++ 3 files changed, 93 insertions(+), 10 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6dde577a..61a35e93 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -221,11 +221,13 @@ pub struct Renderer { resources: Resources, /// Outlines morphing in the frame being drawn by `blend`. paths: Vec, + /// Their aligned outlines, kept from frame to frame. + morphs: morph_path::Cache, } impl Default for Renderer { fn default() -> Self { - Renderer { resources: Resources::new(), paths: Vec::new() } + Renderer { resources: Resources::new(), paths: Vec::new(), morphs: morph_path::Cache::default() } } } @@ -539,6 +541,7 @@ impl Renderer { } } self.paths.clear(); + self.morphs.sweep(); self.finish(ctx, w, h) } @@ -654,7 +657,7 @@ impl Renderer { fn geometry_shape(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, m: Affine, w: f64, h: f64, st: &ShapeState) { let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) { - Some(pm) => morph_path::geometry(pm, s, w, h), + Some(pm) => self.morphs.geometry(pm, s, w, h), None => (shape_geometry(s, w, h), Vec::new()), }; let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 }); diff --git a/crates/render/src/morph_path.rs b/crates/render/src/morph_path.rs index 6579eb18..33af94ed 100644 --- a/crates/render/src/morph_path.rs +++ b/crates/render/src/morph_path.rs @@ -29,12 +29,69 @@ fn ratio(now: f64, own: f64) -> f64 { if own.abs() > 1e-9 && r.is_finite() { r } else { 1.0 } } -/// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at their -/// own sizes (so the alignment does not change while the box grows), then scaled into the box. -/// With a [`Fade`] per sub-path. -pub(crate) fn geometry(pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec) { - let a = shape_geometry(&Shape { geom: pm.from.clone(), ..Shape::default() }, pm.from_box.w, pm.from_box.h); - let b = shape_geometry(s, pm.to_box.w, pm.to_box.h); - let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h)); - GeometryMorph::new(&a, &b).at(pm.t, scale(pm.from_box), scale(pm.to_box)) +/// What a [`GeometryMorph`] is built from: both outlines at their own sizes. None of it changes +/// while a transition plays, only `t` and the current box do. +#[derive(Debug)] +struct Source { + from: Geom, + from_wh: (f64, f64), + to: Geom, + to_wh: (f64, f64), +} + +impl Source { + fn of(pm: &PathMorph, s: &Shape) -> Self { + Source { from: pm.from.clone(), from_wh: (pm.from_box.w, pm.from_box.h), to: s.geom.clone(), to_wh: (pm.to_box.w, pm.to_box.h) } + } + + fn is(&self, pm: &PathMorph, s: &Shape) -> bool { + self.from == pm.from && self.from_wh == (pm.from_box.w, pm.from_box.h) && self.to == s.geom && self.to_wh == (pm.to_box.w, pm.to_box.h) + } + + fn build(&self) -> GeometryMorph { + let geo = |geom: &Geom, (w, h): (f64, f64)| shape_geometry(&Shape { geom: geom.clone(), ..Shape::default() }, w, h); + GeometryMorph::new(&geo(&self.from, self.from_wh), &geo(&self.to, self.to_wh)) + } +} + +/// The aligned outlines of the morphs being played, kept across frames: aligning is the expensive +/// part, blending them at `t` is cheap. +#[derive(Debug, Default)] +pub(crate) struct Cache { + /// With whether the current frame used it. + morphs: Vec<(Source, GeometryMorph, bool)>, +} + +impl Cache { + /// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at + /// their own sizes (so the alignment does not change while the box grows), then scaled into the + /// box. With a [`Fade`] per sub-path. + pub(crate) fn geometry(&mut self, pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec) { + let k = match self.morphs.iter().position(|(src, ..)| src.is(pm, s)) { + Some(k) => k, + None => { + let src = Source::of(pm, s); + let m = src.build(); + self.morphs.push((src, m, false)); + self.morphs.len() - 1 + } + }; + let (_, m, used) = &mut self.morphs[k]; + *used = true; + let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h)); + m.at(pm.t, scale(pm.from_box), scale(pm.to_box)) + } + + /// Ends a frame: forgets the morphs it did not use (their transition is over). + pub(crate) fn sweep(&mut self) { + self.morphs.retain(|(.., used)| *used); + for (.., used) in &mut self.morphs { + *used = false; + } + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.morphs.len() + } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ccb4472f..464ad0dd 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -417,6 +417,29 @@ fn morph_path_fades_a_fill_only_one_side_has() { assert!(half[0] > full[0] + 40 && half[0] < 215, "{half:?} vs {full:?}"); } +#[test] +fn morph_path_aligns_once_per_transition() { + let (from, to) = (Xfrm::new(0.0, 0.0, 200.0, 200.0), Xfrm::new(0.0, 0.0, 300.0, 150.0)); + let star = Shape { geom: Geom::preset("star5"), ..Default::default() }; + let pm = |t: f64| PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t }; + let els = |(g, f): (deckcraft_geom::preset::Geometry, Vec<_>)| (g.paths.iter().map(|p| p.path.elements().to_vec()).collect::>(), f); + let mut cache = morph_path::Cache::default(); + for t in [0.0, 0.3, 0.7, 1.0] { + let got = els(cache.geometry(&pm(t), &star, 250.0, 175.0)); + // The same as aligning afresh. + assert_eq!(got, els(morph_path::Cache::default().geometry(&pm(t), &star, 250.0, 175.0))); + cache.sweep(); + } + assert_eq!(cache.len(), 1); + // Another target outline is another morph. + let _ = cache.geometry(&pm(0.5), &Shape { geom: Geom::preset("rect"), ..Default::default() }, 250.0, 175.0); + assert_eq!(cache.len(), 2); + // A frame without morphs ends the transition: the cache empties. + cache.sweep(); + cache.sweep(); + assert_eq!(cache.len(), 0); +} + #[test] fn text_transform_unmirrors_flips() { // Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text. From 6be9817693a5f8832a8bc3c0e4ceeac28000d3c6 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:59:34 +0200 Subject: [PATCH 02/26] Export: PNG images (all slides) in the browser downloads every slide (#64) --- crates/ui-egui/src/dialogs.rs | 55 +++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 73ce180b..0cd0bcc0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -455,19 +455,17 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } } else if let (Some(dl), Some(s)) = (app.services.download.as_mut(), app.session.active()) { - let bytes = match ext { - "png" | "jpg" => { - let img = deckcraft_render::render_slide( - &s.doc, - s.selection.slide, - &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() }, - ); - if ext == "png" { img.to_png() } else { img.to_jpeg(92) } + if matches!(ext, "png" | "jpg") { + for (file, bytes) in web_image_files(&s.doc, s.selection.slide, all, &name, ext) { + dl(&file, &bytes); } - "pdf" => deckcraft_pdf_bytes(&s.doc, &pdf_params), - other => deckcraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(), - }; - dl(&suggested, &bytes); + } else { + let bytes = match ext { + "pdf" => deckcraft_pdf_bytes(&s.doc, &pdf_params), + other => deckcraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(), + }; + dl(&suggested, &bytes); + } } } ok || cancel @@ -928,11 +926,44 @@ fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json: deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default() } +/// Images for the browser download: the current slide, or every slide (named `1.png`, `2.png`... like the native export) when `all`. +fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: bool, name: &str, ext: &str) -> Vec<(String, Vec)> { + let slides: Vec = if all { (0..doc.slides.len()).collect() } else { vec![current] }; + let single = slides.len() == 1; + slides + .into_iter() + .enumerate() + .map(|(k, i)| { + let img = deckcraft_render::render_slide(doc, i, &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() }); + let bytes = if ext == "png" { img.to_png() } else { img.to_jpeg(92) }; + let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) }; + (file, bytes) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; use std::cell::{Cell, RefCell}; + /// The browser "PNG images (all slides)" export yields one image per slide, not just the selected one (#64). + #[test] + fn web_export_all_slides_gives_one_file_per_slide() { + let mut doc = deckcraft_model::Presentation::default(); + if let Some(first) = doc.slides.first().cloned() { + doc.slides.push(first.clone()); + doc.slides.push(first); + } + assert_eq!(doc.slides.len(), 3); + let all = web_image_files(&doc, 1, true, "Deck", "png"); + let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]); + let one = web_image_files(&doc, 1, false, "Deck", "png"); + assert_eq!(one.len(), 1); + assert_eq!(one[0].0, "Deck.png"); + } + /// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17). #[test] fn inset_wrapped_indents_every_row() { From cebf667579cf4fa4aa8e78f4b27a805864515112 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:03:35 +0200 Subject: [PATCH 03/26] Paste: Ctrl+V pastes an image-only clipboard as a picture (#53) --- crates/ui-egui/src/lib.rs | 59 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4f..b72bf753 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -209,6 +209,8 @@ pub struct SlideApp { pub(crate) notes_buf: (u64, usize, String), /// Thumbnail pane drag (slide index, current drop index). pub(crate) thumb_drag: Option<(usize, usize)>, + /// A V key press or a Paste event arrived since the last V release (see `keyboard`). + v_seen: bool, } impl SlideApp { @@ -244,6 +246,7 @@ impl SlideApp { quit_confirmed: false, notes_buf: (0, usize::MAX, String::new()), thumb_drag: None, + v_seen: false, } } @@ -607,10 +610,21 @@ impl SlideApp { /// Keyboard and clipboard for the slide (when no text field has focus). fn keyboard(&mut self, ctx: &egui::Context) { + let events = ctx.input(|i| i.events.clone()); + // Ctrl+V with only an image on the clipboard: egui-winit sends no Paste event and swallows the + // key press, so the release of a V press never seen is the paste. Tracked even while a text + // field has focus, so that a V typed there is not taken for one. + let mut image_paste = false; + for e in &events { + match e { + egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true, + egui::Event::Key { key: egui::Key::V, pressed: false, .. } => image_paste = !std::mem::take(&mut self.v_seen), + _ => {} + } + } if ctx.memory(|m| m.focused().is_some()) || self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some() { return; } - let events = ctx.input(|i| i.events.clone()); if self.home_open() { // Only app shortcuts (Open, Command Palette…) and Escape, which goes back to the // presentation: typing, pasting or deleting would edit the hidden presentation. @@ -627,6 +641,13 @@ impl SlideApp { self.drain_requests(); return; } + let img = if image_paste { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + if let Some(png) = img { + let data = deckcraft_engine::cmd::base64_encode(&png); + if let Err(e) = self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})) { + self.set_status(e.to_string()); + } + } for e in events { match e { egui::Event::Text(t) => { @@ -932,6 +953,42 @@ mod tests { assert_eq!(harness.state().session.documents().len(), 2); } + /// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and + /// swallows the key press, so only the V release reaches the app. + #[test] + fn ctrl_v_pastes_an_image_when_no_paste_event_arrives() { + let session = Session::with_new(); + let png = { + let d = session.active().unwrap(); + deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0 + }; + assert!(!png.is_empty()); + let services = Services { clipboard_image: Some(Box::new(move || Some(png.clone()))), ..Default::default() }; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, services), + ); + harness.run_steps(2); + let pictures = |app: &SlideApp| { + let d = app.session.active().unwrap(); + d.doc.slides[0].shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count() + }; + let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND }; + // A text paste (Paste event on the press) does not also paste the image on release. + harness.event(egui::Event::Paste("hello".into())); + harness.run_steps(2); + harness.event(v(false)); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 0); + // What egui-winit sends for Ctrl+V with only an image on the clipboard: the release alone. + harness.event(v(false)); + harness.run_steps(2); + assert_eq!(pictures(harness.state()), 1); + } + #[test] fn home_needs_an_open_presentation() { let mut app = SlideApp::new(Session::new(), Services::default()); From 04069e19682c3c20d19ad7492dc53fb4b8f6879d Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:03:01 +0200 Subject: [PATCH 04/26] Morph: text boxes without a partner fade in place instead of moving (#119) The last matching pass paired any two shapes of one kind and preset geometry, so a text box leaving the slide was paired with an unrelated one arriving and flew across to it. That pass now only pairs shapes without text: a text box needs its name or its text on the other slide to morph, otherwise it fades out (or in) where it is, as in PowerPoint. generated with Claude Code --- crates/anim/src/morph.rs | 7 +++++-- crates/anim/src/tests.rs | 20 ++++++++++++++++++++ docs/animation-parity.md | 3 ++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs index b5808c4f..1d4ab8f3 100644 --- a/crates/anim/src/morph.rs +++ b/crates/anim/src/morph.rs @@ -12,7 +12,9 @@ fn text_of(s: &Shape) -> String { /// Pairs of (shape on `a`, shape on `b`) that Morph animates from one to the other: first by /// `!!name` (forced match), then by exact name, then by the same kind and text, then by the same -/// kind and preset geometry. Each shape is used once; top-level shapes only (groups move whole). +/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on +/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used +/// once; top-level shapes only (groups move whole). pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> { let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new(); let mut used_a = vec![false; a.shapes.len()]; @@ -31,7 +33,8 @@ pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> { x.kind_name() == y.kind_name() && x.geom.preset_name().is_some() && x.geom.preset_name() == y.geom.preset_name() - && text_of(x).trim().is_empty() == text_of(y).trim().is_empty() + && text_of(x).trim().is_empty() + && text_of(y).trim().is_empty() }, ]; for pass in passes { diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 1fd82d78..de9c40ab 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -529,6 +529,26 @@ fn morph_pairs_by_name_text_and_geometry() { assert!(morph_pairs(&Slide::default(), &b).is_empty()); } +#[test] +fn morph_pairs_leaves_unrelated_text_boxes_alone() { + // A text box that leaves and an unrelated one that arrives fade in place (#119). + let p = deckcraft_model::defaults::new_presentation(None); + let mut a = Slide::default(); + let mut b = Slide::default(); + let tb = |id, name, x, text| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), text: Some(TextBody::from_text(text)), ..shape(id, name) }; + a.shapes = vec![tb(1, "TextBox 1", 0.0, "Gone")]; + b.shapes = vec![tb(2, "TextBox 2", 400.0, "New")]; + assert!(morph_pairs(&a, &b).is_empty()); + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + let xs: Vec<(&str, f64)> = f.shapes.iter().map(|(s, _)| (s.name.as_str(), s.xfrm.map_or(f64::NAN, |x| x.x))).collect(); + assert_eq!(xs, [("TextBox 1", 0.0), ("TextBox 2", 400.0)]); + // Empty shapes of one kind and geometry still pair, and a named text box keeps its partner. + a.shapes.push(shape(3, "Rect A")); + b.shapes.push(shape(4, "Rect B")); + b.shapes[0].name = "TextBox 1".into(); + assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2)), (ShapeId(3), ShapeId(4))]); +} + #[test] fn morph_xfrm_interpolates() { let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) }; diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 1d50766b..74d246f1 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -24,7 +24,7 @@ three groups, with its Effect Options. All 48 round-trip through PPTX | Duration, advance on click / after time, Apply To All | yes | yes | | Transition sounds | yes | **no** (the model has the field; no picker, no playback) | | Preview in the editor | yes | yes | -| Morph: objects move, resize, recolour | yes | yes | +| Morph: objects move, resize, recolour; unmatched text boxes fade in place | yes | yes (#119) | | Morph: Words and Characters | yes | yes (#5) | | Morph: geometry (circle to star) | yes | yes (#27) | | Morph: `!!` name forcing, pictures, 3-D models, ink | yes | names partly; no 3-D or ink | @@ -76,4 +76,5 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Morph leaves unrelated text boxes unpaired: they fade in place (#119) | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates | From 7db770d91dfc254e93f9f4528197a806ef34ef33 Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:16:27 +0200 Subject: [PATCH 05/26] Morph: a picture cropped differently on the next slide morphs its crop (#118) Two paired pictures that differed only in crop counted as changed content, so the new one faded in over the old one on the moving box. Now they stay one picture whose crop morphs: the whole image moves and scales linearly between where it lies on either slide, and each frame's crop is what the interpolated box shows of it (morph_crop). generated with Claude Code --- crates/anim/src/lib.rs | 2 +- crates/anim/src/morph.rs | 63 ++++++++++++++++++++++++++++++++++++++-- crates/anim/src/tests.rs | 35 ++++++++++++++++++++++ docs/animation-parity.md | 3 +- 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs index fae7e4dd..bea76eb8 100644 --- a/crates/anim/src/lib.rs +++ b/crates/anim/src/lib.rs @@ -27,7 +27,7 @@ pub use deckcraft_model::Rgba; use serde::{Deserialize, Serialize}; pub use easing::ease; -pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm}; +pub use morph::{MorphFrame, MorphPath, MorphText, morph_crop, morph_frame, morph_pairs, morph_xfrm}; pub use path::{MotionPath, default_path}; pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order}; pub use timeline::{EffectTiming, Sequence, Timeline, TriggerPlay}; diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs index b5808c4f..96bd761b 100644 --- a/crates/anim/src/morph.rs +++ b/crates/anim/src/morph.rs @@ -173,9 +173,58 @@ fn reshaped(a: &Shape, b: &Shape) -> bool { a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) } } +/// The content of a shape apart from what Morph interpolates, with a picture's crop left out. +fn uncropped(s: &Shape) -> Shape { + let mut c = content(s); + if let ShapeKind::Picture { fill } = &mut c.kind { + fill.crop = [0.0; 4]; + } + c +} + +fn crop_of(s: &Shape) -> [f64; 4] { + match &s.kind { + ShapeKind::Picture { fill } => fill.crop, + _ => [0.0; 4], + } +} + +/// Whether two pictures differ in their crop only (apart from box and fill), so the crop can morph. +fn recropped(a: &Shape, b: &Shape) -> bool { + matches!(a.kind, ShapeKind::Picture { .. }) && crop_of(a) != crop_of(b) && uncropped(a) == uncropped(b) +} + +/// The crop (left, top, right, bottom) of a picture morphing from box `xa` cropped by `ca` to box +/// `xb` cropped by `cb`, when its box is `x` at progress `t`: the whole image moves and scales +/// linearly between where it lies on either slide, and the crop is what of it `x` shows. Falls back +/// to blending the crops when a crop leaves nothing visible. +pub fn morph_crop(ca: [f64; 4], xa: Xfrm, cb: [f64; 4], xb: Xfrm, x: Xfrm, t: f64) -> [f64; 4] { + // Along one axis: (box start, box size, crop before, crop after) → the crops at `t`. + let axis = |(pa, sa, la, ha): (f64, f64, f64, f64), (pb, sb, lb, hb): (f64, f64, f64, f64), (p, s): (f64, f64)| { + let (va, vb) = (1.0 - la - ha, 1.0 - lb - hb); + if va <= 1e-6 || vb <= 1e-6 { + return (lerp(la, lb, t), lerp(ha, hb, t)); + } + // The whole image: its extent and start on either slide. + let (ea, eb) = (sa / va, sb / vb); + let e = lerp(ea, eb, t); + if !e.is_finite() || e.abs() <= 1e-9 { + return (lerp(la, lb, t), lerp(ha, hb, t)); + } + let o = lerp(pa - la * ea, pb - lb * eb, t); + let lo = (p - o) / e; + (lo, 1.0 - lo - s / e) + }; + let (l, r) = axis((xa.x, xa.w, ca[0], ca[2]), (xb.x, xb.w, cb[0], cb[2]), (x.x, x.w)); + let (tp, bt) = axis((xa.y, xa.h, ca[1], ca[3]), (xb.y, xb.h, cb[1], cb[3]), (x.y, x.h)); + let c = [l, tp, r, bt]; + if c.iter().all(|v| v.is_finite()) { c } else { cb } +} + /// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes /// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the -/// colour blends, when only in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]), +/// colour blends, when pictures differ only in crop the crop morphs ([`morph_crop`]), when only +/// in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]), /// otherwise the new shape fades in under the fading old one on the same moving /// box; with the transition option `words` or `characters` their texts are listed in /// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for @@ -223,8 +272,18 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b: (_, xb) => xb, }; let reshape = reshaped(sa, sb); - if reshape || content(sa) == content(sb) { + let recrop = recropped(sa, sb); + if reshape || recrop || content(sa) == content(sb) { let mut m = Shape { xfrm: x, ..sb.clone() }; + if recrop && let ShapeKind::Picture { fill } = &mut m.kind { + fill.crop = match (xa, xb, x) { + (Some(xa), Some(xb), Some(x)) => morph_crop(crop_of(sa), xa, crop_of(sb), xb, x, s), + _ => { + let (ca, cb) = (crop_of(sa), crop_of(sb)); + [lerp(ca[0], cb[0], s), lerp(ca[1], cb[1], s), lerp(ca[2], cb[2], s), lerp(ca[3], cb[3], s)] + } + }; + } if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb)) && fa != fb { diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 1fd82d78..4d4a7745 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -529,6 +529,41 @@ fn morph_pairs_by_name_text_and_geometry() { assert!(morph_pairs(&Slide::default(), &b).is_empty()); } +#[test] +fn morph_crop_moves_the_image_linearly() { + let bx = |x, w| Xfrm::new(x, 0.0, w, 100.0); + // Same box, the crop pans from the left half to the right half of a 200-wide image. + let c = morph_crop([0.0, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.5, 0.0, 0.0, 0.0], bx(0.0, 100.0), bx(0.0, 100.0), 0.5); + assert!(c.iter().zip([0.25, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}"); + // The box shrinks over an image that stays put: only the right crop grows. + let c = morph_crop([0.0; 4], bx(0.0, 100.0), [0.0, 0.0, 0.5, 0.0], bx(0.0, 50.0), bx(0.0, 75.0), 0.5); + assert!(c.iter().zip([0.0, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}"); + // Nothing visible on one side: the crops blend. + let c = morph_crop([0.5, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.0; 4], bx(0.0, 100.0), bx(0.0, 100.0), 0.5); + assert!(c.iter().all(|v| v.is_finite()) && (c[0] - 0.25).abs() < 1e-9, "{c:?}"); +} + +#[test] +fn morph_frame_morphs_a_picture_crop() { + // A picture recropped on the next slide stays one picture whose crop morphs (#118). + let p = deckcraft_model::defaults::new_presentation(None); + let pic = |id, crop| Shape { + kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, ..Default::default() } }, + ..shape(id, "Picture 1") + }; + let mut a = Slide::default(); + let mut b = Slide::default(); + a.shapes = vec![pic(1, [0.0, 0.0, 0.5, 0.0])]; + b.shapes = vec![pic(2, [0.5, 0.0, 0.0, 0.0])]; + let f = morph_frame(&p, &a, &b, 0.5, &|_| false); + assert_eq!(f.shapes.len(), 1); + assert!(f.opacity.is_empty()); + let deckcraft_model::ShapeKind::Picture { fill } = &f.shapes[0].0.kind else { panic!("not a picture") }; + let want = 0.5 * f.mix; + assert!((fill.crop[0] - want).abs() < 1e-9 && (fill.crop[2] - (0.5 - want)).abs() < 1e-9, "{:?}", fill.crop); + assert_eq!(morph_frame(&p, &a, &b, 1.0, &|_| false).shapes[0].0.kind, b.shapes[0].kind); +} + #[test] fn morph_xfrm_interpolates() { let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) }; diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 1d50766b..7f3d9878 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -24,7 +24,7 @@ three groups, with its Effect Options. All 48 round-trip through PPTX | Duration, advance on click / after time, Apply To All | yes | yes | | Transition sounds | yes | **no** (the model has the field; no picker, no playback) | | Preview in the editor | yes | yes | -| Morph: objects move, resize, recolour | yes | yes | +| Morph: objects move, resize, recolour; a picture's crop morphs | yes | yes (#118) | | Morph: Words and Characters | yes | yes (#5) | | Morph: geometry (circle to star) | yes | yes (#27) | | Morph: `!!` name forcing, pictures, 3-D models, ink | yes | names partly; no 3-D or ink | @@ -76,4 +76,5 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Morph animates a changed picture crop (#118) | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates | From 3bb284cfdd15b357bf252449e68f54cd67cc983f Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:26:42 +0200 Subject: [PATCH 06/26] Text: typing goes on after Tab in a text box (#78) The engine handled Tab (a tab character, or a list level at the start of a paragraph), but egui also treated it as focus navigation: with no widget focused it focused the first ribbon widget, and since the slide only takes keys while nothing is focused, every key after Tab was lost. When the slide has the keyboard, the keyboard handler now cancels that focus move. This also covers the "can't type after indenting" part of #93. generated with Claude Code --- crates/ui-egui/src/lib.rs | 44 +++++++++++++++++++++++++++++++++++++++ docs/gaps.md | 6 +----- docs/ui-parity.md | 3 ++- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4f..6983f1c7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -664,6 +664,12 @@ impl SlideApp { } } egui::Event::Key { key, pressed: true, modifiers, .. } => { + if key == egui::Key::Tab { + // The slide has the keyboard, so Tab is ours (indent, tab character, next + // shape). Left to egui it would also focus the first ribbon widget, and + // every key after it would go there instead of the slide (#78). + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + } let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl }; if !self.session.tool.drawing_freeform() && menus::ui_shortcut(self, key, mods) { continue; @@ -932,6 +938,44 @@ mod tests { assert_eq!(harness.state().session.documents().len(), 2); } + /// #78: after Tab in a text box, typing and deleting no longer reached the text. + #[test] + fn typing_goes_on_after_tab_in_a_text_box() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0; + harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap(); + harness.event(egui::Event::Text("ab".into())); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.event(egui::Event::Text("c".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\tc"); + harness.key_press(egui::Key::Backspace); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\t"); + assert!(harness.state().session.active().unwrap().selection.text.is_some(), "still editing"); + // Tab at the start of a paragraph indents it, and typing goes on there too (#93). + harness.key_press(egui::Key::Enter); + harness.run_steps(2); + harness.key_press(egui::Key::Tab); + harness.run_steps(2); + harness.event(egui::Event::Text("d".into())); + harness.run_steps(2); + assert_eq!(title_text(harness.state()), "ab\t\nd"); + let level = |h: &egui_kittest::Harness| { + h.state().session.active().unwrap().doc.slides[0].shapes[0].text.as_ref().unwrap().paragraphs[1].level + }; + assert_eq!(level(&harness), 1); + } + #[test] fn home_needs_an_open_presentation() { let mut app = SlideApp::new(Session::new(), Services::default()); diff --git a/docs/gaps.md b/docs/gaps.md index 6ea4130a..fa303000 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -138,11 +138,6 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Impact:** copy-paste of screenshots is a core workflow. - **Estimate:** 2–4 h. **Beta blocker.** -### Tab inside a text box ends text entry (#78) -- **Evidence:** #78. -- **Impact:** tab stops and indented lists can't be typed. -- **Estimate:** 1–2 h. - ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & @@ -247,5 +242,6 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Removed "Tab inside a text box ends text entry" (#78, fixed) | | 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) | | 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5c7c50b9..8fe5e08d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -48,7 +48,7 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa | Connector glue and rerouting | yes | yes | | Edit points on freeforms | yes | **no** | | Crop handles, crop to shape, aspect ratio | yes | partial | -| Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) | +| Text caret, selection, double/triple click, drag-select | yes | yes | | Exact size/position, lock aspect | yes | yes | | Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial | | Context menus on shapes, slides, text, thumbnails | yes | yes, shorter | @@ -87,4 +87,5 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Tab in text boxes keeps editing (#78) | | 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 | From 44ddb579dad100c348119c4729e3c337182b633d Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:51:58 +0200 Subject: [PATCH 07/26] Charts: a single-series chart without an explicit title shows the series name as its automatic title (#142) --- crates/pptx/src/read/chart.rs | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index a9a960a3..b93378bb 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -202,6 +202,10 @@ pub(crate) fn chart_from(root: &El) -> Option { c.series.push(ser); } } + // `autoTitleDeleted val="0"` without `c:title`: PowerPoint and LibreOffice draw the series name as the title of a single-series chart. + if ch.child("title").is_none() && ch.child("autoTitleDeleted").and_then(|a| a.bool("val")) == Some(false) && c.series.len() == 1 { + c.title = c.series.first().map(|s| s.name.clone()).filter(|n| !n.is_empty()); + } let first = *kinds.first()?; c.kind = if kinds.iter().any(|k| *k != first) { ChartType::Combo } else { first }; if c.kind != ChartType::Combo { @@ -225,3 +229,37 @@ pub(crate) fn chart_from(root: &El) -> Option { } Some(c) } + +#[cfg(test)] +mod tests { + use super::*; + + const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main"; + + fn chart(auto_deleted: &str, title: &str, series: usize) -> Chart { + let val = r#"1"#; + let ser: String = (0..series).map(|i| format!(r#"S{i}{val}"#)).collect(); + let plot = format!(r#"{ser}"#); + let xml = format!(r#"{title}{auto_deleted}{plot}"#); + read_chart_xml(xml.as_bytes()).unwrap() + } + + #[test] + fn single_series_gets_automatic_title() { + assert_eq!(chart(r#""#, "", 1).title.as_deref(), Some("S0")); + } + + #[test] + fn no_automatic_title_when_deleted_or_multi_series() { + assert_eq!(chart(r#""#, "", 1).title, None); + assert_eq!(chart("", "", 1).title, None); + assert_eq!(chart(r#""#, "", 2).title, None); + } + + #[test] + fn explicit_title_wins() { + let t = "Mine"; + assert_eq!(chart(r#""#, t, 1).title.as_deref(), Some("Mine")); + } +} From 1c8992fdbe2680e64edc90571771e22346a73d0c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:52:32 +0200 Subject: [PATCH 08/26] Charts: a missing varyColors varies single-series bar colours by point (#143) --- crates/pptx/src/read/chart.rs | 29 ++++++++++++++++++++++++++--- crates/render/src/chart.rs | 14 +++++++++++++- crates/render/src/tests.rs | 17 +++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index a9a960a3..e8239ce2 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -150,9 +150,8 @@ pub(crate) fn chart_from(root: &El) -> Option { "surfaceChart" | "surface3DChart" => ChartType::Surface, _ => continue, }; - if let Some(v) = g.child_val("varyColors") { - c.vary_colors = v == "1" || v == "true"; - } + // A missing means "vary by point" (the schema default is true), as PowerPoint draws it. + c.vary_colors = g.child_val("varyColors").is_none_or(|v| v == "1" || v == "true"); if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { c.gap_width = (v / 100.0).clamp(0.0, 5.0); } @@ -225,3 +224,27 @@ pub(crate) fn chart_from(root: &El) -> Option { } Some(c) } + +#[cfg(test)] +mod tests { + use super::read_chart_xml; + + const NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart"; + + fn xml(vary: &str) -> String { + let ser = r#""#; + let bar = format!(r#"{vary}{ser}"#); + format!(r#"{bar}"#) + } + + #[test] + fn missing_vary_colors_means_vary_by_point() { + assert!(read_chart_xml(xml("").as_bytes()).is_some_and(|c| c.vary_colors)); + } + + #[test] + fn explicit_vary_colors_is_respected() { + assert!(read_chart_xml(xml(r#""#).as_bytes()).is_some_and(|c| !c.vary_colors)); + assert!(read_chart_xml(xml(r#""#).as_bytes()).is_some_and(|c| c.vary_colors)); + } +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index a29b2023..ced1f77a 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -36,6 +36,18 @@ fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart } } +/// Fill of one bar: a `` fill wins, and a single-series chart that varies colours by point uses one accent per point +/// (not waterfalls, which are never read from a ``). +fn bar_fill(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Rgba { + if let Some((_, Fill::Solid { color: cc })) = s.point_fills.iter().find(|(k, _)| *k == pi) { + return ctx.color(cc, None); + } + if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall { + return series_color(ctx, c, pi); + } + series_fill(ctx, c, si, Some(s)) +} + fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) { let mut body = TextBody::from_text(text); body.body.inset_l = Some(0.0); @@ -291,7 +303,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h Rect::new(plot.x0 + start, to_v(b), plot.x0 + start + bar, to_v(a)) }; ctx.set_transform(m); - ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 1.0)); + ctx.set_paint(color(bar_fill(rctx, c, si, i, s), 1.0)); ctx.fill_rect(&r.abs()); if c.data_labels { let t = fmt_num(s.values.get(i).copied().flatten().unwrap_or(0.0)); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ccb4472f..7e60e75d 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -523,3 +523,20 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() { let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() })); assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0); } + +#[test] +fn single_series_column_chart_varies_colours_by_point() { + let render = |vary: bool| { + let mut c = Chart::sample(ChartType::Column); + c.series.truncate(1); + c.vary_colors = vary; + let sh = Shape { + id: ShapeId(800), + xfrm: Some(Xfrm::new(50.0, 50.0, 400.0, 300.0)), + kind: ShapeKind::Chart(Box::new(c)), + ..Default::default() + }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }).pixels + }; + assert_ne!(render(true), render(false)); +} From f033c76913a271944b9afb895d161b420b8dcc66 Mon Sep 17 00:00:00 2001 From: Hevar Hemin Date: Sun, 11 Oct 2026 22:28:45 +0300 Subject: [PATCH 09/26] Bidi: auto-detect RTL paragraph direction and keep typing caret advancing smoothly When typing in Kurdish, Arabic or Hebrew in a new document or text shape, default paragraph direction (props.rtl: None) was resolving to LTR. In an LTR paragraph, visual reordering pinned newly typed characters and the caret to the left margin, resetting to the start of the line with every keystroke and jumping on space characters due to UAX #9 rule L1. - Add first_strong_is_rtl() in deckcraft-model following UAX #9 rules P2/P3. - Auto-resolve unassigned paragraph direction to RTL in shape_para layout when the paragraph starts with an RTL script, defaulting alignment to Align::Right. - Auto-promote paragraph to RTL (props.rtl = Some(true), props.align = Some(Align::Right)) when typing or inserting text starting with an RTL character. - Add regression tests in deckcraft-model, deckcraft-text, and deckcraft-engine. --- crates/engine/src/cmd/text.rs | 22 +++++++++- crates/engine/src/tests.rs | 47 +++++++++++++++++++- crates/model/src/bidi.rs | 81 +++++++++++++++++++++++++++++++++++ crates/model/src/lib.rs | 2 + crates/text/src/lib.rs | 7 ++- crates/text/src/tests.rs | 13 ++++++ 6 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 crates/model/src/bidi.rs diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 48be3a51..e09bb8d9 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use deckcraft_model::edit::{self as ed, Pos}; use deckcraft_model::resolve::{self, Ctx}; -use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; +use deckcraft_model::text::{Align, AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody}; use deckcraft_model::{Presentation, ShapeId, ShapeKind}; use serde_json::{Value, json}; @@ -232,12 +232,26 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) let c = ed::insert(body, at, &text, props); t.anchor = c; t.caret = c; + for pi in at.0..=c.0 { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para); + } + } c })?; autocorrect(s)?; Ok(json!({"caret": [pos.0, pos.1]})) } +fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph) { + if para.props.rtl.is_none() && deckcraft_model::bidi::first_strong_is_rtl(¶.text()) { + para.props.rtl = Some(true); + if para.props.align.is_none() { + para.props.align = Some(Align::Right); + } + } +} + /// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked /// run, the props to type with are the run's without the link. `None` elsewhere. fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option { @@ -314,6 +328,11 @@ pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { let c = ed::insert_body(body, at, src); t.anchor = c; t.caret = c; + for pi in at.0..=c.0 { + if let Some(para) = body.paragraphs.get_mut(pi) { + promote_rtl_if_needed(para); + } + } c })?; Ok(json!({"caret": [pos.0, pos.1]})) @@ -518,6 +537,7 @@ fn set(s: &mut Session, p: &Value) -> Result { para.level = *lv; para.props = pp.clone(); } + promote_rtl_if_needed(para); for r in &mut para.runs { r.props = keep.clone(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9aefd0b7..31365ca0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -406,7 +406,7 @@ fn right_to_left_paragraphs() { let mut s = session(); let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; s.execute("text.edit", &json!({"id": title})).unwrap(); - s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap(); + s.execute("text.insert", &json!({"text": "Year 2024 (Retrait) only"})).unwrap(); s.execute("format.rtl", &json!({})).unwrap(); let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); assert_eq!(para(&s).rtl, Some(true)); @@ -426,6 +426,51 @@ fn right_to_left_paragraphs() { assert_eq!(para(&s).rtl, Some(true)); } +#[test] +fn typing_kurdish_or_arabic_auto_detects_rtl_and_advances_caret_smoothly() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + + let mut first_x = None; + let mut last_x = 0.0; + + // Type Kurdish sentence character-by-character: "من ناوم هێڤاره" + for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] { + s.execute("text.insert", &json!({"text": ch})).unwrap(); + let st = s.doc().unwrap(); + let t = st.selection.text.as_ref().unwrap(); + let l = super::cmd::text::layout_for(st, t).unwrap(); + let (cx, ..) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }).unwrap(); + + if first_x.is_none() { + first_x = Some(cx); + } + last_x = cx; + + // Caret must remain near the right margin (e.g. > 400.0 pt in title placeholder) + // and must NOT jump back to the left margin (e.g. ~7.2 pt). + assert!(cx > 400.0, "caret should not jump to left margin: {cx}"); + } + + // Caret advances leftwards overall as text is appended + assert!(last_x < first_x.unwrap(), "caret should advance leftwards: last={last_x} first={:?}", first_x); + let para = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); + assert_eq!(para.rtl, Some(true), "auto-detected as RTL"); + assert_eq!(para.align, Some(deckcraft_model::text::Align::Right), "aligned right"); +} + +#[test] +fn explicit_ltr_override_is_preserved_when_typing_rtl() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("format.rtl", &json!({"on": false})).unwrap(); + s.execute("text.insert", &json!({"text": "مرحبا"})).unwrap(); + let para = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone(); + assert_eq!(para.rtl, Some(false), "user explicit LTR is preserved"); +} + #[test] fn header_footer_fixed_date_is_the_slides_own_text() { use deckcraft_model::text::RunKind; diff --git a/crates/model/src/bidi.rs b/crates/model/src/bidi.rs new file mode 100644 index 00000000..0b113522 --- /dev/null +++ b/crates/model/src/bidi.rs @@ -0,0 +1,81 @@ +//! Bidirectional text helpers (UAX #9). +//! +//! Follows Unicode Bidirectional Algorithm (UAX #9 rules P2 and P3) for paragraph +//! base direction auto-detection. + +/// Does the first strong directional character in `text` belong to a right-to-left script? +/// +/// Follows UAX #9 rules P2 and P3: characters with strong bidirectional class AL or R +/// (Arabic, Hebrew, Syriac, Thaana…) give `true`; strong L characters (Latin, Greek, +/// Cyrillic, CJK…) give `false`. Neutral, weak, and whitespace characters are skipped. +pub fn first_strong_is_rtl(text: &str) -> bool { + for c in text.chars() { + if c == '\u{200F}' { + // Right-to-Left Mark (RLM) + return true; + } + if c == '\u{200E}' { + // Left-to-Right Mark (LRM) + return false; + } + if c.is_alphabetic() { + return is_rtl_script(c); + } + } + false +} + +/// Helper checking if a char belongs to standard RTL Unicode blocks. +pub fn is_rtl_script(c: char) -> bool { + matches!( + c, + '\u{0590}'..='\u{05FF}' // Hebrew + | '\u{0600}'..='\u{06FF}' // Arabic + | '\u{0700}'..='\u{074F}' // Syriac + | '\u{0750}'..='\u{077F}' // Arabic Supplement + | '\u{0780}'..='\u{07BF}' // Thaana + | '\u{0870}'..='\u{089F}' // Arabic Extended-B + | '\u{08A0}'..='\u{08FF}' // Arabic Extended-A + | '\u{FB1D}'..='\u{FB4F}' // Hebrew Presentation Forms + | '\u{FB50}'..='\u{FDFF}' // Arabic Presentation Forms-A + | '\u{FE70}'..='\u{FEFE}' // Arabic Presentation Forms-B + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_arabic_and_kurdish() { + assert!(first_strong_is_rtl("مرحبا")); + assert!(first_strong_is_rtl("من ناوم هێڤاره")); + assert!(first_strong_is_rtl("سلام")); + } + + #[test] + fn detects_hebrew() { + assert!(first_strong_is_rtl("שלום")); + } + + #[test] + fn detects_latin_as_not_rtl() { + assert!(!first_strong_is_rtl("Hello")); + assert!(!first_strong_is_rtl("DeckCraft")); + } + + #[test] + fn skips_neutrals_numbers_and_whitespace() { + assert!(first_strong_is_rtl(" 123 مرحبا")); + assert!(first_strong_is_rtl("... (2024) سنة")); + assert!(!first_strong_is_rtl(" 123 Hello")); + assert!(!first_strong_is_rtl("")); + assert!(!first_strong_is_rtl(" 12345 ")); + } + + #[test] + fn honours_directional_marks() { + assert!(first_strong_is_rtl("\u{200F}Hello")); + assert!(!first_strong_is_rtl("\u{200E}مرحبا")); + } +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 6940fa4b..f8129f69 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] pub mod anim; +pub mod bidi; pub mod chart; pub mod defaults; pub mod edit; @@ -24,6 +25,7 @@ pub use deckcraft_geom::{Rect, Size, Xfrm}; use serde::{Deserialize, Serialize}; pub use anim::{AnimClass, AnimStart, Animation, Transition}; +pub use bidi::{first_strong_is_rtl, is_rtl_script}; pub use chart::Chart; pub use style::{ColorBase, ColorRef, Effects, Fill, Line, ShapeStyle}; pub use table::{Cell, Table}; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 225f4afb..a14f5ad7 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -415,8 +415,11 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64, rtl: false, }; }; - let props = resolve::para(ctx, shape, para); - let rtl = props.rtl.unwrap_or(false); + let mut props = resolve::para(ctx, shape, para); + let rtl = props.rtl.unwrap_or_else(|| deckcraft_model::bidi::first_strong_is_rtl(¶.text())); + if rtl && para.props.align.is_none() { + props.align = Some(Align::Right); + } let mut styles: Vec