Merge the landing branch into land-r8

# Conflicts:
#	contributors/people.toml
#	crates/engine/src/cmd/file.rs
#	crates/pptx/src/opc.rs
#	crates/pptx/tests/roundtrip.rs
#	crates/ui-egui/src/backstage.rs
#	crates/ui-egui/src/lib.rs
This commit is contained in:
Brandon Thomas
2026-10-14 15:53:57 -04:00
76 changed files with 6113 additions and 477 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
add
),
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids?} (all of the shapes' animations)", has_slide, remove),
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids? (all of the shapes' animations)}", has_slide, remove),
cmd!("animation.move", "Reorder Animation", ["Animations", "Timing"], None, "{index, to}", has_slide, move_anim),
cmd!(
"animation.timing",
+304 -35
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Format Background",
["Design", "Customize"],
None,
"{color? | gradient? | picture?: base64 | style?: 1..12 | reset?, all?: bool (apply to all), index?}",
"{color? | gradient? | picture?: base64 | pattern? | style?: 1..12 | reset?, all?: bool, index?, transparency?: 0..1, pictureProps?: {tile?, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0.01..10, align?, mirror?, stretch?: [l,t,r,b], rotateWithShape?}, copy?: bool}",
has_slide,
background
),
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Slide Size",
["Design", "Customize"],
None,
"{preset?: widescreen|standard|… | w, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}",
"{preset: widescreen|standard|…, orientation?: portrait|landscape, scale?: maximize|ensureFit|none} | {w: pt, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}",
has_doc,
slide_size
),
@@ -152,7 +152,7 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(not(target_arch = "wasm32"))]
{
let path = str_param(p, "path").ok_or_else(|| bad("design.browseTheme", "give `path` or `data`"))?;
std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?
crate::io::read_file(path).map_err(crate::EngineError::Other)?
}
#[cfg(target_arch = "wasm32")]
return Err(bad("design.browseTheme", "give `data`"));
@@ -480,70 +480,256 @@ fn effects(s: &mut Session, p: &Value) -> Result<Value> {
}
fn background(s: &mut Session, p: &Value) -> Result<Value> {
let bg: Option<Background> = if bool_or(p, "reset", false) {
None
} else if let Some(c) = color_param(p, "color") {
Some(Background::Fill { fill: Fill::solid(c) })
} else if let Some(i) = usize_param(p, "style") {
if bool_or(p, "copy", false) {
return copy_slide_background(s);
}
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
let transparency = f64_param(p, "transparency");
let reset = bool_or(p, "reset", false);
let color = color_param(p, "color");
let style = usize_param(p, "style");
let op = if reset {
BgOp::Set(None)
} else if let Some(mut c) = color {
if let Some(t) = transparency {
set_solid_transparency(&mut c, t);
}
BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) }))
} else if let Some(i) = style {
// Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2).
let slots =
[deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1];
let i = i.clamp(1, 12) - 1;
Some(Background::Ref {
BgOp::Set(Some(Background::Ref {
idx: 1001 + (i / 4) as u32,
color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)),
})
}))
} else if let Some(g) = p.get("gradient") {
let mut g = super::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?;
g.rotate_with_shape = false;
Some(Background::Fill { fill: Fill::Gradient(g) })
BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) }))
} else if let Some(pt) = p.get("pattern") {
Some(Background::Fill {
BgOp::Set(Some(Background::Fill {
fill: Fill::Pattern(deckcraft_model::style::PatternFill {
preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(),
fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)),
}),
})
}))
} else if p.get("picture").is_some() {
let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?;
let ct = super::insert::content_type(&name, &bytes);
let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?;
Some(Background::Fill { fill: Fill::Picture(deckcraft_model::style::PictureFill { media, ..Default::default() }) })
let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
if let Some(pp) = &props {
apply_picture_props(&mut pf, pp);
}
if let Some(t) = transparency {
set_picture_transparency(&mut pf, t);
}
BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) }))
} else if props.is_some() || transparency.is_some() {
BgOp::Adjust
} else {
return Err(bad("design.background", "give `color`, `gradient`, `pattern`, `picture`, `style` or `reset`"));
return Err(bad(
"design.background",
"give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`",
));
};
let all = bool_or(p, "all", false);
let index = usize_param(p, "index");
if matches!(op, BgOp::Adjust) {
let next = {
let st = s.doc()?;
let current = background_of(&st.doc, &st.selection, index);
let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) }));
let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } }));
if props.is_some() && !picture {
return Err(bad("design.background", "picture options need a picture background"));
}
if transparency.is_some() && !picture && !solid {
return Err(bad("design.background", "transparency needs a solid or picture background"));
}
let mut next = current.clone();
if let Some(bg) = next.as_mut() {
adjust_background(bg, props.as_ref(), transparency);
}
if next == current { None } else { Some(next) }
};
let Some(next) = next else { return ok() };
s.edit(|doc, sel| {
write_background(doc, sel, index, false, next);
Ok(())
})?;
return ok();
}
let BgOp::Set(bg) = op else { return ok() };
let unchanged = {
let st = s.doc()?;
backgrounds_already(&st.doc, &st.selection, index, all, &bg)
};
if unchanged {
return ok();
}
s.edit(|doc, sel| {
match sel.target {
Target::Master { master } => {
if let Some(m) = doc.masters.get_mut(master) {
Arc::make_mut(m).background = bg.clone();
write_background(doc, sel, index, all, bg);
Ok(())
})?;
ok()
}
enum BgOp {
Set(Option<Background>),
Adjust,
}
fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option<usize>) -> Option<Background> {
match sel.target {
Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()),
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()),
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()),
Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()),
}
}
fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: &Option<Background>) -> bool {
match sel.target {
Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg),
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg),
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg),
Target::Slides => {
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
!list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg))
}
}
}
fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: Option<Background>) {
match sel.target {
Target::Master { master } => {
if let Some(m) = doc.masters.get_mut(master) {
Arc::make_mut(m).background = bg;
}
}
Target::Layout { master, layout } => {
if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) {
l.background = bg;
}
}
t @ (Target::Handout | Target::NotesMaster) => {
if let Some(m) = crate::page_master_mut(doc, t) {
Arc::make_mut(m).background = bg;
}
}
Target::Slides => {
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
for i in list {
if let Some(sl) = doc.slides.get_mut(i) {
Arc::make_mut(sl).background = bg.clone();
}
}
Target::Layout { master, layout } => {
if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) {
l.background = bg.clone();
}
}
}
}
fn copy_slide_background(s: &mut Session) -> Result<Value> {
let bg = {
let st = s.doc()?;
if !matches!(st.selection.target, Target::Slides) {
return Err(bad("design.background", "copy applies to slides"));
}
let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone());
if st.doc.slides.iter().all(|sl| sl.background == bg) {
return ok();
}
bg
};
s.edit(|doc, sel| {
write_background(doc, sel, None, true, bg);
Ok(())
})?;
ok()
}
fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option<f64>) {
match bg {
Background::Fill { fill: Fill::Picture(pf) } => {
if let Some(pp) = props {
apply_picture_props(pf, pp);
}
t @ (Target::Handout | Target::NotesMaster) => {
if let Some(m) = crate::page_master_mut(doc, t) {
Arc::make_mut(m).background = bg.clone();
}
if let Some(t) = transparency {
set_picture_transparency(pf, t);
}
Target::Slides => {
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
for i in list {
if let Some(sl) = doc.slides.get_mut(i) {
Arc::make_mut(sl).background = bg.clone();
}
Background::Fill { fill: Fill::Solid { color } } => {
if let Some(t) = transparency {
set_solid_transparency(color, t);
}
}
_ => {}
}
}
fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) {
let a = 1.0 - t.clamp(0.0, 1.0);
pf.alpha = (a < 1.0).then_some(a);
}
fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) {
color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_)));
let t = t.clamp(0.0, 1.0);
if t > 0.0 {
color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32));
}
}
/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch).
fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) {
use deckcraft_model::style::PictureMode;
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
pf.upright = !up;
}
match pp.get("tile").and_then(Value::as_bool) {
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
}
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
_ => {}
}
match &mut pf.mode {
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
if let Some(v) = f("offsetX") {
*tx = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("offsetY") {
*ty = v.clamp(-4000.0, 4000.0);
}
if let Some(v) = f("scaleX") {
*sx = v.clamp(0.01, 10.0);
}
if let Some(v) = f("scaleY") {
*sy = v.clamp(0.01, 10.0);
}
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
*flip = m.to_string();
}
if let Some(a) =
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
{
*align = a.to_string();
}
}
PictureMode::Stretch { fill_rect } => {
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
for (slot, v) in fill_rect.iter_mut().zip(arr) {
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
*slot = v.clamp(-10.0, 0.99);
}
}
}
}
Ok(())
})?;
ok()
}
}
fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1266,7 +1452,9 @@ fn slide_number_paragraph() -> deckcraft_model::Paragraph {
#[cfg(test)]
mod tests {
use deckcraft_color::SchemeSlot;
use deckcraft_model::style::PictureMode;
use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes};
use deckcraft_model::{Background, Fill};
use serde_json::{Value, json};
use crate::{Session, UiRequest};
@@ -1283,6 +1471,21 @@ mod tests {
s.doc().unwrap().history.undo.len()
}
fn png() -> String {
let mut img = image::RgbaImage::new(2, 2);
img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255]));
let mut bytes = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
crate::cmd::base64_encode(&bytes)
}
fn picture(s: &Session) -> deckcraft_model::style::PictureFill {
match &s.doc().unwrap().doc.slides[0].background {
Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(),
other => panic!("expected a picture background, got {other:?}"),
}
}
#[test]
fn civil_dates() {
assert_eq!(super::civil(0), (1970, 1, 1));
@@ -1453,6 +1656,72 @@ mod tests {
assert_eq!(font_scheme(&s).major.ea, "Kept");
assert_eq!(font_scheme(&s).minor.latin, "Open Sans");
}
#[test]
fn background_picture_options_tile_transparency_and_copy() {
let mut s = Session::with_new();
let n = undo_len(&s);
let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string();
assert!(err.contains("picture background"), "{err}");
assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err());
assert_eq!(undo_len(&s), n);
s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap();
match &s.doc().unwrap().doc.slides[0].background {
Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()),
other => panic!("expected a solid background, got {other:?}"),
}
let n = undo_len(&s);
s.execute("design.background", &json!({"transparency": 0.25})).unwrap();
assert_eq!(undo_len(&s), n, "the same transparency is not an undo step");
s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap();
let pf = picture(&s);
assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6);
assert!(matches!(pf.mode, PictureMode::Tile { .. }));
let n = undo_len(&s);
s.execute(
"design.background",
&json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}),
)
.unwrap();
match picture(&s).mode {
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0));
assert_eq!(flip, "xy");
assert_eq!(align, "ctr");
}
other => panic!("expected a tile, got {other:?}"),
}
assert!(picture(&s).upright);
s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap();
match picture(&s).mode {
PictureMode::Tile { tx, align, .. } => {
assert_eq!(tx, 12.0);
assert_eq!(align, "ctr");
}
other => panic!("expected the tile to stay, got {other:?}"),
}
assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step");
s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap();
match picture(&s).mode {
PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]),
other => panic!("expected a stretch, got {other:?}"),
}
s.execute("slide.new", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.slides[1].background.is_none());
s.execute("slide.go", &json!({"index": 0})).unwrap();
let n = undo_len(&s);
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background);
assert_eq!(undo_len(&s), n + 1);
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background");
s.execute("design.background", &json!({"copy": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step");
}
}
#[cfg(test)]
+73 -16
View File
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3, orientation?: portrait|landscape, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
cmd!(
query "file.info",
"Info",
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "file.recovery.save", "Save AutoRecover Information", [], None, "{} → {written}", has_doc, recovery_save),
cmd!(query "file.recovery.list", "Recovered Presentations", [], None, "{} → [{uid, path, title, saved}]", always, recovery_list),
cmd!(noundo "file.recovery.open", "Open Recovered Presentations", [], None, "{} → {opened: [document index]}", always, recovery_open),
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry, else all}", always, recovery_discard),
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry (else all)}", always, recovery_discard),
cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert),
cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window),
]
@@ -187,7 +187,7 @@ pub struct LoadedFile {
/// Read and parse the presentation at `path`.
#[cfg(not(target_arch = "wasm32"))]
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
let (doc, damaged) = open_checked(path, &bytes)?;
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
@@ -257,12 +257,13 @@ fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> {
Ok(())
}
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`).
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`, `gif`).
pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
match format {
f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).map_err(|e| EngineError::Other(e.to_string())),
"outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()),
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
"gif" => gif_bytes(doc, &Value::Null),
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
}
}
@@ -336,16 +337,21 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
}
/// The output extensions [`format_for_path`] maps to a writer of that format.
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "txt", "md"];
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"];
/// The format a file named `path` is written in, or `None` when its extension names no format
/// DeckCraft writes (`.odp`, `.gif`, `.svg`, no extension …): [`format_for_path`] would save
/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save
/// those as native `.deckcraft` bytes under the wrong name.
pub fn writable_format_for_path(path: &str) -> Option<&'static str> {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
WRITABLE_EXTENSIONS.contains(&ext.as_str()).then(|| format_for_path(path))
}
/// Animated GIF bytes for `doc` with the options in `p` (see [`crate::gif_export::GifOptions::from_params`]).
pub fn gif_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
Ok(crate::gif_export::encode(doc, &crate::gif_export::GifOptions::from_params(p)?)?.bytes)
}
pub fn format_for_path(path: &str) -> &'static str {
let l = path.to_ascii_lowercase();
if l.ends_with(".pptx") {
@@ -360,6 +366,8 @@ pub fn format_for_path(path: &str) -> &'static str {
"jpeg"
} else if l.ends_with(".pdf") {
"pdf"
} else if l.ends_with(".gif") {
"gif"
} else if l.ends_with(".txt") || l.ends_with(".md") {
"outline"
} else {
@@ -381,16 +389,8 @@ fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
// Write to a temporary sibling then rename, so a failed save never truncates the old file.
let tmp = format!("{path}.saving");
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
fn write_file(_path: &str, _bytes: &[u8]) -> Result<()> {
Err(EngineError::Other("saving to a path is not available in the browser".into()))
crate::io::write_file(path, bytes).map_err(EngineError::Other)
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
@@ -519,6 +519,23 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}
"gif" => {
// `slides` may be a range, as for PDF ("1-3,5").
let mut gp = p.clone();
if let Some(text) = p.get("slides").and_then(Value::as_str) {
gp["slides"] = json!(parse_slide_range(text, st.doc.slides.len())?);
}
let out = crate::gif_export::encode(&st.doc, &crate::gif_export::GifOptions::from_params(&gp)?)?;
write_file(&path, &out.bytes)?;
Ok(json!({
"path": path,
"bytes": out.bytes.len(),
"frames": out.frames,
"width": out.width,
"height": out.height,
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
}))
}
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
let bytes = save_bytes(&st.doc, &format)?;
write_file(&path, &bytes)?;
@@ -533,7 +550,7 @@ fn info(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.doc()?;
let doc = &st.doc;
#[cfg(not(target_arch = "wasm32"))]
let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len());
let bytes = st.path.as_deref().and_then(crate::io::file_len);
#[cfg(target_arch = "wasm32")]
let bytes: Option<u64> = None;
Ok(json!({
@@ -644,3 +661,43 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> {
#[cfg(test)]
#[path = "color_at_tests.rs"]
mod color_at_tests;
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::write_file;
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn write_file_does_not_touch_a_predictable_saving_sibling() {
let dir = scratch("sibling");
let dest = dir.join("deck.deckcraft");
let dest_str = dest.to_str().unwrap();
std::fs::write(&dest, b"old").unwrap();
std::fs::write(dir.join("deck.deckcraft.saving"), b"precious").unwrap();
write_file(dest_str, b"new").unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"new");
assert_eq!(std::fs::read(dir.join("deck.deckcraft.saving")).unwrap(), b"precious");
let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
assert_eq!(names.len(), 2, "no temporary file may be left behind: {names:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_file_failed_rename_keeps_destination_and_cleans_up() {
let dir = scratch("failed");
let dest = dir.join("target");
std::fs::create_dir(&dest).unwrap();
assert!(write_file(dest.to_str().unwrap(), b"new").is_err());
assert!(dest.is_dir());
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
}
+3 -10
View File
@@ -41,15 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("format.size", "Font Size", ["Home", "Font"], None, "{size: pt}", has_text_or_shapes, size),
cmd!("format.grow", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+."), "{}", has_text_or_shapes, grow),
cmd!("format.shrink", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+,"), "{}", has_text_or_shapes, shrink),
cmd!(
"format.color",
"Font Color",
["Home", "Font"],
None,
"{color: #RRGGBB | accent1..6 | tx1… | {scheme, lumMod…}}",
has_text_or_shapes,
color
),
cmd!("format.color", "Font Color", ["Home", "Font"], None, "{color: #RRGGBB|accent1..6|tx1…|{scheme, lumMod…}}", has_text_or_shapes, color),
cmd!(
"format.textTransparency",
"Text Transparency",
@@ -214,7 +206,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text Box Margins",
["Format Shape", "Text Box"],
None,
"{left?, top?, right?, bottom?: pt}",
"{left?: pt, top?: pt, right?: pt, bottom?: pt}",
has_text_or_shapes,
margins
),
@@ -1206,6 +1198,7 @@ fn state(s: &mut Session, _p: &Value) -> Result<Value> {
"align": pp.align.map(|a| a.xml()),
"bullets": matches!(pp.bullet, Some(Bullet::Char { .. })),
"numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })),
"lang": r.lang,
"lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(),
"painter": s.painter.is_some(),
}))
+1 -2
View File
@@ -308,9 +308,8 @@ pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec<u8>)> {
let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?;
return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes));
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = str_param(p, "path") {
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into());
return Ok((name, bytes));
}
+2 -1
View File
@@ -10,7 +10,7 @@ use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes", has_doc, slide),
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
]
@@ -129,6 +129,7 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
"transition": serde_json::to_value(&sl.transition).unwrap_or_default(),
"animations": serde_json::to_value(&sl.animations).unwrap_or_default(),
"notes": sl.notes_text(),
"notesBody": sl.notes,
"comments": sl.comments.len(),
"hidden": sl.hidden,
}))
+124 -7
View File
@@ -46,6 +46,9 @@ pub struct CommandSpec {
/// Where it lives: `["Home", "Font"]` (ribbon tab and group) or `["Arrange"]` (menu).
pub menu: &'static [&'static str],
pub shortcut: Option<&'static str>,
/// The parameters as `describe` and `commands` show them, read by [`parse_params`]: `?` marks
/// an optional key, a spaced ` | ` separates alternative sets of keys (so alternative values
/// are written unspaced, `ms|null`), `(…)` is a remark and `→` starts the answer.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
@@ -66,6 +69,28 @@ pub struct CommandInfo {
}
impl CommandSpec {
/// Holds because `Session::execute` skips the selection check when `id` or `ids` is given.
pub fn acts_on(&self) -> Option<String> {
let keys = param_keys(self.params).unwrap_or_default();
let named = if keys.contains(&"ids") {
Some("the shapes in `ids`")
} else if keys.contains(&"id") {
Some("the shape in `id`")
} else {
None
};
let reason = (self.enabled)(&Session::with_new()).err()?;
Some(match (reason.as_str(), named) {
(SELECT_TEXT_OR_SHAPE, _) => "Acts on the selected text or shapes.".into(),
(NOTHING_SELECTED, Some(n)) => format!("Acts on {n}, else on the selected shapes."),
(NOTHING_SELECTED, None) => "Acts on the selected shapes.".into(),
(SELECT_TWO, Some(n)) => format!("Acts on two or more shapes: {n}, else the selected ones."),
(SELECT_TWO, None) => "Acts on two or more selected shapes.".into(),
(NO_TEXT_POINT, _) => "Acts on the text being edited; text.edit starts editing a shape's text.".into(),
_ => return None,
})
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
CommandInfo {
@@ -92,6 +117,8 @@ pub fn has_slide(s: &Session) -> std::result::Result<(), String> {
}
pub const NOTHING_SELECTED: &str = "nothing selected";
pub const SELECT_TWO: &str = "select two or more objects";
pub const NO_TEXT_POINT: &str = "no text insertion point";
pub const SELECT_TEXT_OR_SHAPE: &str = "select text or a shape";
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) }
@@ -102,17 +129,14 @@ pub fn has_two(s: &Session) -> std::result::Result<(), String> {
}
pub fn has_text(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) }
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err(NO_TEXT_POINT.into()) }
}
/// Text editing or shapes with text selected (formatting commands apply to either).
/// Text editing or selected shapes (formatting commands apply to either).
pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) {
Ok(())
} else {
Err("select text or a shape".into())
}
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { Ok(()) } else { Err(SELECT_TEXT_OR_SHAPE.into()) }
}
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|d| !d.history.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
}
@@ -172,6 +196,99 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
}
pub(crate) fn param_keys(doc: &str) -> std::result::Result<Vec<&str>, String> {
let mut keys = vec![];
for p in parse_params(doc)? {
if !keys.contains(&p.name) {
keys.push(p.name);
}
}
Ok(keys)
}
#[derive(Debug, PartialEq)]
pub struct Param<'a> {
pub name: &'a str,
pub optional: bool,
/// What the doc says of it: its values, unit or remark (`rect|roundRect|…`, `[x,y,w,h] pt`).
pub about: &'a str,
/// An alternative to the parameter before it in the same object (`color? | none?`).
pub or: bool,
/// Which of the doc's alternative objects it belongs to: `{index} | {id}` puts id in object 1.
pub object: usize,
}
/// The top-level parameters of a params doc: `{preset, rect?: [x,y,w,h] pt} | {id}` gives preset,
/// rect (optional, `[x,y,w,h] pt`) and id (in the second object). Text after `→` is the result
/// and is skipped. Errs where the doc strays from that grammar.
pub fn parse_params(doc: &str) -> std::result::Result<Vec<Param<'_>>, String> {
let doc = doc.split('→').next().unwrap_or_default();
let mut params: Vec<Param> = vec![];
let mut open: Vec<char> = vec![];
let mut expect_key = false;
let mut or = false;
let mut objects: usize = 0;
let mut about_from: Option<usize> = None;
let mut chars = doc.char_indices().peekable();
let mut prev = ' ';
fn end_about<'a>(doc: &'a str, params: &mut [Param<'a>], from: &mut Option<usize>, to: usize) {
if let (Some(f), Some(p)) = (from.take(), params.last_mut()) {
p.about = doc.get(f..to).unwrap_or_default().trim();
}
}
while let Some((i, c)) = chars.next() {
let in_object = open == ['{'];
match c {
'{' | '[' | '(' => {
open.push(c);
if open == ['{'] {
expect_key = true;
objects += 1;
or = false;
}
}
'}' | ']' | ')' => {
if in_object {
end_about(doc, &mut params, &mut about_from, i);
}
open.pop();
}
',' if in_object => {
end_about(doc, &mut params, &mut about_from, i);
expect_key = true;
}
'|' if in_object && prev == ' ' && chars.peek().is_some_and(|(_, n)| *n == ' ') => {
end_about(doc, &mut params, &mut about_from, i);
expect_key = true;
or = true;
}
c if in_object && expect_key && (c.is_ascii_alphabetic() || c == '_') => {
let mut end = i + c.len_utf8();
while let Some((j, n)) = chars.peek().copied().filter(|(_, n)| n.is_ascii_alphanumeric() || *n == '_') {
end = j + n.len_utf8();
chars.next();
}
let after = doc.get(end..).unwrap_or_default();
let rest = after.trim_start_matches('?').trim_start();
let at = doc.len() - rest.len();
about_from = match rest.chars().next() {
Some(':') => Some(at + 1),
Some('(') => Some(at),
Some(',' | '}' | '|') => None,
_ => return Err(format!("`{}` is followed by `{}`", &doc[i..end], rest.chars().take(12).collect::<String>())),
};
params.push(Param { name: &doc[i..end], optional: after.starts_with('?'), about: "", or, object: objects.saturating_sub(1) });
or = false;
expect_key = false;
}
c if in_object && expect_key && !c.is_whitespace() => return Err(format!("expected a key at `{}`", &doc[i..])),
_ => {}
}
prev = c;
}
Ok(params)
}
// ---------- param helpers ----------
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
+2 -2
View File
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary),
cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info),
cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word?: default the selected word, or the word at the caret} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus),
cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word? (default: the selected word, or the word at the caret)} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus),
cmd!(
"review.replaceWord",
"Insert Synonym",
@@ -639,7 +639,7 @@ fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(path) = str_param(p, "path") {
let bytes = std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
let bytes = crate::io::read_file(path).map_err(crate::EngineError::Other)?;
return Ok((path.to_string(), bytes));
}
Err(bad("review.compare", "give `path` or `data`"))
+19 -3
View File
@@ -39,8 +39,16 @@ pub fn specs() -> Vec<CommandSpec> {
ink_to_shape
),
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by),
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
cmd!(
"shape.move",
"Move",
[],
None,
"{dx?: pt (relative), dy?: pt (relative), ids?} | {x?: pt (absolute), y?: pt (absolute), ids?}",
has_selection,
move_by
),
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?: pt, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds),
cmd!(
"shape.rotate",
@@ -121,7 +129,15 @@ pub fn specs() -> Vec<CommandSpec> {
lock
),
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
cmd!(
"picture.crop",
"Crop",
["Picture Format", "Size"],
None,
"{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}",
has_selection,
crop
),
cmd!(
"picture.adjust",
"Corrections",
+20 -2
View File
@@ -32,7 +32,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide),
cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout),
cmd!("slide.reset", "Reset", ["Home", "Slides"], None, "{index?}", has_slide, reset),
cmd!("slide.notes", "Notes", ["View"], None, "{text, index?}", has_slide, notes),
cmd!(
"slide.notes",
"Notes",
["View"],
None,
"{text | body: formatted notes (as in slide.inspect's notesBody), index?} (text as typed: untouched notes keep their formatting)",
has_slide,
notes
),
cmd!("slide.rename", "Rename Slide", [], None, "{name, index?}", has_slide, rename),
cmd!("section.add", "Add Section", ["Home", "Slides", "Section"], None, "{name?, at?: slide index}", has_slide, section_add),
cmd!("section.rename", "Rename Section", ["Home", "Slides", "Section"], None, "{index, name}", has_slide, section_rename),
@@ -468,10 +476,20 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
fn notes(s: &mut Session, p: &Value) -> Result<Value> {
let i = index_of(s, p)?;
// Formatted notes as a whole (the notes editors), or text: then only what was retyped
// changes, so bold, levels and bullets around it survive.
let body = match p.get("body") {
Some(b) => Some(serde_json::from_value::<TextBody>(b.clone()).map_err(|e| bad("slide.notes", format!("invalid `body`: {e}")))?),
None => None,
};
let text = str_param(p, "text").unwrap_or("").to_string();
s.edit(|doc, _| {
if let Some(sl) = doc.slides.get_mut(i) {
Arc::make_mut(sl).notes = TextBody::from_text(&text);
let notes = &mut Arc::make_mut(sl).notes;
match body {
Some(b) => *notes = b,
None => deckcraft_model::edit::replace_text(notes, &text),
}
}
Ok(())
})?;
+140 -7
View File
@@ -1,10 +1,11 @@
//! Table Design and Table Layout tabs.
use deckcraft_model::{ShapeKind, Table};
use deckcraft_geom::{Affine, Point, Rect};
use deckcraft_model::{Shape, ShapeKind, Table};
use serde_json::{Value, json};
use super::*;
use crate::{Result, Session};
use crate::{DocState, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -27,7 +28,15 @@ pub fn specs() -> Vec<CommandSpec> {
s, p, "delCol"
)),
cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge),
cmd!("table.split", "Split Cells", ["Table Layout", "Merge"], None, "{cell: [r, c], id?}", has_selection, split),
cmd!(
"table.split",
"Split Cells",
["Table Layout", "Merge"],
None,
"{cell?: [r, c] (else the edited cell), id?} | {row?: index, col?: index, id?}",
has_selection,
split
),
cmd!(
"table.style",
"Table Styles",
@@ -42,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Table Style Options",
["Table Design", "Table Style Options"],
None,
"{headerRow?, totalRow?, bandedRows?, firstColumn?, lastColumn?, bandedColumns?: bool, id?}",
"{headerRow?: bool, totalRow?: bool, bandedRows?: bool, firstColumn?: bool, lastColumn?: bool, bandedColumns?: bool, id?}",
has_selection,
options
),
@@ -86,7 +95,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("table.distributeColumns", "Distribute Columns", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute(
s, p, false
)),
cmd!("table.columnWidth", "Width", ["Table Layout", "Cell Size"], None, "{col, width: pt, id?}", has_selection, column_width),
cmd!(
"table.columnWidth",
"Width",
["Table Layout", "Cell Size"],
None,
"{col, width: pt, keepTotal?: bool (the next column absorbs the change), id?}",
has_selection,
column_width
),
cmd!("table.rowHeight", "Height", ["Table Layout", "Cell Size"], None, "{row, height: pt, id?}", has_selection, row_height),
cmd!(
"table.drawLine",
@@ -110,6 +127,104 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Narrowest a column gets when its border is dragged (points).
pub const MIN_COLUMN_WIDTH: f64 = 12.0;
/// A table as drawn on the slide: where it is, its drawn row heights and its visible cells.
#[derive(Clone, Debug)]
pub struct TableFrame {
/// Table-local (points from the table's top-left corner) → slide space, as the table is drawn.
pub affine: Affine,
pub cols: Vec<f64>,
/// Row heights as drawn: a stored height is a minimum, rows grow to fit their text.
pub heights: Vec<f64>,
/// Visible cells (merges honoured): (row, col, table-local rect).
pub cells: Vec<(usize, usize, Rect)>,
}
/// A border between table rows or columns that can be dragged.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TableBorder {
/// The right edge of this column (the last one is the table's right edge).
Column(usize),
/// The bottom edge of this row (the last one is the table's bottom edge).
Row(usize),
}
/// The drawn geometry of table shape `sh` on the edited slide (`None` if it isn't a table).
pub fn table_frame(st: &DocState, sh: &Shape) -> Option<TableFrame> {
let ShapeKind::Table(t) = &sh.kind else { return None };
// The table is drawn in its own box; rows that grow past it don't move it.
let x = sh.xfrm.unwrap_or_else(|| xfrm_of(&st.doc, &st.selection, sh));
let heights = table_drawn_heights(&st.doc, &st.selection, t);
let cells = deckcraft_render::table_cell_rects(t, &heights);
Some(TableFrame { affine: x.affine(), cols: t.cols.clone(), heights, cells })
}
impl TableFrame {
pub fn width(&self) -> f64 {
self.cols.iter().map(|w| w.max(0.0)).sum()
}
pub fn height(&self) -> f64 {
self.heights.iter().map(|h| h.max(0.0)).sum()
}
/// Table-local point for slide point `p`.
pub fn local(&self, p: Point) -> Point {
self.affine.inverse() * p
}
/// Table-local rect of visible cell (`r`, `c`).
pub fn cell_rect(&self, r: usize, c: usize) -> Option<Rect> {
self.cells.iter().find(|(cr, cc, _)| (*cr, *cc) == (r, c)).map(|x| x.2)
}
/// The visible cell at table-local point `p` (points outside the table go to the nearest cell).
pub fn cell_at(&self, p: Point) -> Option<(usize, usize)> {
let q = Point::new(p.x.clamp(0.0, self.width().max(0.0)), p.y.clamp(0.0, self.height().max(0.0)));
let inside = |r: &Rect| q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1;
// Edges are shared: prefer the cell the point is strictly inside.
self.cells
.iter()
.find(|(_, _, r)| q.x >= r.x0 && q.x < r.x1 && q.y >= r.y0 && q.y < r.y1)
.or_else(|| self.cells.iter().rev().find(|(_, _, r)| inside(r)))
.map(|(r, c, _)| (*r, *c))
}
/// The draggable border within `tol` of table-local point `p`: the inner borders and the right
/// and bottom edges (the left and top edges move the table). Borders hidden inside merged
/// cells don't count.
pub fn border_at(&self, p: Point, tol: f64) -> Option<TableBorder> {
let (w, h) = (self.width(), self.height());
if !(p.x >= -tol && p.y >= -tol && p.x <= w + tol && p.y <= h + tol) {
return None;
}
let q = Point::new(p.x.clamp(0.0, w), p.y.clamp(0.0, h));
let mut best: Option<(f64, TableBorder)> = None;
let mut x = 0.0;
for (i, cw) in self.cols.iter().enumerate() {
x += cw.max(0.0);
let d = (p.x - x).abs();
// A cell ending at this edge at this height shows the border.
let shown = self.cells.iter().any(|(_, _, r)| (r.x1 - x).abs() < 1e-6 && q.y >= r.y0 && q.y <= r.y1);
if d <= tol && shown && best.is_none_or(|b| d < b.0) {
best = Some((d, TableBorder::Column(i)));
}
}
let mut y = 0.0;
for (i, rh) in self.heights.iter().enumerate() {
y += rh.max(0.0);
let d = (p.y - y).abs();
let shown = self.cells.iter().any(|(_, _, r)| (r.y1 - y).abs() < 1e-6 && q.x >= r.x0 && q.x <= r.x1);
if d <= tol && shown && best.is_none_or(|b| d < b.0) {
best = Some((d, TableBorder::Row(i)));
}
}
best.map(|b| b.1)
}
}
fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> {
let a = p.get(key)?.as_array()?;
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
@@ -418,9 +533,27 @@ fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
let (_, c) = at(s, p);
let w = f64_param(p, "width").ok_or_else(|| bad("table.columnWidth", "missing `width`"))?.clamp(4.0, 4000.0);
let keep_total = bool_or(p, "keepTotal", false);
with_table(s, p, "table.columnWidth", move |t| {
if let Some(x) = t.cols.get_mut(c) {
*x = w;
// Keeping the table's width, the column to the right gives or takes the difference (dragging
// an inner border). The last column has none: the table gets wider or narrower.
let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
match pair {
Some(pair) if keep_total => {
let min = MIN_COLUMN_WIDTH.min(pair / 2.0);
let w = w.max(min).min(pair - min);
if let Some(x) = t.cols.get_mut(c) {
*x = w;
}
if let Some(x) = t.cols.get_mut(c + 1) {
*x = pair - w;
}
}
_ => {
if let Some(x) = t.cols.get_mut(c) {
*x = w;
}
}
}
Ok(())
})
+129 -14
View File
@@ -2,10 +2,11 @@
use std::sync::Arc;
use deckcraft_geom::{Affine, Point, Rect};
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::{Presentation, ShapeId, ShapeKind};
use deckcraft_model::text::{Align, AutoFit, Hyperlink, Paragraph, Run, RunKind, RunProps, TextBody};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use serde_json::{Value, json};
use super::*;
@@ -114,7 +115,7 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti
/// Edit the active text body and selection; refits auto-sizing text boxes afterwards.
pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result<T> {
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", "no text insertion point"))?;
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?;
// Materialise an inherited placeholder box before editing (so autofit can change it).
let r = s.edit(|doc, sel| {
let mut t = t0.clone();
@@ -183,20 +184,44 @@ pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option<Ctx<
/// Lay out the text being edited (for vertical caret movement and hit testing).
pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayout> {
if t.notes || t.cell.is_some() {
text_frame(st, t).map(|f| f.0)
}
/// The text being edited as it is drawn: its layout, and the transform from layout space to the
/// slide (flips don't mirror text, vertical text is turned). Shapes and table cells alike; the
/// caret, the text selection, hit testing and caret movement all use it. `None` for notes.
pub fn text_frame(st: &DocState, t: &TextSel) -> Option<(deckcraft_text::TextLayout, Affine)> {
if t.notes {
return None;
}
let sh = st.shape(t.shape)?;
let body = sh.text.as_ref()?;
let ctx = ctx_for(&st.doc, &st.selection)?;
let lay = |s: &Shape, body: &TextBody, rect: Rect| {
deckcraft_text::layout(&ctx, s, body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false })
};
if let Some((r, c)) = t.cell {
// A cell's text is laid out in the cell's rect with the cell's margins, anchor and the
// table style's text look, exactly as the table draws it.
let placed = cell_on_slide(st, t.shape, r, c)?;
return Some((placed.layout, placed.to_slide));
}
let body = sh.text.as_ref()?;
let x = xfrm_of(&st.doc, &st.selection, sh);
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
Some(deckcraft_text::layout(
&ctx,
sh,
body,
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
))
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
let l = lay(sh, body, tr);
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
Some((l, m))
}
/// The text position under slide point `p` in the text being edited (its shape or cell).
pub fn text_pos_at(st: &DocState, t: &TextSel, p: Point) -> Pos {
match text_frame(st, t) {
Some((l, m)) => {
let h = l.hit(m.inverse() * p);
(h.para, h.ch)
}
None => (0, 0),
}
}
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
@@ -234,6 +259,22 @@ pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point,
Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot)))
}
/// The colour of text typed at the caret. The canvas draws the caret in it, so the caret shows
/// wherever the text does: light on a dark slide, dark on a light one. `None` when that text
/// has no fill to take a colour from.
pub fn caret_color(st: &DocState, t: &TextSel) -> Option<deckcraft_color::Rgba> {
if t.notes || t.cell.is_some() {
return None;
}
let sh = st.shape(t.shape)?;
let body = sh.text.as_ref()?;
let ctx = ctx_for(&st.doc, &st.selection)?;
let blank = Paragraph::default();
let para = body.paragraphs.get(t.caret.0).unwrap_or(&blank);
let props = resolve::run(&ctx, sh, para, &ed::props_at(body, t.caret));
Some(resolve::text_color(&ctx, &props)).filter(|c| c.a > 0)
}
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
let a = p.get(key)?.as_array()?;
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
@@ -305,6 +346,7 @@ fn typed(s: &Session, body: &TextBody, at: Pos, text: &str) -> String {
}
pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>) -> Result<Value> {
let inherited = inherited_aligns_for_edit(s);
let text = {
let st = s.doc()?;
match &st.selection.text {
@@ -322,12 +364,53 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
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, &inherited);
}
}
c
})?;
autocorrect(s, text.chars().count() == 1)?;
Ok(json!({"caret": [pos.0, pos.1]}))
}
/// Per outline level (0..=8), whether shape `id` on the current slide already resolves an
/// alignment for its paragraphs (its own list style, the layout's or the master's). All `false`
/// when the shape can't be resolved (notes, a shape that is gone).
fn inherited_aligns(s: &Session, id: ShapeId) -> [bool; 9] {
let mut out = [false; 9];
let Ok(st) = s.doc() else { return out };
let Some(sh) = st.shape(id) else { return out };
let Some(ctx) = ctx_for(&st.doc, &st.selection) else { return out };
for (lvl, set) in out.iter_mut().enumerate() {
let para = deckcraft_model::text::Paragraph { level: lvl as u8, ..Default::default() };
*set = resolve::para(&ctx, sh, &para).align.is_some();
}
out
}
/// The aligns [`inherited_aligns`] gives for the text being edited (none for notes).
fn inherited_aligns_for_edit(s: &Session) -> [bool; 9] {
match s.doc().ok().and_then(|st| st.selection.text.clone()) {
Some(t) if !t.notes => inherited_aligns(s, t.shape),
_ => [false; 9],
}
}
/// A paragraph with no direction of its own whose first strong character is right-to-left
/// (Arabic, Hebrew, Kurdish…) becomes right-to-left, and right-aligned when nothing (the
/// paragraph or what it inherits, `inherited`) sets its alignment.
fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph, inherited: &[bool; 9]) {
if para.props.rtl.is_none() && deckcraft_model::bidi::first_strong_is_rtl(&para.text()) {
para.props.rtl = Some(true);
let inherits_align = inherited.get(usize::from(para.level)).copied().unwrap_or(false);
if para.props.align.is_none() && !inherits_align {
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<RunProps> {
@@ -598,12 +681,18 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result<Value> {
let inherited = inherited_aligns_for_edit(s);
let pos = edit_text(s, |body, t| {
let (a, b) = t.ordered();
let at = ed::delete(body, a, b);
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, &inherited);
}
}
c
})?;
Ok(json!({"caret": [pos.0, pos.1]}))
@@ -1037,6 +1126,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?,
};
let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)));
let inherited = inherited_aligns(s, id);
edit_shapes(s, &json!({"id": id}), "text.set", |sh| {
let target = match (&mut sh.kind, cell) {
(ShapeKind::Table(t), Some((r, c))) => t.cell_mut(r, c).map(|x| &mut x.text),
@@ -1055,6 +1145,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
para.level = *lv;
para.props = pp.clone();
}
promote_rtl_if_needed(para, &inherited);
for r in &mut para.runs {
r.props = keep.clone();
}
@@ -1104,7 +1195,7 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
}
let ids = st.selection.shapes.clone();
if ids.is_empty() {
return Err(bad("format", "select text or a shape"));
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
}
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
match &mut sh.kind {
@@ -1194,7 +1285,7 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
}
let ids = st.selection.shapes.clone();
if ids.is_empty() {
return Err(bad("format", "select text or a shape"));
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
}
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
match &mut sh.kind {
@@ -1264,6 +1355,30 @@ fn replace_font(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"replaced": n}))
}
#[cfg(test)]
mod rtl_promote_tests {
use deckcraft_model::text::{Align, Paragraph, Run};
use super::promote_rtl_if_needed;
fn para(text: &str) -> Paragraph {
Paragraph { runs: vec![Run::new(text)], ..Default::default() }
}
#[test]
fn right_aligns_only_when_no_alignment_is_set_or_inherited() {
let mut p = para("مرحبا");
promote_rtl_if_needed(&mut p, &[false; 9]);
assert_eq!((p.props.rtl, p.props.align), (Some(true), Some(Align::Right)));
let mut p = para("مرحبا");
promote_rtl_if_needed(&mut p, &[true; 9]);
assert_eq!((p.props.rtl, p.props.align), (Some(true), None));
let mut p = para("Hello مرحبا");
promote_rtl_if_needed(&mut p, &[false; 9]);
assert_eq!((p.props.rtl, p.props.align), (None, None));
}
}
#[cfg(test)]
mod replace_font_tests {
use serde_json::json;
+1 -1
View File
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Sound",
["Transitions", "Timing"],
None,
"{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?; none?: true (No Sound); stopPrevious?: true; loop?: bool (Loop Until Next Sound); index?} — the sound plays as the slide comes in",
"{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?, none?: true (No Sound), stopPrevious?: true, loop?: bool (Loop Until Next Sound), index?} — the sound plays as the slide comes in",
has_slide,
sound
),
+483
View File
@@ -0,0 +1,483 @@
//! The folders automation may read and write.
//!
//! `deckcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command
//! reads goes through [`check_read`] and every path it writes through [`check_write`]:
//!
//! - The path is made absolute against the working directory, as the file system reads it. Its
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere).
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! - Read and write access are separate: a root given alone grants only its own kind of access.
//!
//! Roots are in force on a thread inside [`confine`], and in the whole process after
//! [`confine_process`] (`deckcraft-cli mcp`), except inside [`unconfined`]. Without roots
//! nothing is checked.
//!
//! A path is checked, then opened: a process that can change the folders in between (swapping in
//! a link) can still win that race. The roots keep an agent to the files it was given; they don't
//! guard against other programs on the computer.
use std::cell::RefCell;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, OnceLock};
/// Read access or write access.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Access {
Read,
Write,
}
/// The folders automation may read and write; a root left out grants no access of its kind.
#[derive(Debug)]
pub struct AutomationRoots {
read: Option<Root>,
write: Option<Root>,
}
#[derive(Debug)]
struct Root {
/// The folder made absolute, links followed: what paths are compared with.
resolved: PathBuf,
/// The same without Windows' `\\?\` prefix, for people.
shown: PathBuf,
}
impl Root {
/// The folder at `path`, which must exist.
fn open(path: &Path, access: Access) -> Result<Self, String> {
let kind = match access {
Access::Read => "read",
Access::Write => "write",
};
let resolved = std::fs::canonicalize(path)
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
Ok(Self { shown: plain(resolved.clone()), resolved })
}
}
impl AutomationRoots {
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
/// (nothing is confined then).
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
if read.is_none() && write.is_none() {
return Ok(None);
}
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
Ok(Some(Arc::new(Self { read, write })))
}
/// The read root, absolute with its links followed.
pub fn read_root(&self) -> Option<&Path> {
self.read.as_ref().map(|r| r.shown.as_path())
}
/// The write root, absolute with its links followed.
pub fn write_root(&self) -> Option<&Path> {
self.write.as_ref().map(|r| r.shown.as_path())
}
/// May the file or folder at `path` be read (or written)? `Err` says why not.
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
let root = match access {
Access::Read => self.read.as_ref(),
Access::Write => self.write.as_ref(),
};
let Some(Root { resolved: folder, shown }) = root else {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
};
};
if inside(&resolve(path)?, folder) {
return Ok(());
}
let root = shown.display();
match access {
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),
}
}
}
/// Where the roots are in force on a thread ([`confine`], [`unconfined`]).
#[derive(Clone, Debug)]
enum Scope {
Confined(Arc<AutomationRoots>),
Unconfined,
}
thread_local! {
/// This thread's scope; `None`: the process's roots ([`confine_process`]).
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// The roots in force in every thread outside [`confine`] and [`unconfined`].
static PROCESS: OnceLock<Arc<AutomationRoots>> = OnceLock::new();
/// Put `roots` in force in the whole process, for good. `false` when roots were in force already
/// (they stay as they were).
pub fn confine_process(roots: Arc<AutomationRoots>) -> bool {
PROCESS.set(roots).is_ok()
}
/// Puts the scope it replaced back when dropped (also when `f` panics).
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let previous = self.0.take();
SCOPE.with(|s| *s.borrow_mut() = previous);
}
}
fn with_scope<T>(scope: Option<Scope>, f: impl FnOnce() -> T) -> T {
let _restore = Restore(SCOPE.with(|s| s.replace(scope)));
f()
}
/// Run `f` with `roots` in force on this thread (`None`: as things stand).
pub fn confine<T>(roots: Option<&Arc<AutomationRoots>>, f: impl FnOnce() -> T) -> T {
match roots {
Some(r) => with_scope(Some(Scope::Confined(r.clone())), f),
None => f(),
}
}
/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the
/// app's own folders.
pub fn unconfined<T>(f: impl FnOnce() -> T) -> T {
with_scope(Some(Scope::Unconfined), f)
}
/// The roots in force on this thread, if any.
pub fn current() -> Option<Arc<AutomationRoots>> {
match SCOPE.with(|s| s.borrow().clone()) {
Some(Scope::Confined(r)) => Some(r),
Some(Scope::Unconfined) => None,
None => PROCESS.get().cloned(),
}
}
/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read.
fn plain(path: PathBuf) -> PathBuf {
if !cfg!(windows) {
return path;
}
let text = path.to_string_lossy();
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
match text.strip_prefix(r"\\?\") {
Some(rest) => PathBuf::from(rest),
None => path,
}
}
/// May `path` be read here? Always, without roots in force.
pub fn check_read(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Read))
}
/// May `path` be written here? Always, without roots in force.
pub fn check_write(path: &str) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path, Access::Write))
}
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
/// existing folder's links followed and the rest appended.
fn resolve(path: &str) -> Result<PathBuf, String> {
if path.is_empty() {
return Err("automation path rejected: path is empty".into());
}
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
}
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute.as_path();
loop {
match std::fs::canonicalize(at) {
Ok(mut found) => {
found.extend(missing.iter().rev());
return Ok(found);
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
},
}
let parent = at.parent();
match (at.components().next_back(), parent) {
(Some(Component::Normal(name)), Some(parent)) => {
missing.push(name.to_os_string());
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
}
}
}
}
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
/// Windows, whose file systems ignore it)?
fn inside(path: &Path, root: &Path) -> bool {
let mut path = path.components();
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
}
fn same_component(a: Component, b: Component) -> bool {
if a == b {
return true;
}
if !cfg!(windows) {
return false;
}
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
_ => false,
}
}
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
fn is_device_name(component: &str) -> bool {
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
}
#[cfg(test)]
mod tests {
use super::*;
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
let base = std::env::temp_dir().join(format!("dc-file-access-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["in/sub", "out", "outside", "in2"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
std::fs::write(base.join("in/a.deckcraft"), "x").unwrap();
std::fs::write(base.join("outside/secret.deckcraft"), "x").unwrap();
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
(base, roots)
}
fn s(p: PathBuf) -> String {
p.to_string_lossy().into_owned()
}
#[test]
fn paths_inside_the_roots_pass_and_others_dont() {
let (base, roots) = folders("inside");
assert_eq!(roots.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself");
assert_eq!(roots.check(&s(base.join("in/sub/new.deckcraft")), Access::Read), Ok(()), "missing files are checked by their folder");
assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder");
let e = roots.check(&s(base.join("outside/secret.deckcraft")), Access::Read).unwrap_err();
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
// Read and write access are separate.
let e = roots.check(&s(base.join("in/a.deckcraft")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "{e}");
assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_sibling_with_the_same_prefix_is_outside() {
let (base, roots) = folders("prefix");
std::fs::write(base.join("in2/b.deckcraft"), "x").unwrap();
assert!(roots.check(&s(base.join("in2/b.deckcraft")), Access::Read).is_err(), "in2 is not inside in");
assert!(roots.check(&format!("{}2/c.deckcraft", s(base.join("in"))), Access::Read).is_err());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn parent_traversal_is_resolved_or_refused() {
let (base, roots) = folders("dotdot");
let sep = std::path::MAIN_SEPARATOR;
let up = format!("{}{sep}..{sep}outside{sep}secret.deckcraft", s(base.join("in")));
assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
let back = format!("{}{sep}..{sep}a.deckcraft", s(base.join("in/sub")));
assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.deckcraft", s(base.join("out")));
let e = roots.check(&missing, Access::Write).unwrap_err();
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn relative_paths_resolve_against_the_working_directory() {
let (base, roots) = folders("relative");
// The tests' working directory is the crate's folder: not inside the roots.
assert!(roots.check("a.deckcraft", Access::Read).unwrap_err().contains("outside the read root"));
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
let (base, _) = folders("missing");
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let e = AutomationRoots::new(None, Some(&base.join("in/a.deckcraft"))).unwrap_err();
assert!(
e.starts_with("cannot open the automation write root")
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
"{e}"
);
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(None, None).unwrap().is_none());
// One root alone grants only its own access.
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
let e = read_only.check(&s(base.join("in/x.deckcraft")), Access::Write).unwrap_err();
assert!(e.contains("write authority is absent"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn links_are_followed_before_comparing() {
let (base, roots) = folders("links");
#[cfg(unix)]
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
#[cfg(windows)]
let made = |target: &Path, link: &Path| {
let made =
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
made.is_ok()
};
#[cfg(not(any(unix, windows)))]
let made = |_: &Path, _: &Path| false;
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
if !made(&base.join("outside/secret.deckcraft"), &base.join("in/leak.deckcraft")) {
eprintln!("skipped: symbolic links can't be made here");
let _ = std::fs::remove_dir_all(base);
return;
}
assert!(roots.check(&s(base.join("in/leak.deckcraft")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
assert!(made(&base.join("outside"), &base.join("out/away")));
let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "a folder link out: {e}");
assert!(made(&base.join("outside/gone.deckcraft"), &base.join("out/dangling.png")));
let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err();
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
// A link to a folder inside the root stays inside.
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
assert_eq!(roots.check(&s(base.join("in/alias/x.deckcraft")), Access::Read), Ok(()));
// A root given through a link is the folder it leads to.
assert!(made(&base.join("in"), &base.join("in-link")));
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
assert_eq!(linked.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[cfg(windows)]
#[test]
fn windows_ignores_case_and_refuses_devices() {
let (base, roots) = folders("case");
let upper = s(base.join("IN/a.deckcraft"));
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
assert_eq!(roots.check(&s(base.join("In/Sub/New.deckcraft")), Access::Read), Ok(()));
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
assert!(e.contains("reserved device names"), "{e}");
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn scopes_nest_and_stay_on_their_thread() {
let (base, roots) = folders("scopes");
let outside = s(base.join("outside/secret.deckcraft"));
assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default");
confine(Some(&roots), || {
assert!(check_read(&outside).is_err());
assert_eq!(unconfined(|| check_read(&outside)), Ok(()));
assert!(check_read(&outside).is_err(), "restored after unconfined");
let o = outside.clone();
let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap();
assert!(there, "another thread is not confined by this thread's scope");
});
assert_eq!(check_read(&outside), Ok(()), "restored after confine");
let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom")));
assert!(r.is_err());
assert_eq!(check_read(&outside), Ok(()), "restored after a panic");
assert_eq!(confine(None, || check_read(&outside)), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn device_names() {
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.deckcraft", "NUL "] {
assert!(is_device_name(d), "{d}");
}
for f in ["console", "com10", "COM0", "lpt", "a.deckcraft", "nul-x"] {
assert!(!is_device_name(f), "{f}");
}
}
/// Every file a command reads or writes by path goes through `io` (`read_file`,
/// `write_file`), which check the roots. Engine code that reaches for `std::fs` itself would
/// bypass them, so none may: every file under `src/` (commands, `lib.rs`, `guard.rs`...)
/// except `io.rs`, this module, `recovery.rs` (the recovery folder is set by the host
/// application, never by a command's path, and a headless session has none) and the test
/// files.
#[test]
fn commands_do_not_touch_the_file_system_themselves() {
fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
walk(&path, files);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = vec![];
walk(&src, &mut files);
assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}");
let mut offenders = vec![];
for path in files {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
// `io` and this module are the checked gateway; `tests*.rs` are test-only modules.
// `recovery` and `spell` touch only the app's own folders (AutoRecover files, the
// system and user spelling dictionaries), never a path a command names.
if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs" | "recovery.rs" | "spell.rs")
|| name.starts_with("tests")
{
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
// Test modules (which build their own fixtures) come last in each file.
let code = text.split("#[cfg(test)]").next().unwrap_or("");
for (n, line) in code.lines().enumerate() {
let line = line.trim_start();
let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w));
if !line.starts_with("//") && touches {
offenders.push(format!("{}:{}: {line}", path.display(), n + 1));
}
}
}
assert!(offenders.is_empty(), "use crate::io::{{read_file, write_file}}: {offenders:#?}");
}
}
+421
View File
@@ -0,0 +1,421 @@
//! Animated GIF of the deck (the Export… dialog; `file.export` with format `gif`).
//!
//! One frame per slide, drawn by the slide renderer and shown for `secondsPerSlide`. Every frame
//! gets its own 256-colour palette (exact when the slide has 256 colours or fewer, NeuQuant
//! otherwise), so a photo on one slide doesn't steal colours from the next. Transitions and
//! build animations are not animated: a slide appears as it looks when its builds have finished.
//!
//! All parameters are hostile: sizes, delays and slide lists are checked against fixed bounds, and
//! the total number of pixels encoded is capped, so a request can't exhaust memory or run for
//! hours.
use deckcraft_model::Presentation;
use serde_json::Value;
use crate::cmd::bad;
use crate::{EngineError, Result};
const CMD: &str = "file.export";
/// Size presets, by frame width in pixels (16:9 slides: 640×360, 960×540, 1280×720, 1920×1080).
pub const SIZE_PRESETS: [(&str, u32); 4] = [("small", 640), ("medium", 960), ("large", 1280), ("extraLarge", 1920)];
/// Frame width when none is given (the `medium` preset).
pub const DEFAULT_WIDTH: u32 = 960;
pub const MIN_WIDTH: u32 = 16;
pub const MAX_WIDTH: u32 = 3840;
/// Longest frame side: a very tall slide is scaled down to fit.
pub const MAX_SIDE: u32 = 3840;
pub const DEFAULT_SECONDS: f64 = 5.0;
/// GIF delays are in hundredths of a second; viewers treat delays under 0.02 s as 0.1 s anyway.
pub const MIN_SECONDS: f64 = 0.1;
pub const MAX_SECONDS: f64 = 600.0;
pub const MAX_FRAMES: usize = 500;
/// Cap on frames × width × height (≈ 96 Full HD frames or 480 frames at 960×540).
pub const MAX_TOTAL_PIXELS: u64 = 200_000_000;
/// NeuQuant sampling: 1 is slowest and best, 30 fastest; 10 is the usual balance.
const QUANTIZER_SPEED: i32 = 10;
/// What to export.
#[derive(Clone, Debug, PartialEq)]
pub struct GifOptions {
/// Frame width in pixels; the height follows the slide's aspect ratio.
pub width: u32,
pub seconds_per_slide: f64,
/// Loop forever (`true`) or play once.
pub loop_forever: bool,
/// Slide indices in play order (default: all).
pub slides: Option<Vec<usize>>,
/// Hidden slides are left out unless this is set.
pub include_hidden: bool,
}
impl Default for GifOptions {
fn default() -> Self {
GifOptions { width: DEFAULT_WIDTH, seconds_per_slide: DEFAULT_SECONDS, loop_forever: true, slides: None, include_hidden: false }
}
}
/// An encoded GIF and what is in it.
#[derive(Clone, Debug)]
pub struct GifOutput {
pub bytes: Vec<u8>,
pub frames: usize,
pub width: u32,
pub height: u32,
/// Per-frame delay in hundredths of a second, as written.
pub delay_cs: u16,
}
fn width_value(v: &Value) -> Result<u32> {
let err =
|| bad(CMD, format!("`width` must be a whole number of pixels from {MIN_WIDTH} to {MAX_WIDTH}, or one of small, medium, large, extraLarge"));
match v {
Value::String(s) => SIZE_PRESETS.iter().find(|(k, _)| k.eq_ignore_ascii_case(s.trim())).map(|(_, w)| *w).ok_or_else(err),
Value::Number(n) => n.as_u64().and_then(|w| u32::try_from(w).ok()).filter(|w| (MIN_WIDTH..=MAX_WIDTH).contains(w)).ok_or_else(err),
_ => Err(err()),
}
}
/// Check a per-slide duration (NaN, infinities and out-of-range values are errors).
pub fn check_seconds(s: f64) -> Result<f64> {
if (MIN_SECONDS..=MAX_SECONDS).contains(&s) {
Ok(s)
} else {
Err(bad(CMD, format!("`secondsPerSlide` must be from {MIN_SECONDS} to {MAX_SECONDS}")))
}
}
/// Present and not `null`.
fn given<'a>(p: &'a Value, key: &str) -> Option<&'a Value> {
p.get(key).filter(|v| !v.is_null())
}
impl GifOptions {
/// Read `{width?: px | small|medium|large|extraLarge, secondsPerSlide?, loop?: bool |
/// forever|once, slides?: [index], includeHidden?}`. Absent or `null` values take their
/// defaults; anything else that is out of range or of the wrong type is an error.
pub fn from_params(p: &Value) -> Result<Self> {
let mut o = GifOptions::default();
if let Some(v) = given(p, "width") {
o.width = width_value(v)?;
}
if let Some(v) = given(p, "secondsPerSlide") {
let s = v.as_f64().ok_or_else(|| bad(CMD, "`secondsPerSlide` must be a number"))?;
o.seconds_per_slide = check_seconds(s)?;
}
if let Some(v) = given(p, "loop") {
o.loop_forever = match v {
Value::Bool(b) => *b,
Value::String(s) if s.eq_ignore_ascii_case("forever") => true,
Value::String(s) if s.eq_ignore_ascii_case("once") => false,
_ => return Err(bad(CMD, "`loop` must be true (forever), false (once), \"forever\" or \"once\"")),
};
}
if let Some(v) = given(p, "slides") {
let a = v.as_array().ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices"))?;
if a.is_empty() {
return Err(bad(CMD, "`slides` is empty"));
}
if a.len() > MAX_FRAMES {
return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides")));
}
let list = a
.iter()
.map(|x| x.as_u64().and_then(|i| usize::try_from(i).ok()))
.collect::<Option<Vec<usize>>>()
.ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices (0, 1, 2…)"))?;
o.slides = Some(list);
}
if let Some(v) = given(p, "includeHidden") {
o.include_hidden = v.as_bool().ok_or_else(|| bad(CMD, "`includeHidden` must be true or false"))?;
}
Ok(o)
}
/// The slides that become frames, in order: the chosen ones (or all), without hidden slides
/// unless `include_hidden`.
pub fn frames(&self, doc: &Presentation) -> Result<Vec<usize>> {
let n = doc.slides.len();
let chosen: Vec<usize> = match &self.slides {
Some(list) => {
if let Some(i) = list.iter().find(|i| **i >= n) {
return Err(bad(CMD, format!("no slide {i} (the presentation has {n})")));
}
list.clone()
}
None => (0..n).collect(),
};
let frames: Vec<usize> = chosen.into_iter().filter(|i| self.include_hidden || doc.slides.get(*i).is_some_and(|s| !s.hidden)).collect();
if frames.is_empty() {
return Err(bad(CMD, "no slides to export (they are all hidden, or the presentation is empty)"));
}
if frames.len() > MAX_FRAMES {
return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides; choose a range")));
}
Ok(frames)
}
/// Delay per frame in hundredths of a second.
pub fn delay_cs(&self) -> Result<u16> {
let s = check_seconds(self.seconds_per_slide)?;
// In range by `check_seconds`: 10..=60_000.
Ok((s * 100.0).round().clamp(1.0, f64::from(u16::MAX)) as u16)
}
}
/// Frame size for a slide of `doc`'s size at `width` px: the height follows the aspect ratio and
/// no side exceeds [`MAX_SIDE`].
pub fn frame_size(doc: &Presentation, width: u32) -> (u32, u32) {
let (sw, sh) = (doc.slide_size.width, doc.slide_size.height);
let aspect = if sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0 { sh / sw } else { 9.0 / 16.0 };
let w = f64::from(width.clamp(MIN_WIDTH, MAX_WIDTH));
let mut h = w * aspect;
let mut w2 = w;
if h > f64::from(MAX_SIDE) {
w2 = w * f64::from(MAX_SIDE) / h;
h = f64::from(MAX_SIDE);
}
let side = |v: f64| if v.is_finite() { v.round().clamp(1.0, f64::from(MAX_SIDE)) as u32 } else { 1 };
(side(w2), side(h))
}
/// Straight RGBA of a premultiplied render composited onto white (GIF has no partial alpha, and
/// fully transparent pixels would otherwise become see-through).
fn on_white(img: &deckcraft_render::Image, w: u32, h: u32) -> Vec<u8> {
let len = (w as usize).saturating_mul(h as usize).saturating_mul(4);
if img.width != w || img.height != h || img.pixels.len() != len {
log::error!("GIF export: a slide rendered at {}×{} instead of {w}×{h}; writing a blank frame", img.width, img.height);
return vec![255; len];
}
let mut px = img.pixels.clone();
for p in px.as_chunks_mut::<4>().0 {
let back = 255 - p[3];
p[0] = p[0].saturating_add(back);
p[1] = p[1].saturating_add(back);
p[2] = p[2].saturating_add(back);
p[3] = 255;
}
px
}
/// Render and encode the GIF.
pub fn encode(doc: &Presentation, opts: &GifOptions) -> Result<GifOutput> {
use image::codecs::gif::{GifEncoder, Repeat};
let frames = opts.frames(doc)?;
let delay_cs = opts.delay_cs()?;
let (w, h) = frame_size(doc, opts.width);
let total = (frames.len() as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h));
if total > MAX_TOTAL_PIXELS {
return Err(bad(CMD, format!("{} slides at {w}×{h} is too large for a GIF; choose a smaller size or fewer slides", frames.len())));
}
let scale = f64::from(w) / doc.slide_size.width;
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let enc_err = |e: image::ImageError| EngineError::Other(format!("GIF encoding failed: {e}"));
let mut bytes = Vec::new();
{
let mut enc = GifEncoder::new_with_speed(&mut bytes, QUANTIZER_SPEED);
// No repeat block at all means "play once"; a repeat count of 0 means "forever".
if opts.loop_forever {
enc.set_repeat(Repeat::Infinite).map_err(enc_err)?;
}
for i in &frames {
let img = deckcraft_render::render_slide(doc, *i, &deckcraft_render::RenderOpts { scale, size: Some((w, h)), ..Default::default() });
let buffer = image::RgbaImage::from_raw(w, h, on_white(&img, w, h))
.ok_or_else(|| EngineError::Other(format!("GIF export: slide {i} has no pixels")))?;
let delay = image::Delay::from_numer_denom_ms(u32::from(delay_cs) * 10, 1);
enc.encode_frame(image::Frame::from_parts(buffer, 0, 0, delay)).map_err(enc_err)?;
}
}
Ok(GifOutput { bytes, frames: frames.len(), width: w, height: h, delay_cs })
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use image::AnimationDecoder;
use serde_json::json;
use super::*;
use crate::Session;
const COLOURS: [(&str, [u8; 3]); 4] =
[("#E02020", [0xE0, 0x20, 0x20]), ("#20A040", [0x20, 0xA0, 0x40]), ("#2040E0", [0x20, 0x40, 0xE0]), ("#F0C000", [0xF0, 0xC0, 0x00])];
/// Four blank slides with solid backgrounds (red, green, blue, yellow); slide 2 (blue) hidden.
fn deck() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"blank": true})).unwrap();
while s.doc().unwrap().doc.slides.len() < COLOURS.len() {
s.execute("slide.new", &json!({})).unwrap();
}
for (i, (hex, _)) in COLOURS.iter().enumerate() {
s.execute("design.background", &json!({"color": hex, "index": i})).unwrap();
}
s.execute("slide.hide", &json!({"index": 2, "hidden": true})).unwrap();
s
}
fn temp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-gif-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
fn decode(bytes: &[u8]) -> (image::metadata::LoopCount, Vec<image::Frame>) {
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).unwrap();
let count = dec.loop_count();
(count, dec.into_frames().collect_frames().unwrap())
}
/// The NETSCAPE2.0 application block's loop count, if the GIF has one.
fn netscape_loops(bytes: &[u8]) -> Option<u16> {
let tag = b"NETSCAPE2.0";
let at = bytes.windows(tag.len()).position(|w| w == tag)?;
// NETSCAPE2.0, sub-block size 3, id 1, loop count (little endian).
let b = bytes.get(at + tag.len()..at + tag.len() + 4)?;
Some(u16::from_le_bytes([b[2], b[3]]))
}
fn close(a: [u8; 4], b: [u8; 3]) -> bool {
(0..3).all(|k| (i32::from(a[k]) - i32::from(b[k])).abs() <= 12) && a[3] == 255
}
#[test]
fn exports_one_frame_per_visible_slide() {
let mut s = deck();
let path = temp("deck.gif");
let r = s.execute("file.export", &json!({"path": path.to_string_lossy(), "width": 320, "secondsPerSlide": 1.5})).unwrap();
assert_eq!(r["frames"], 3, "{r}");
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"GIF89a"));
assert_eq!(netscape_loops(&bytes), Some(0), "loops forever by default");
let (count, frames) = decode(&bytes);
assert!(matches!(count, image::metadata::LoopCount::Infinite));
assert_eq!(frames.len(), 3, "the hidden slide is skipped");
// Slides are 16:9: 320×180.
let expected = [COLOURS[0].1, COLOURS[1].1, COLOURS[3].1];
for (f, want) in frames.iter().zip(expected) {
assert_eq!((f.buffer().width(), f.buffer().height()), (320, 180));
assert_eq!(f.delay().numer_denom_ms(), (1500, 1));
let px = f.buffer().get_pixel(160, 90).0;
assert!(close(px, want), "frame colour {px:?}, expected {want:?}");
}
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(320), Some(180)));
let _ = std::fs::remove_file(&path);
}
#[test]
fn presets_ranges_hidden_slides_and_play_once() {
let s = deck();
let doc = &s.doc().unwrap().doc;
let o = GifOptions::from_params(&json!({"width": "small", "loop": false, "slides": [3, 2, 1], "includeHidden": true})).unwrap();
let out = encode(doc, &o).unwrap();
assert_eq!((out.frames, out.width, out.height, out.delay_cs), (3, 640, 360, 500));
assert_eq!(netscape_loops(&out.bytes), None, "play once: no repeat block");
let (_, frames) = decode(&out.bytes);
let want = [COLOURS[3].1, COLOURS[2].1, COLOURS[1].1];
for (f, w) in frames.iter().zip(want) {
assert!(close(f.buffer().get_pixel(320, 180).0, w));
assert_eq!(f.delay().numer_denom_ms(), (5000, 1), "5 seconds by default");
}
// A range that holds only the hidden slide has nothing to export.
let o = GifOptions::from_params(&json!({"slides": [2]})).unwrap();
assert!(encode(doc, &o).is_err());
for (preset, w) in SIZE_PRESETS {
assert_eq!(GifOptions::from_params(&json!({"width": preset})).unwrap().width, w);
}
assert_eq!(
GifOptions::from_params(&json!({"width": "Large", "loop": "once"})).unwrap(),
GifOptions { width: 1280, loop_forever: false, ..Default::default() }
);
assert_eq!(GifOptions::from_params(&json!({"width": null, "secondsPerSlide": null})).unwrap(), GifOptions::default());
}
#[test]
fn hostile_params_are_errors_not_panics() {
for p in [
json!({"width": 0}),
json!({"width": 15}),
json!({"width": 3841}),
json!({"width": -5}),
json!({"width": 1e308}),
json!({"width": 640.5}),
json!({"width": "huge"}),
json!({"width": [640]}),
json!({"secondsPerSlide": 0}),
json!({"secondsPerSlide": 0.05}),
json!({"secondsPerSlide": -1}),
json!({"secondsPerSlide": 601}),
json!({"secondsPerSlide": 1e308}),
json!({"secondsPerSlide": "5"}),
json!({"loop": 1}),
json!({"loop": "twice"}),
json!({"slides": []}),
json!({"slides": [-1]}),
json!({"slides": [0.5]}),
json!({"slides": "1-3"}),
json!({"slides": vec![0; MAX_FRAMES + 1]}),
json!({"includeHidden": "yes"}),
] {
assert!(GifOptions::from_params(&p).is_err(), "{p}");
}
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MIN_POSITIVE, 1e-300] {
assert!(check_seconds(v).is_err(), "{v}");
let o = GifOptions { seconds_per_slide: v, ..Default::default() };
assert!(o.delay_cs().is_err(), "{v}");
}
assert_eq!(GifOptions { seconds_per_slide: 600.0, ..Default::default() }.delay_cs().unwrap(), 60_000);
assert_eq!(GifOptions { seconds_per_slide: 0.1, ..Default::default() }.delay_cs().unwrap(), 10);
// Through the command too: errors, the presentation untouched, no file written.
let mut s = deck();
let path = temp("hostile.gif");
for p in [
json!({"width": 99999}),
json!({"secondsPerSlide": -3}),
json!({"slides": [0, 99]}),
json!({"slides": [18446744073709551615u64]}),
json!({"slides": [2]}),
] {
let mut p = p;
p["path"] = json!(path.to_string_lossy());
assert!(s.execute("file.export", &p).is_err(), "{p}");
}
assert!(!path.exists());
assert_eq!(s.doc().unwrap().doc.slides.len(), 4);
}
#[test]
fn sizes_are_bounded() {
let mut s = deck();
// A very tall slide is scaled down so no side exceeds MAX_SIDE.
s.execute("design.slideSize", &json!({"w": 100.0, "h": 4000.0, "scale": "none"})).unwrap();
let doc = &s.doc().unwrap().doc;
let (w, h) = frame_size(doc, MAX_WIDTH);
assert!(w >= 1 && h <= MAX_SIDE && w <= MAX_WIDTH, "{w}×{h}");
// The total pixel budget stops huge requests before anything is rendered.
let mut big = Presentation::clone(doc);
big.slide_size = deckcraft_geom::Size::new(960.0, 540.0);
let slide = big.slides[0].clone();
big.slides = vec![slide; 200];
let o = GifOptions { width: MAX_WIDTH, ..Default::default() };
let e = encode(&big, &o).unwrap_err().to_string();
assert!(e.contains("too large"), "{e}");
// A degenerate slide size falls back to 16:9 rather than dividing by zero.
let mut odd = Presentation::clone(doc);
odd.slide_size = deckcraft_geom::Size::new(0.0, f64::NAN);
assert_eq!(frame_size(&odd, 640), (640, 360));
}
#[test]
fn gif_is_also_a_save_format_and_inferred_from_the_path() {
let s = deck();
let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "gif").unwrap();
assert!(bytes.starts_with(b"GIF89a"));
assert_eq!(crate::cmd::file::format_for_path("Talk.GIF"), "gif");
let bytes = crate::cmd::file::gif_bytes(&s.doc().unwrap().doc, &json!({"width": 64, "slides": [0]})).unwrap();
assert_eq!(decode(&bytes).1.len(), 1);
}
}
+69
View File
@@ -0,0 +1,69 @@
//! Reading and writing the files a command names by path.
//!
//! Every such read and write goes through [`read_file`] and [`write_file`], so the automation
//! roots ([`crate::file_access`]) are checked in one place. A command module that called
//! `std::fs` itself would bypass them; a test in `file_access` keeps that from happening.
/// The bytes of the file at `path`, inside the automation read root when there is one.
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
crate::file_access::check_read(path)?;
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
}
#[cfg(target_arch = "wasm32")]
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
Err(format!("{path}: files are opened through the browser on the web"))
}
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
///
/// The bytes go to a temporary sibling first and are renamed over `path`, so a failed save never
/// truncates the old file. The sibling is in the same folder, so it is inside the root whenever
/// `path` is.
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
crate::file_access::check_write(path)?;
let err = |e: std::io::Error| format!("{path}: {e}");
// A uniquely named temporary sibling, created exclusively (never following or reusing an
// existing file or symlink), is renamed over the destination: concurrent saves never share
// temporary bytes.
let mut attempts = 0;
let (tmp, mut file) = loop {
let tmp = format!("{path}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(f) => break (tmp, f),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
Err(e) => return Err(err(e)),
}
};
let written = file.write_all(bytes).and_then(|()| file.sync_all());
drop(file);
let result = written.and_then(|()| std::fs::rename(&tmp, path));
if let Err(e) = result {
let _ = std::fs::remove_file(&tmp);
return Err(err(e));
}
Ok(())
}
#[cfg(target_arch = "wasm32")]
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> {
Err(format!("{path}: saving to a path is not available in the browser"))
}
/// The size in bytes of the file at `path`; `None` when it can't be read (or is outside the
/// automation read root, so File › Info doesn't reveal files the agent may not read).
#[cfg(not(target_arch = "wasm32"))]
pub fn file_len(path: &str) -> Option<u64> {
crate::file_access::check_read(path).ok()?;
std::fs::metadata(path).ok().map(|m| m.len())
}
#[cfg(target_arch = "wasm32")]
pub fn file_len(_path: &str) -> Option<u64> {
None
}
+15 -8
View File
@@ -290,15 +290,22 @@ impl Session {
let Some(t) = st.selection.text.clone() else { return Ok(Value::Null) };
let Some((r, c)) = t.cell else { return Ok(Value::Null) };
let Some(deckcraft_model::ShapeKind::Table(tb)) = st.shape(t.shape).map(|s| s.kind.clone()) else { return Ok(Value::Null) };
let (nr, nc) = (tb.n_rows(), tb.n_cols());
let (nr, nc) = (tb.n_rows(), tb.n_cols().max(1));
let idx = r * nc + c;
if !back && idx + 1 >= nr * nc {
// Tab in the last cell adds a row.
self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": r}))?;
return self.execute("text.edit", &json!({"id": t.shape, "cell": [r + 1, 0]}));
}
let ni = if back { idx.saturating_sub(1) } else { idx + 1 };
self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc.max(1), ni % nc.max(1)]}))?;
// Cells hidden under a merged cell are skipped.
let visible = |i: &usize| tb.cell(i / nc, i % nc).is_some_and(|x| !x.is_covered());
let next = if back { (0..idx).rev().find(visible) } else { (idx + 1..nr * nc).find(visible) };
let ni = match next {
Some(ni) => ni,
None if !back => {
// Tab in the last cell adds a row.
let last = nr.saturating_sub(1);
self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": last}))?;
return self.execute("text.edit", &json!({"id": t.shape, "cell": [last + 1, 0]}));
}
None => idx,
};
self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc, ni % nc]}))?;
self.execute("text.selectAll", &json!({}))
}
}
+3
View File
@@ -11,10 +11,13 @@ pub mod cmd;
pub mod compare;
pub mod connect;
pub mod drawtable;
pub mod file_access;
pub mod fonts;
pub mod gif_export;
pub mod guard;
pub mod icons;
pub mod inkshape;
pub mod io;
pub mod keys;
pub mod links;
pub mod media_refs;
+684 -1
View File
@@ -293,7 +293,8 @@ fn writable_format_for_path_rejects_formats_deckcraft_does_not_write() {
assert_eq!(f("a.pdf"), Some("pdf"));
assert_eq!(f("a.jpg"), Some("jpeg"));
assert_eq!(f("a.txt"), Some("outline"));
for path in ["a.odp", "a.gif", "a.svg", "a.outline", "a", "dir.v2/a"] {
assert_eq!(f("a.gif"), Some("gif"));
for path in ["a.odp", "a.bmp", "a.svg", "a.outline", "a", "dir.v2/a"] {
assert_eq!(f(path), None, "{path}");
}
}
@@ -567,11 +568,307 @@ fn grown_table_frame_covers_its_drawn_rows() {
assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. })));
}
/// A table at (100, 100): columns `cols` wide, rows 40 pt tall, cells holding `data`.
fn table_at(s: &mut Session, cols: &[f64], data: serde_json::Value) -> deckcraft_model::ShapeId {
let rows = data.as_array().map_or(1, Vec::len);
let w: f64 = cols.iter().sum();
let r =
s.execute("insert.table", &json!({"rows": rows, "cols": cols.len(), "rect": [100.0, 100.0, w, 40.0 * rows as f64], "data": data})).unwrap();
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
for (c, cw) in cols.iter().enumerate() {
s.execute("table.columnWidth", &json!({"id": id, "col": c, "width": cw})).unwrap();
}
s.execute("edit.deselect", &json!({})).unwrap();
id
}
fn table_of(s: &Session, id: deckcraft_model::ShapeId) -> deckcraft_model::Table {
match &s.doc().unwrap().shape(id).unwrap().kind {
deckcraft_model::ShapeKind::Table(t) => t.clone(),
k => panic!("not a table: {k:?}"),
}
}
fn tev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }
}
fn text_sel(s: &Session) -> Option<TextSel> {
s.doc().unwrap().selection.text.clone()
}
/// Slide point of the caret before character `ch` of paragraph `para` in the text being edited.
fn caret_point(s: &Session, para: usize, ch: usize) -> deckcraft_geom::Point {
let st = s.doc().unwrap();
text_point(st, st.selection.text.as_ref().unwrap(), para, ch)
}
/// Slide point of the caret before character `ch` of paragraph `para` of text `t`.
fn text_point(st: &DocState, t: &TextSel, para: usize, ch: usize) -> deckcraft_geom::Point {
let (l, m) = cmd::text::text_frame(st, t).unwrap();
let (x, top, bot) = l.caret(deckcraft_text::Pos { para, ch }).unwrap();
m * deckcraft_geom::Point::new(x, (top + bot) / 2.0)
}
#[test]
fn cell_text_is_laid_out_where_the_cell_draws_it() {
// The caret and hit testing of a cell's text use the cell's rect, not the table's.
let mut s = session();
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", ""]]));
s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap();
let c = caret_point(&s, 0, 0);
assert!(c.x > 300.0 && c.x < 320.0 && c.y > 100.0 && c.y < 140.0, "caret of cell (0, 1) at {c:?}");
// An empty cell still has a caret.
s.execute("text.edit", &json!({"id": id, "cell": [1, 1]})).unwrap();
let c = caret_point(&s, 0, 0);
assert!(c.x > 300.0 && c.y > 140.0 && c.y < 180.0, "caret of empty cell (1, 1) at {c:?}");
// Hit testing: the start and the end of "Hello world".
let st = s.doc().unwrap();
let t = TextSel { shape: id, cell: Some((0, 0)), ..Default::default() };
assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(101.0, 115.0)), (0, 0));
assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(295.0, 115.0)), (0, 11));
}
#[test]
fn double_click_in_a_cell_selects_the_word_clicked() {
let mut s = session();
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]]));
// Where "world" is drawn.
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
let p = caret_point(&s, 0, 8);
s.execute("text.exit", &json!({})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
// As the UI sends it: press, release, press, double-click (on release), then the release.
s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap();
s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap();
assert!(text_sel(&s).is_none(), "a click on an unselected table selects it");
s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap();
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap();
let t = text_sel(&s).unwrap();
assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11))), "the word under the double-click");
// The control channel sends the double-click alone.
s.execute("text.exit", &json!({})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
assert_eq!(text_sel(&s).map(|t| t.ordered()), Some(((0, 6), (0, 11))));
}
#[test]
fn clicks_while_editing_a_cell_move_the_caret_between_cells() {
let mut s = session();
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "Second cell"], ["c", "d"]]));
s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap();
let p6 = caret_point(&s, 0, 6);
let p11 = caret_point(&s, 0, 11);
s.execute("text.edit", &json!({"id": id, "cell": [1, 0]})).unwrap();
// A click in another cell of the table moves the caret there, at the click.
s.pointer(tev(PointerKind::Down, p6.x, p6.y)).unwrap();
let t = text_sel(&s).unwrap();
assert_eq!((t.shape, t.cell, t.caret, t.anchor), (id, Some((0, 1)), (0, 6), (0, 6)));
// Dragging selects within that cell, even past its edge.
s.pointer(tev(PointerKind::Drag, p11.x, p11.y)).unwrap();
s.pointer(tev(PointerKind::Drag, 600.0, p11.y)).unwrap();
s.pointer(tev(PointerKind::Up, 600.0, p11.y)).unwrap();
let t = text_sel(&s).unwrap();
assert_eq!((t.cell, t.ordered()), (Some((0, 1)), ((0, 6), (0, 11))));
// Shift+click in another cell starts a new caret there instead of extending across cells.
let shift = Mods { shift: true, ..Default::default() };
s.pointer(PointerEvent { mods: shift, ..tev(PointerKind::Down, 150.0, 165.0) }).unwrap();
s.pointer(tev(PointerKind::Up, 150.0, 165.0)).unwrap();
let t = text_sel(&s).unwrap();
assert_eq!((t.cell, t.is_range()), (Some((1, 0)), false));
// Typing goes into the clicked cell.
s.execute("text.insert", &json!({"text": "X"})).unwrap();
assert_eq!(table_of(&s, id).cell(1, 0).unwrap().text.text(), "cX");
// A click outside the table stops editing.
s.pointer(tev(PointerKind::Down, 50.0, 400.0)).unwrap();
s.pointer(tev(PointerKind::Up, 50.0, 400.0)).unwrap();
assert!(text_sel(&s).is_none());
}
#[test]
fn a_click_in_a_selected_table_enters_the_clicked_cell() {
let mut s = session();
let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "Hello world"]]));
s.pointer(tev(PointerKind::Down, 350.0, 160.0)).unwrap();
s.pointer(tev(PointerKind::Up, 350.0, 160.0)).unwrap();
assert_eq!((s.doc().unwrap().selection.shapes.clone(), text_sel(&s)), (vec![id], None), "the first click selects the table");
let st = s.doc().unwrap();
let p = text_point(st, &TextSel { shape: id, cell: Some((1, 1)), ..Default::default() }, 0, 6);
s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap();
s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap();
let t = text_sel(&s).unwrap();
assert_eq!((t.cell, t.caret), (Some((1, 1)), (0, 6)), "caret at the click");
// Dragging a selected table still moves it.
s.execute("text.exit", &json!({})).unwrap();
s.pointer(tev(PointerKind::Down, 150.0, 120.0)).unwrap();
s.pointer(tev(PointerKind::Drag, 170.0, 130.0)).unwrap();
s.pointer(tev(PointerKind::Up, 170.0, 130.0)).unwrap();
assert!(text_sel(&s).is_none());
assert_ne!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().x, 100.0);
}
#[test]
fn caret_moves_by_line_in_a_wrapped_cell() {
let mut s = session();
let id = table_at(&mut s, &[90.0, 200.0], json!([["one two three four five six seven", "b"]]));
s.execute("text.edit", &json!({"id": id, "cell": [0, 0], "at": [0, 0]})).unwrap();
s.execute("text.move", &json!({"to": "down"})).unwrap();
let down = text_sel(&s).unwrap().caret;
assert!(down.0 == 0 && down.1 > 0, "down goes to the next line of the paragraph: {down:?}");
s.execute("text.move", &json!({"to": "lineEnd"})).unwrap();
let end = text_sel(&s).unwrap().caret;
assert!(end.1 > down.1 && end.1 < 33, "end of the second line: {end:?}");
s.execute("text.move", &json!({"to": "up"})).unwrap();
assert!(text_sel(&s).unwrap().caret.1 < down.1);
}
#[test]
fn table_borders_are_hit_where_they_are_drawn() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]]));
let hit = |s: &Session, x: f64, y: f64| s.hit_test(deckcraft_geom::Point::new(x, y), 3.0);
assert_eq!(hit(&s, 201.0, 125.0), Some(tools::Hit::TableColumn { id, col: 0 }));
assert_eq!(hit(&s, 400.0, 125.0), Some(tools::Hit::TableColumn { id, col: 2 }), "the right edge");
assert_eq!(hit(&s, 150.0, 139.0), Some(tools::Hit::TableRow { id, row: 0 }));
assert_eq!(hit(&s, 150.0, 181.0), Some(tools::Hit::TableRow { id, row: 1 }), "the bottom edge");
assert!(matches!(hit(&s, 100.0, 125.0), Some(tools::Hit::Shape { .. })), "the left edge moves the table");
assert!(matches!(hit(&s, 150.0, 125.0), Some(tools::Hit::Shape { .. })));
// Hovering a border shows it (the UI picks the resize cursor from the hover).
s.pointer(tev(PointerKind::Move, 300.0, 170.0)).unwrap();
assert_eq!(s.tool.hover, Some(tools::Hit::TableColumn { id, col: 1 }));
// A border inside a merged cell isn't there.
s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap();
assert!(matches!(hit(&s, 200.0, 120.0), Some(tools::Hit::Shape { .. })));
assert_eq!(hit(&s, 200.0, 160.0), Some(tools::Hit::TableColumn { id, col: 0 }));
}
#[test]
fn dragging_column_borders_resizes_columns() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]]));
let undo_depth = s.doc().unwrap().history.undo.len();
// An inner border: the two columns share the change, the table keeps its width.
s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap();
s.pointer(tev(PointerKind::Drag, 215.0, 125.0)).unwrap();
s.pointer(tev(PointerKind::Drag, 230.0, 128.0)).unwrap();
s.pointer(tev(PointerKind::Up, 230.0, 128.0)).unwrap();
assert_eq!(table_of(&s, id).cols, vec![130.0, 70.0, 100.0]);
assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 300.0);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth + 1, "one undo step per drag");
// Columns don't collapse.
s.pointer(tev(PointerKind::Down, 230.0, 125.0)).unwrap();
s.pointer(tev(PointerKind::Drag, 500.0, 125.0)).unwrap();
s.pointer(tev(PointerKind::Up, 500.0, 125.0)).unwrap();
assert_eq!(table_of(&s, id).cols, vec![200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH, 100.0]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 100.0]);
// The right edge: the last column and the table get wider.
s.pointer(tev(PointerKind::Down, 400.0, 120.0)).unwrap();
s.pointer(tev(PointerKind::Drag, 450.0, 120.0)).unwrap();
s.pointer(tev(PointerKind::Up, 450.0, 120.0)).unwrap();
assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 150.0]);
assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 350.0);
// A click on a border without dragging changes nothing.
let before = s.doc().unwrap().doc.clone();
s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap();
s.pointer(tev(PointerKind::Up, 200.0, 125.0)).unwrap();
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc));
}
#[test]
fn dragging_a_row_border_sets_the_row_height() {
let mut s = session();
let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "d"]]));
let drawn = |s: &Session| {
let st = s.doc().unwrap();
tools::table_rows(st, &table_of(s, id))
};
s.pointer(tev(PointerKind::Down, 150.0, 140.0)).unwrap();
s.pointer(tev(PointerKind::Drag, 150.0, 170.0)).unwrap();
s.pointer(tev(PointerKind::Up, 150.0, 170.0)).unwrap();
assert_eq!(drawn(&s), vec![70.0, 40.0]);
let st = s.doc().unwrap();
assert_eq!(cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap()).h, 110.0, "the table grows");
// Rows still fit their text: dragging the border up stops at the text.
s.pointer(tev(PointerKind::Down, 150.0, 170.0)).unwrap();
s.pointer(tev(PointerKind::Drag, 150.0, 60.0)).unwrap();
s.pointer(tev(PointerKind::Up, 150.0, 60.0)).unwrap();
let h = drawn(&s)[0];
assert!(h > 10.0 && h < 40.0, "row 0 shrinks to its text: {h}");
// The bottom edge sizes the last row.
let bottom = 100.0 + drawn(&s).iter().sum::<f64>();
s.pointer(tev(PointerKind::Down, 150.0, bottom)).unwrap();
s.pointer(tev(PointerKind::Drag, 150.0, bottom + 20.0)).unwrap();
s.pointer(tev(PointerKind::Up, 150.0, bottom + 20.0)).unwrap();
assert_eq!(drawn(&s)[1], 60.0);
}
#[test]
fn dragging_a_border_while_editing_a_cell_keeps_editing() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"]]));
s.execute("text.edit", &json!({"id": id, "cell": [0, 1]})).unwrap();
s.pointer(tev(PointerKind::Down, 200.0, 120.0)).unwrap();
s.pointer(tev(PointerKind::Drag, 180.0, 120.0)).unwrap();
s.pointer(tev(PointerKind::Up, 180.0, 120.0)).unwrap();
assert_eq!(table_of(&s, id).cols, vec![80.0, 120.0]);
assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((0, 1)));
}
#[test]
fn column_width_can_keep_the_table_width() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"]]));
s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 150.0, "keepTotal": true})).unwrap();
assert_eq!(table_of(&s, id).cols, vec![100.0, 150.0, 50.0]);
s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 1000.0, "keepTotal": true})).unwrap();
assert_eq!(table_of(&s, id).cols, vec![100.0, 200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH]);
// The last column has no neighbour: the table changes width.
s.execute("table.columnWidth", &json!({"id": id, "col": 2, "width": 80.0, "keepTotal": true})).unwrap();
assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 100.0 + (200.0 - cmd::table::MIN_COLUMN_WIDTH) + 80.0);
}
#[test]
fn rotated_tables_hit_the_cell_under_the_pointer() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
s.execute("shape.rotate", &json!({"ids": [id], "deg": 90})).unwrap();
let st = s.doc().unwrap();
let f = cmd::table::table_frame(st, st.shape(id).unwrap()).unwrap();
let p = f.affine * deckcraft_geom::Point::new(150.0, 60.0);
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((1, 1)));
}
#[test]
fn tab_moves_between_cells_skipping_merged_ones() {
let mut s = session();
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]]));
s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap();
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
let cell = |s: &Session| text_sel(s).and_then(|t| t.cell);
let shift = Mods { shift: true, ..Default::default() };
s.key("Tab", Mods::default()).unwrap();
assert_eq!(cell(&s), Some((0, 2)), "the cell under the merge is skipped");
s.key("Tab", shift).unwrap();
assert_eq!(cell(&s), Some((0, 0)));
s.key("Tab", shift).unwrap();
assert_eq!(cell(&s), Some((0, 0)), "Shift+Tab in the first cell stays");
s.execute("text.edit", &json!({"id": id, "cell": [1, 2]})).unwrap();
s.key("Tab", Mods::default()).unwrap();
assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row");
}
#[test]
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();
// Explicitly left-to-right first, so typing Arabic doesn't already turn it right-to-left (#169).
s.execute("format.rtl", &json!({"on": false})).unwrap();
s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).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();
@@ -592,6 +889,61 @@ 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;
// The title is centred: the caret must NOT jump back to the left margin (~7.2 pt).
assert!(cx > 100.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, None, "the title keeps the alignment it inherits");
// A text box inherits the deck's default (left) alignment, which it keeps too.
s.execute("text.exit", &json!({})).ok();
let id = s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 100], "text": ""})).unwrap()["id"].as_u64().unwrap();
s.execute("text.edit", &json!({"id": id})).unwrap();
s.execute("text.insert", &json!({"text": "مرحبا"})).unwrap();
let st = s.doc().unwrap();
let sh = st.shape(deckcraft_model::ShapeId(id as u32)).unwrap();
let para = sh.text.as_ref().unwrap().paragraphs[0].props.clone();
assert_eq!(para.rtl, Some(true));
assert_eq!(para.align, None, "the text box keeps the alignment it inherits");
}
#[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;
@@ -2362,6 +2714,306 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
Some((u32::from_be_bytes(w), u32::from_be_bytes(h)))
}
/// A blank slide holding one text box being edited, with smart guides and grid snapping off so
/// drags move by exactly the pointer delta. Returns the session, the box id and its box.
fn editing_text_box(rot: f64) -> (Session, u64, deckcraft_geom::Xfrm) {
let mut s = session();
s.prefs.snap_to_grid = false;
s.prefs.smart_guides = false;
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let id = s.execute("insert.textBox", &json!({"rect": [200.0, 150.0, 240.0, 60.0], "text": "Hello world"})).unwrap()["id"].as_u64().unwrap();
if rot != 0.0 {
s.execute("shape.rotate", &json!({"ids": [id], "deg": rot})).unwrap();
}
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
let x = text_box_xfrm(&s, id);
(s, id, x)
}
/// Slide point for a point in the box's own (rotated) frame.
fn on_box(x: &deckcraft_geom::Xfrm, lx: f64, ly: f64) -> (f64, f64) {
let p = x.affine() * deckcraft_geom::Point::new(lx, ly);
(p.x, p.y)
}
fn drag_gesture(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> serde_json::Value {
let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
let down = s.pointer(ev(PointerKind::Down, from)).unwrap();
let mid = ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0);
s.pointer(ev(PointerKind::Drag, mid)).unwrap();
s.pointer(ev(PointerKind::Drag, to)).unwrap();
s.pointer(ev(PointerKind::Up, to)).unwrap();
down
}
#[test]
fn dragging_the_frame_of_edited_text_moves_the_box() {
let (mut s, id, x) = editing_text_box(0.0);
assert!(x.h > 12.0, "{x:?}");
let sid = deckcraft_model::ShapeId(id as u32);
// Hovering the band on either side of the edge says "move"; the inside says "text".
for ly in [-2.0, 2.0, x.h + 2.0] {
let (px, py) = on_box(&x, x.w / 4.0, ly);
s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
assert_eq!(s.tool.hover, Some(tools::Hit::Frame { id: sid }), "ly {ly}");
}
let (px, py) = on_box(&x, x.w / 4.0, x.h / 2.0);
s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
assert_eq!(s.tool.hover, Some(tools::Hit::Shape { id: sid, text: true }));
// Press on the top edge, just outside, and drag: the box moves, text editing ends.
let undo_before = s.doc().unwrap().history.undo.len();
let from = on_box(&x, x.w / 4.0, -1.0);
let down = drag_gesture(&mut s, from, (from.0 + 30.0, from.1 + 20.0));
assert_eq!(down["hit"], json!({"kind": "frame", "id": id}));
let moved = text_box_xfrm(&s, id);
assert!((moved.x - (x.x + 30.0)).abs() < 1e-9 && (moved.y - (x.y + 20.0)).abs() < 1e-9, "{moved:?}");
assert_eq!((moved.w, moved.h), (x.w, x.h));
let st = s.doc().unwrap();
assert!(st.selection.text.is_none());
assert_eq!(st.selection.shapes, vec![sid]);
// One undo step, which puts the box back.
assert_eq!(st.history.undo.len(), undo_before + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text_box_xfrm(&s, id), x);
// The inner side of the band works too; a click there without a drag selects the box whole
// and changes nothing.
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
let undo_before = s.doc().unwrap().history.undo.len();
let (px, py) = on_box(&x, x.w * 0.75, x.h - 1.0);
let ev = |kind| PointerEvent { kind, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None };
s.pointer(ev(PointerKind::Down)).unwrap();
s.pointer(ev(PointerKind::Up)).unwrap();
let st = s.doc().unwrap();
assert!(st.selection.text.is_none());
assert_eq!(st.selection.shapes, vec![sid]);
assert_eq!(st.history.undo.len(), undo_before);
assert_eq!(text_box_xfrm(&s, id), x);
}
#[test]
fn dragging_inside_edited_text_selects_text() {
let (mut s, id, x) = editing_text_box(0.0);
let undo_before = s.doc().unwrap().history.undo.len();
let down = drag_gesture(&mut s, on_box(&x, 8.0, x.h / 2.0), on_box(&x, x.w - 8.0, x.h / 2.0));
assert!(down.get("caret").is_some(), "{down}");
assert_eq!(text_box_xfrm(&s, id), x);
let st = s.doc().unwrap();
let t = st.selection.text.as_ref().expect("still editing");
assert!(t.is_range(), "{t:?}");
assert_eq!(t.ordered(), ((0, 0), (0, 11)));
assert_eq!(st.history.undo.len(), undo_before);
}
#[test]
fn frame_of_rotated_edited_text_follows_the_rotation() {
let (mut s, id, x) = editing_text_box(30.0);
assert_eq!(x.rot, 30.0);
let sid = deckcraft_model::ShapeId(id as u32);
// The rotated bottom edge is the frame; dragging it moves the box and keeps its rotation.
let from = on_box(&x, x.w / 4.0, x.h + 1.0);
assert_eq!(s.hit_test(deckcraft_geom::Point::new(from.0, from.1), 3.0), Some(tools::Hit::Frame { id: sid }));
let down = drag_gesture(&mut s, from, (from.0 + 25.0, from.1 - 10.0));
assert_eq!(down["hit"]["kind"], "frame");
let moved = text_box_xfrm(&s, id);
assert!((moved.x - (x.x + 25.0)).abs() < 1e-9 && (moved.y - (x.y - 10.0)).abs() < 1e-9, "{moved:?}");
assert_eq!(moved.rot, 30.0);
// Inside the rotated box, pressing still places the caret.
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
let mx = text_box_xfrm(&s, id);
let (px, py) = on_box(&mx, mx.w / 2.0, mx.h / 2.0);
let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
s.pointer(PointerEvent { kind: PointerKind::Up, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
assert!(r.get("caret").is_some(), "{r}");
assert_eq!(text_box_xfrm(&s, id), mx);
// Inside the axis-aligned bounds but off the rotated box: neither text nor frame, so a press
// there leaves editing instead of moving the caret.
let off = on_box(&mx, mx.w / 2.0, -mx.h);
let off_pt = deckcraft_geom::Point::new(off.0, off.1);
assert!(mx.bounds().contains(off_pt));
assert_ne!(s.hit_test(off_pt, 3.0), Some(tools::Hit::Frame { id: sid }));
let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
s.pointer(PointerEvent { kind: PointerKind::Up, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
assert!(r.get("caret").is_none(), "{r}");
assert!(s.doc().unwrap().selection.text.is_none());
assert_eq!(text_box_xfrm(&s, id), mx);
}
#[test]
fn resize_handles_beat_the_frame_of_edited_text() {
let (mut s, id, x) = editing_text_box(0.0);
// The SE corner lies in the frame band, but its handle wins.
let from = on_box(&x, x.w, x.h);
let down = drag_gesture(&mut s, from, (from.0 + 40.0, from.1));
assert_eq!(down["hit"]["kind"], "resize");
let sized = text_box_xfrm(&s, id);
assert!((sized.w - (x.w + 40.0)).abs() < 1e-9, "{sized:?}");
assert_eq!((sized.x, sized.y), (x.x, x.y));
}
#[test]
fn format_language_marks_the_selected_text() {
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": "Hola mundo"})).unwrap();
s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap();
s.execute("format.language", &json!({"lang": "es-ES"})).unwrap();
assert_eq!(s.execute("format.state", &json!({})).unwrap()["lang"], "es-ES");
let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap();
let langs: Vec<_> = body.paragraphs[0].runs.iter().map(|r| (r.text.as_str(), r.props.lang.as_deref())).collect();
assert_eq!(langs, vec![("Hola ", None), ("mundo", Some("es-ES"))]);
}
#[test]
fn every_params_doc_names_its_keys() {
for c in command_specs() {
assert!(cmd::param_keys(c.params).is_ok(), "{}: {:?}", c.id, cmd::param_keys(c.params));
}
assert_eq!(cmd::param_keys("{index} | {id}").unwrap(), ["index", "id"]);
assert_eq!(cmd::param_keys("{color? | none?: true, kind?: a|b (x, y), ids?} → {id}").unwrap(), ["color", "none", "kind", "ids"]);
}
#[test]
fn a_params_doc_spells_out_each_parameter() {
use cmd::Param;
let p = |name, optional, about, or, object| Param { name, optional, about, or, object };
assert_eq!(
cmd::parse_params("{preset: rect|roundRect, rect?: [x,y,w,h] pt, index? (else selected shapes)} → {id}").unwrap(),
[
p("preset", false, "rect|roundRect", false, 0),
p("rect", true, "[x,y,w,h] pt", false, 0),
p("index", true, "(else selected shapes)", false, 0)
]
);
assert_eq!(
cmd::parse_params("{color? | none?: true, gradient?: {stops, kind?: a|b}, ids?}").unwrap(),
[
p("color", true, "", false, 0),
p("none", true, "true", true, 0),
p("gradient", true, "{stops, kind?: a|b}", false, 0),
p("ids", true, "", false, 0)
]
);
assert_eq!(
cmd::parse_params("{name} | {major, minor}").unwrap(),
[p("name", false, "", false, 0), p("major", false, "", false, 1), p("minor", false, "", false, 1)]
);
}
#[test]
fn what_a_command_acts_on_names_only_keys_it_takes() {
for c in command_specs() {
let keys = cmd::param_keys(c.params).unwrap();
let said = c.acts_on().unwrap_or_default();
assert!(!said.contains("`ids`") || keys.contains(&"ids"), "{}: {said}", c.id);
assert!(!said.contains("`id`") || keys.contains(&"id"), "{}: {said}", c.id);
}
let acts = |id: &str| find_command(id).and_then(|c| c.acts_on());
assert_eq!(acts("shape.resize").as_deref(), Some("Acts on the shapes in `ids`, else on the selected shapes."));
assert_eq!(acts("table.style").as_deref(), Some("Acts on the shape in `id`, else on the selected shapes."));
assert_eq!(acts("shape.setDefault").as_deref(), Some("Acts on the selected shapes."));
assert_eq!(acts("format.size").as_deref(), Some("Acts on the selected text or shapes."));
assert_eq!(acts("slide.new"), None);
}
/// Tables whose style the file defines (#292, #294: its look and background drawn) edit like the
/// others: the caret is laid out with the file style's lettering, clicks and double-clicks land
/// in the cell where it is drawn, and borders still resize.
#[test]
fn tables_with_a_file_defined_style_edit_and_resize() {
use deckcraft_model::style::{ColorRef, Fill};
use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef};
let mut s = session();
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]]));
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
let built_in = caret_point(&s, 0, 8);
s.execute("text.exit", &json!({})).unwrap();
// The table's style, defined by the file: a plain (not bold) header, a filled body, a
// background.
{
let style = table_of(&s, id).style;
let d = s.doc_mut().unwrap();
let doc = std::sync::Arc::make_mut(&mut d.doc);
let solid = |r, g, b| Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b))));
doc.table_styles.push(TableStyleDef {
id: style,
name: "File style".into(),
parts: vec![
(TablePart::WholeTbl, TablePartStyle { fill: solid(230, 240, 250), ..Default::default() }),
(TablePart::FirstRow, TablePartStyle { fill: solid(40, 60, 90), bold: Some(false), ..Default::default() }),
],
background: Some(TablePartStyle { fill: solid(200, 200, 200), ..Default::default() }),
raw: String::new(),
});
}
s.execute("edit.deselect", &json!({})).unwrap();
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
let p = caret_point(&s, 0, 8);
assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}");
s.execute("text.exit", &json!({})).unwrap();
s.execute("edit.deselect", &json!({})).unwrap();
// A double-click selects the word drawn there.
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
let t = text_sel(&s).unwrap();
assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11))));
// A click in another cell moves the caret there.
s.pointer(tev(PointerKind::Down, 400.0, 160.0)).unwrap();
s.pointer(tev(PointerKind::Up, 400.0, 160.0)).unwrap();
assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1)));
// Border drag, while editing: the columns resize, editing goes on.
s.pointer(tev(PointerKind::Down, 300.0, 120.0)).unwrap();
s.pointer(tev(PointerKind::Drag, 320.0, 120.0)).unwrap();
s.pointer(tev(PointerKind::Drag, 330.0, 121.0)).unwrap();
s.pointer(tev(PointerKind::Up, 330.0, 121.0)).unwrap();
assert_eq!(table_of(&s, id).cols, vec![230.0, 170.0]);
assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1)));
}
#[test]
fn editing_notes_keeps_their_formatting_through_pptx() {
use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody};
// A deck whose notes have a bold heading over indented bullet points, as saved by PowerPoint.
let mut p = deckcraft_model::defaults::new_presentation(None);
let bullet = ParaProps { margin_left: Some(22.5), indent: Some(-22.5), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() };
let point = |t: &str| Paragraph { level: 1, props: bullet.clone(), runs: vec![Run::new(t)], ..Default::default() };
std::sync::Arc::make_mut(&mut p.slides[0]).notes = TextBody {
paragraphs: vec![
Paragraph { runs: vec![Run::with("Overview", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() },
point("First point"),
point("Second point"),
],
..Default::default()
};
let bytes = deckcraft_pptx::export(&p).unwrap();
let mut s = session();
s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": crate::cmd::base64_encode(&bytes)})).unwrap();
// Type a word into the second bullet, as the notes pane does.
s.execute("slide.notes", &json!({"index": 0, "text": "Overview\nFirst point\nSecond main point"})).unwrap();
let check = |notes: &TextBody| {
assert_eq!(notes.text(), "Overview\nFirst point\nSecond main point");
assert_eq!(notes.paragraphs[0].runs[0].props.bold, Some(true));
for para in &notes.paragraphs[1..] {
assert_eq!(para.level, 1);
assert_eq!(para.props.margin_left, Some(22.5));
assert_eq!(para.props.indent, Some(-22.5));
assert_eq!(para.props.bullet, Some(Bullet::Char { char: "•".into() }));
}
};
check(&s.doc().unwrap().doc.slides[0].notes);
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
let reopened = deckcraft_pptx::import(&crate::cmd::base64_decode(&saved).unwrap()).unwrap();
check(&reopened.slides[0].notes);
// slide.inspect hands out the formatted notes, and slide.notes {body} takes them back as they are.
let body = s.execute("slide.inspect", &json!({"index": 0})).unwrap()["notesBody"].clone();
s.execute("slide.notes", &json!({"index": 0, "text": "Plain"})).unwrap();
assert_eq!(s.doc().unwrap().doc.slides[0].notes, TextBody::from_text("Plain"), "new text shares nothing: plain");
s.execute("slide.notes", &json!({"index": 0, "body": body})).unwrap();
check(&s.doc().unwrap().doc.slides[0].notes);
assert!(s.execute("slide.notes", &json!({"index": 0, "body": 7})).is_err());
}
#[test]
fn autocorrect_options_as_you_type() {
let mut s = session();
@@ -2522,6 +3174,37 @@ fn language_marks_text_and_no_proof_and_repeats_are_reported() {
assert!(s.execute("format.language", &json!({"lang": "en US!"})).is_err());
}
#[test]
fn the_caret_takes_the_colour_of_the_text() {
let caret = |theme: &str| {
let mut s = session();
s.execute("design.theme", &json!({"name": theme})).unwrap();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
let st = s.doc().unwrap();
let empty = cmd::text::caret_color(st, &st.selection.text.clone().unwrap()).unwrap();
s.execute("text.insert", &json!({"text": "Typed"})).unwrap();
let st = s.doc().unwrap();
assert_eq!(cmd::text::caret_color(st, &st.selection.text.clone().unwrap()), Some(empty));
empty
};
// Dark text on the light theme, light text on the dark one: never black on a dark slide.
let (light, dark) = (caret("DeckCraft"), caret("Nocturne"));
assert!(light.r < 0x60 && light.g < 0x60 && light.b < 0x60, "{light:?}");
assert!(dark.r > 0xC0 && dark.g > 0xC0 && dark.b > 0xC0, "{dark:?}");
// Text set to red gets a red caret.
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": "Red"})).unwrap();
s.execute("text.selectAll", &json!({})).unwrap();
s.execute("format.color", &json!({"color": "#FF0000"})).unwrap();
s.execute("text.move", &json!({"to": "end"})).unwrap();
let st = s.doc().unwrap();
let red = cmd::text::caret_color(st, &st.selection.text.clone().unwrap()).unwrap();
assert_eq!((red.r, red.g, red.b), (0xFF, 0, 0));
}
/// Smooth Corners (#286): a rounded rectangle becomes a squircle outline that survives PPTX.
#[test]
fn smooth_corners_makes_a_squircle_outline() {
+233 -80
View File
@@ -110,6 +110,21 @@ pub enum Hit {
id: ShapeId,
text: bool,
},
/// The frame of the box whose text is being edited (a band on either side of its edge):
/// pressing there leaves text editing and drags the box, like PowerPoint.
Frame {
id: ShapeId,
},
/// The right edge of table column `col` (dragging it resizes the column).
TableColumn {
id: ShapeId,
col: usize,
},
/// The bottom edge of table row `row` (dragging it resizes the row).
TableRow {
id: ShapeId,
row: usize,
},
}
#[derive(Clone, Debug)]
@@ -177,6 +192,18 @@ enum Drag {
TextSelect {
shape: ShapeId,
},
/// Dragging a table border: the right edge of column `index`, or the bottom edge of row
/// `index`. `start` is the table-local press point, `size` the column's width or the row's
/// drawn height then, `frame` where the table was drawn.
TableBorder {
id: ShapeId,
column: bool,
index: usize,
start: Point,
size: f64,
frame: Affine,
moved: bool,
},
Ink {
points: Vec<(f64, f64, f32)>,
},
@@ -382,6 +409,13 @@ impl Session {
}
}
}
// The frame of the text being edited: grabbing it moves the box.
if let Some(e) = edited_box(st)
&& e.movable
&& edit_zone(&e.xfrm, p, tol) == EditZone::Frame
{
return Some(Hit::Frame { id: e.id });
}
// Shapes, topmost first.
// Locked objects are still picked (to format them or type in them); they just don't move.
for sh in st.shapes().iter().rev() {
@@ -510,30 +544,10 @@ impl Session {
}
ToolKind::Select => {}
}
let st = self.doc()?;
// Clicking inside the text being edited moves the caret / starts a text selection.
if let Some(t) = st.selection.text.clone()
&& !t.notes
&& t.cell.is_none()
&& let Some(sh) = st.shape(t.shape)
{
let x = xfrm_of(&st.doc, &st.selection, sh);
if x.bounds().inflate(tol, tol).contains(p)
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Warp { .. }))
{
let pos = text_pos(st, sh, p);
let shift = mods.shift;
self.select(|_, sel| {
if let Some(tt) = sel.text.as_mut() {
tt.caret = pos;
if !shift {
tt.anchor = pos;
}
}
})?;
self.tool.drag = Some(Drag::TextSelect { shape: t.shape });
return Ok(json!({"caret": [pos.0, pos.1]}));
}
if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? {
self.tool.drag = Some(Drag::TextSelect { shape });
return Ok(json!({"caret": [pos.0, pos.1]}));
}
let hit = self.hit_test(p, tol);
// Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one;
@@ -567,6 +581,8 @@ impl Session {
}
let st = self.doc()?;
match hit {
Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?,
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
Some(Hit::Resize { id, handle }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
self.begin("Size");
@@ -592,6 +608,17 @@ impl Session {
self.begin("Adjust Shape");
self.tool.drag = Some(Drag::Adjust { id, index, orig });
}
Some(Hit::Frame { id }) => {
// Like PowerPoint: the box leaves text editing and is selected whole (a click
// without a drag stops there), and dragging moves it as one undo step.
let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect();
self.select(|_, sel| {
sel.shapes = vec![id];
sel.text = None;
sel.cells = None;
})?;
self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None });
}
Some(Hit::Warp { id, index }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
let found = st.shape(id).and_then(|sh| {
@@ -624,8 +651,10 @@ impl Session {
let origs: Vec<(ShapeId, Xfrm)> =
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
// A click (no drag) on text puts the caret there; unselected text boxes and
// placeholders take the caret straight away like PowerPoint.
let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p));
// placeholders take the caret straight away like PowerPoint. A click in a selected
// table puts the caret in the clicked cell.
let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_)));
let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
let ids2 = ids.clone();
self.select(|_, sel| {
sel.shapes = ids2;
@@ -874,9 +903,10 @@ impl Session {
self.tool.preview = Some(x);
}
Drag::TextSelect { shape } => {
// The selection stays in the shape or table cell where it started.
let st = self.doc()?;
if let Some(sh) = st.shape(shape) {
let pos = text_pos(st, sh, p);
if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) {
let pos = cmd::text::text_pos_at(st, t, p);
self.select(|_, sel| {
if let Some(t) = sel.text.as_mut() {
t.caret = pos;
@@ -884,6 +914,26 @@ impl Session {
})?;
}
}
Drag::TableBorder { id, column, index, start, size, frame, moved } => {
let local = frame.inverse() * p;
let d = if column { local.x - start.x } else { local.y - start.y };
if !moved && d.abs() < tol.max(1.0) {
return Ok(Value::Null);
}
if !moved {
self.begin(if column { "Column Width" } else { "Row Height" });
}
// An inner column border moves between its two columns (the table keeps its
// width); the right edge widens the last column and the table. A row's height is
// a minimum: rows still grow to fit their text.
if column {
let width = (size + d).max(cmd::table::MIN_COLUMN_WIDTH);
self.execute("table.columnWidth", &json!({"id": id, "col": index, "width": width, "keepTotal": true}))?;
} else {
self.execute("table.rowHeight", &json!({"id": id, "row": index, "height": (size + d).max(1.0)}))?;
}
self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: true });
}
Drag::Freeform { mode, mut points, pressed } => {
// Dragging scribbles (except for curves, which only take clicked points).
if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
@@ -939,11 +989,8 @@ impl Session {
};
}
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => {
// Plain click on text: caret at the click.
let st = self.doc()?;
if let Some(sh) = st.shape(id) {
let pos = text_pos(st, sh, at);
self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?;
// Plain click on text: caret at the click (in a table, in the clicked cell).
if self.edit_text_at(id, at)? {
out = json!({"editing": id});
}
}
@@ -1073,8 +1120,12 @@ impl Session {
self.tool.sticky = false;
return Ok(Value::Null);
}
let st = self.doc()?;
if st.selection.text.is_some() {
// The double-click's second press started a click (caret on release): this replaces it.
if matches!(self.tool.drag, Some(Drag::Move { moved: false, .. })) {
self.tool.drag = None;
}
// In the text being edited: select the word double-clicked.
if self.place_caret(p, tol, false)?.is_some() {
return self.execute("text.selectWord", &json!({}));
}
match self.hit_test(p, tol) {
@@ -1082,18 +1133,12 @@ impl Session {
let st = self.doc()?;
let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) };
match &sh.kind {
ShapeKind::Table(_) => {
let x = xfrm_of(&st.doc, &st.selection, &sh);
let local = x.affine().inverse() * p;
if let ShapeKind::Table(t) = &sh.kind {
let cell = deckcraft_render_cell(t, &table_rows(st, t), local);
return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]}));
ShapeKind::Table(_) | ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => {
// Caret at the double-click (in a table, in the clicked cell), then select
// the word there.
if !self.edit_text_at(id, p)? {
return Ok(Value::Null);
}
Ok(Value::Null)
}
ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => {
let pos = text_pos(st, &sh, p);
self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?;
self.execute("text.selectWord", &json!({}))
}
ShapeKind::Group { .. } => {
@@ -1147,6 +1192,9 @@ impl Session {
| Hit::LineEnd { id, .. }
| Hit::Handle { id, .. }
| Hit::Vertex { id, .. }
| Hit::Frame { id }
| Hit::TableColumn { id, .. }
| Hit::TableRow { id, .. }
| Hit::Warp { id, .. },
) => Some(*id),
None => None,
@@ -1166,6 +1214,96 @@ impl Session {
}))
}
/// A click at `p` inside the text being edited: the caret goes there (`extend`: the selection
/// grows to it). In a table being edited, a click in another cell moves the caret into that
/// cell. Returns the edited shape and the caret, or `None` when `p` isn't in the edited text
/// (or is on a handle or a table border).
fn place_caret(&mut self, p: Point, tol: f64, extend: bool) -> Result<Option<(ShapeId, (usize, usize))>> {
let st = self.doc()?;
let Some(mut t) = st.selection.text.clone().filter(|t| !t.notes) else { return Ok(None) };
let Some(sh) = st.shape(t.shape) else { return Ok(None) };
// A text box: in its own (rotated) frame, short of the band that moves it (`Hit::Frame`).
// A table cell: anywhere over the table.
let inside = match t.cell {
None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside),
Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p),
};
if !inside
|| matches!(
self.hit_test(p, tol),
Some(
Hit::Resize { .. }
| Hit::Rotate { .. }
| Hit::Adjust { .. }
| Hit::Warp { .. }
| Hit::Frame { .. }
| Hit::TableColumn { .. }
| Hit::TableRow { .. }
)
)
{
return Ok(None);
}
let old_cell = t.cell;
if old_cell.is_some()
&& let Some(f) = cmd::table::table_frame(st, sh)
{
t.cell = f.cell_at(f.local(p)).or(old_cell);
}
let pos = cmd::text::text_pos_at(st, &t, p);
// A selection can't span cells: a click in another cell starts afresh.
let extend = extend && t.cell == old_cell;
let cell = t.cell;
self.select(|_, sel| {
if let Some(tt) = sel.text.as_mut() {
tt.cell = cell;
tt.caret = pos;
if !extend {
tt.anchor = pos;
}
}
})?;
Ok(Some((t.shape, pos)))
}
/// Start editing shape `id`'s text with the caret at slide point `p`; for a table, in the cell
/// at `p`. Returns whether editing started.
fn edit_text_at(&mut self, id: ShapeId, p: Point) -> Result<bool> {
let st = self.doc()?;
let Some(sh) = st.shape(id) else { return Ok(false) };
let params = match cmd::table::table_frame(st, sh) {
Some(f) => {
let Some((r, c)) = f.cell_at(f.local(p)) else { return Ok(false) };
let pos = cmd::text::text_pos_at(st, &TextSel { shape: id, cell: Some((r, c)), ..Default::default() }, p);
json!({"id": id, "cell": [r, c], "at": [pos.0, pos.1]})
}
None => {
let pos = text_pos(st, sh, p);
json!({"id": id, "at": [pos.0, pos.1]})
}
};
self.execute("text.edit", &params)?;
Ok(true)
}
/// Press on a table border: dragging it resizes the column or row. Editing a cell of the
/// table goes on; otherwise the table becomes the selection.
fn begin_table_border(&mut self, id: ShapeId, column: bool, index: usize, p: Point) -> Result<()> {
let st = self.doc()?;
let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) };
let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0);
let (start, frame) = (f.local(p), f.affine);
if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) {
self.select(|_, sel| {
sel.shapes = vec![id];
sel.text = None;
sel.cells = None;
})?;
}
self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: false });
Ok(())
}
fn is_picture(&self, id: ShapeId) -> bool {
self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_)))
}
@@ -1504,6 +1642,46 @@ fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option<S
None
}
/// The box whose text is being edited on the slide (not speaker notes, not a table cell).
struct EditedBox {
id: ShapeId,
xfrm: Xfrm,
/// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked.
movable: bool,
}
fn edited_box(st: &crate::DocState) -> Option<EditedBox> {
let t = st.selection.text.as_ref()?;
if t.notes || t.cell.is_some() {
return None;
}
let sh = st.shape(t.shape)?;
Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked })
}
/// Where a point falls against the box of the text being edited.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EditZone {
/// Well inside: the caret goes there.
Inside,
/// On the frame: a band `tol` wide on either side of the box edge.
Frame,
Outside,
}
/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside
/// small boxes the band narrows to a quarter of the box so their text stays reachable.
fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone {
let l = x.affine().inverse() * p;
let (w, h) = (x.w.max(0.0), x.h.max(0.0));
let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 };
if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol {
return EditZone::Outside;
}
let inner = tol.min(w / 4.0).min(h / 4.0);
if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame }
}
fn contains_rect(outer: Rect, inner: Rect) -> bool {
inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1
}
@@ -1617,6 +1795,15 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
let x = xfrm_of(&st.doc, &st.selection, sh);
let inv = x.affine().inverse();
let local = inv * p;
// Table borders resize rows and columns.
let near = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol;
if near && let Some(f) = cmd::table::table_frame(st, sh) {
match f.border_at(f.local(p), tol) {
Some(cmd::table::TableBorder::Column(col)) => return Some(Hit::TableColumn { id: sh.id, col }),
Some(cmd::table::TableBorder::Row(row)) => return Some(Hit::TableRow { id: sh.id, row }),
None => {}
}
}
match &sh.kind {
ShapeKind::Group { children, child } => {
// A group is hit when any child is hit; the group is what gets selected.
@@ -1685,18 +1872,7 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
/// Text position under slide point `p` in shape `sh`.
pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
let x = xfrm_of(&st.doc, &st.selection, sh);
let t = TextSel { shape: sh.id, ..Default::default() };
match cmd::text::layout_for(st, &t) {
Some(l) => {
// The text's own transform: flips don't mirror text, vertical text is turned.
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
let local = deckcraft_render::text_transform(x.affine(), tr, l.rotation).inverse() * p;
let h = l.hit(local);
(h.para, h.ch)
}
None => (0, 0),
}
cmd::text::text_pos_at(st, &TextSel { shape: sh.id, ..Default::default() }, p)
}
/// Row heights of table `t` as drawn: rows grow to fit their text.
@@ -1704,29 +1880,6 @@ pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec<f64>
cmd::table_drawn_heights(&st.doc, &st.selection, t)
}
/// Which table cell contains a table-local point (`heights` from [`table_rows`]).
fn deckcraft_render_cell(t: &deckcraft_model::Table, heights: &[f64], p: Point) -> (usize, usize) {
let mut y = 0.0;
let mut row = heights.len().saturating_sub(1);
for (i, h) in heights.iter().enumerate() {
if p.y < y + h {
row = i;
break;
}
y += h;
}
let mut x = 0.0;
let mut col = t.cols.len().saturating_sub(1);
for (i, w) in t.cols.iter().enumerate() {
if p.x < x + w {
col = i;
break;
}
x += w;
}
(row, col)
}
#[cfg(test)]
mod tests {
use super::*;