diff --git a/ROADMAP.md b/ROADMAP.md index c57012c..90dd8c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **403 / 434 (92.9%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **423 / 448 (94.4%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | | **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated | | **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions | | **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) | @@ -158,6 +158,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | Progress: Drawing Canvas (#344): insert, shapes/pictures/text boxes inside, Fit/Expand/Scale, DOCX `wpc:wpc`, turns and flips as a whole; catalog 404/434 | | 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) | | 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) | diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 218497a..663714d 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -383,6 +383,12 @@ pub enum InlineObject { ch_w: f32, ch_h: f32, children: Vec, + /// A Drawing Canvas (Insert › Shapes › New Drawing Canvas, DrawingML's `wpc:wpc`) rather + /// than a group: it has its own background and outline, and its frame is independent of + /// its members: resizing it doesn't scale them (`ch_w` × `ch_h` follow `w` × `h`), and + /// what lies outside the frame is clipped. + #[serde(default, skip_serializing_if = "Option::is_none")] + canvas: Option, }, /// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…); /// `result` the cached display text. A `locked` field keeps its result when fields update. @@ -482,6 +488,16 @@ pub struct GroupChild { /// Most members a group keeps (hostile files). pub const MAX_GROUP_CHILDREN: usize = 1000; +/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CanvasStyle { + pub fill: Option, + pub stroke: Option, + /// Outline width, points. + pub stroke_width: f32, +} + impl InlineObject { /// A picture, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped. pub fn is_drawing(&self) -> bool { @@ -507,7 +523,7 @@ impl InlineObject { _ => None, } } - /// Resize a drawing (a group's members scale with it). + /// Resize a drawing (a group's members scale with it; a canvas's keep their size and place). pub fn set_size(&mut self, nw: f32, nh: f32) { if let InlineObject::Image { w, h, .. } | InlineObject::Graphic { w, h, .. } @@ -517,6 +533,21 @@ impl InlineObject { *w = nw; *h = nh; } + if let InlineObject::Group { ch_w, ch_h, canvas: Some(_), .. } = self { + *ch_w = nw; + *ch_h = nh; + } + } + /// A Drawing Canvas's background and outline (`None` for anything else). + pub fn canvas_style(&self) -> Option<&CanvasStyle> { + match self { + InlineObject::Group { canvas, .. } => canvas.as_ref(), + _ => None, + } + } + /// Whether this is a Drawing Canvas. + pub fn is_canvas(&self) -> bool { + self.canvas_style().is_some() } /// The text box story this object shows, if it is a text box. pub fn text_box(&self) -> Option { diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 9d860cb..8845ef4 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -24,6 +24,11 @@ pub const PASSWORD_REQUIRED: &str = "this document is protected with a password" /// What [`DocxError::WrongPassword`] says. pub const WRONG_PASSWORD: &str = "the password is incorrect"; +/// The `a:ext` URI under a Drawing Canvas's `wpc:extLst` that holds its turn and flips as an +/// `a:xfrm` (`rot`, `flipH`, `flipV`). Neither the canvas schema nor `wp:anchor` carries a +/// transform, so this rides in the extension list Word and other readers skip. +pub(crate) const CANVAS_SPIN_EXT: &str = "urn:wordcraft:canvas-xfrm"; + /// Read a package that may be password-protected ([`is_encrypted`]): it is decrypted with /// `password` first. An encrypted package without a password gives /// [`DocxError::PasswordRequired`]; a wrong one, [`DocxError::WrongPassword`]. diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index ca2036e..193a36b 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -147,7 +147,7 @@ fn clean_text(s: &str) -> String { fn children_of_choice(ac: &El) -> Option<&El> { // Prefer a Choice whose requirements we understand; else the Fallback; else any Choice. - const KNOWN: &[&str] = &["wps", "wpg", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"]; + const KNOWN: &[&str] = &["wps", "wpg", "wpc", "w14", "w15", "wp14", "a14", "w16se", "w16cid", "w16", "w16cex", "w16sdtdh", "v"]; let ok = |c: &El| c.attr("Requires").unwrap_or("").split_whitespace().all(|r| KNOWN.contains(&r)); ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice")) } @@ -783,7 +783,9 @@ impl Reader<'_> { float.effect = float.effect.map(|e| e.max(0.0)); return Some(InlineObject::Graphic { w, h, alt, float, graphic }); } - let mut obj = if let Some(g) = gd.child("wpg:wgp") { + let mut obj = if let Some(c) = gd.child("wpc:wpc") { + self.read_canvas(sc, c, rels, w, h, float) + } else if let Some(g) = gd.child("wpg:wgp") { self.read_group(sc, g, rels, w, h, float)? } else if gd.find("a:blip").is_some() { self.read_pic(gd, rels, w, h, alt, float)? @@ -879,7 +881,29 @@ impl Reader<'_> { if children.is_empty() { return None; } - Some(InlineObject::Group { w, h, float, ch_w, ch_h, children }) + Some(InlineObject::Group { w, h, float, ch_w, ch_h, children, canvas: None }) + } + + /// A `wpc:wpc` Drawing Canvas (`w` × `h`, ECMA-376 Part 1 §20.4 and the Word 2010 canvas + /// schema): its `wpc:bg` fill, `wpc:whole` outline and members, which sit in the canvas's + /// own space (offsets from its top-left corner, unscaled). An empty canvas is kept. + /// The canvas schema has no `a:xfrm` of its own (nor does the anchor), so a turned or + /// flipped canvas keeps its turn in our `wpc:extLst` extension ([`CANVAS_SPIN_EXT`]). + fn read_canvas(&mut self, sc: &mut StoryCtx, c: &El, rels: &Rels, w: f32, h: f32, mut float: Float) -> InlineObject { + let spin_ext = c.child("wpc:extLst").and_then(|l| l.els().find(|e| e.name == "a:ext" && e.attr("uri") == Some(crate::CANVAS_SPIN_EXT))); + float.set_spin(xfrm_spin(spin_ext)); + let solid = |e: Option<&El>| { + e.and_then(|f| f.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| c.attr("val")).and_then(Rgb::parse) + }; + let ln = c.child("wpc:whole").and_then(|e| e.child("a:ln")); + let style = wordcraft_doc::para::CanvasStyle { + fill: solid(c.child("wpc:bg")), + stroke: solid(ln), + stroke_width: ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0), + }; + let mut children = Vec::new(); + self.group_members(sc, c, rels, (0.0, 0.0, 1.0, 1.0), 0, &mut children); + InlineObject::Group { w, h, float, ch_w: w.max(1.0), ch_h: h.max(1.0), children, canvas: Some(style) } } /// The members of group `g` into `out`, mapped into the outermost group's space by `t` @@ -908,12 +932,13 @@ impl Reader<'_> { let sppr = match e.name.as_str() { "wps:wsp" => e.child("wps:spPr"), "pic:pic" => e.child("pic:spPr"), - "wpg:grpSp" => e.child("wpg:grpSpPr"), + // A group inside a canvas is a whole `wpg:wgp`. + "wpg:grpSp" | "wpg:wgp" => e.child("wpg:grpSpPr"), _ => continue, }; let x = group_xfrm(sppr); let ((ox, oy), (ew, eh)) = (x.off, x.ext); - if e.name == "wpg:grpSp" { + if e.name == "wpg:grpSp" || e.name == "wpg:wgp" { if depth >= 8 { continue; } diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index bd9ec0e..875c9a0 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -501,29 +501,52 @@ impl Writer<'_> { w.close("w:r"); self.rev_close(w, props); } - InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children } => { + InlineObject::Group { w: gw, h: gh, float, ch_w, ch_h, children, canvas } => { self.rev_open(w, props); w.open("w:r", &[]); self.rpr(w, props); w.open("w:drawing", &[]); let docpr = self.next_docpr(); - let name = format!("Group {docpr}"); + let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" }); self.drawing_open(w, float, *gw, *gh, &docpr, &name, ""); w.empty("wp:cNvGraphicFramePr", &[]); w.open("a:graphic", &[]); - w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]); - // ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt` - // maps onto the group's `a:off`/`a:ext`. - w.open("wpg:wgp", &[]); - w.empty("wpg:cNvGrpSpPr", &[]); - w.open("wpg:grpSpPr", &[]); - w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::>()); - w.empty("a:off", &[("x", "0"), ("y", "0")]); - w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]); - w.empty("a:chOff", &[("x", "0"), ("y", "0")]); - w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]); - w.close("a:xfrm"); - w.close("wpg:grpSpPr"); + let outer = match canvas { + Some(c) => { + // A Drawing Canvas: its background (`wpc:bg`) and outline (`wpc:whole`), then + // its members, at offsets in the canvas's own (unscaled) space. + w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]); + w.open("wpc:wpc", &[]); + w.open("wpc:bg", &[]); + solid_fill(w, c.fill); + w.close("wpc:bg"); + w.open("wpc:whole", &[]); + match c.stroke { + Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]), + None => w.open("a:ln", &[]), + } + solid_fill(w, c.stroke); + w.close("a:ln"); + w.close("wpc:whole"); + "wpc:wpc" + } + None => { + w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup")]); + // ECMA-376 §20.1.7.6 (`a:xfrm` of a group): the members' space `a:chOff`/`a:chExt` + // maps onto the group's `a:off`/`a:ext`. + w.open("wpg:wgp", &[]); + w.empty("wpg:cNvGrpSpPr", &[]); + w.open("wpg:grpSpPr", &[]); + w.open("a:xfrm", &spin_attrs(float.spin()).iter().map(|(k, v)| (*k, v.as_str())).collect::>()); + w.empty("a:off", &[("x", "0"), ("y", "0")]); + w.empty("a:ext", &[("cx", &emu(gw.max(0.0))), ("cy", &emu(gh.max(0.0)))]); + w.empty("a:chOff", &[("x", "0"), ("y", "0")]); + w.empty("a:chExt", &[("cx", &emu(ch_w.max(0.0))), ("cy", &emu(ch_h.max(0.0)))]); + w.close("a:xfrm"); + w.close("wpg:grpSpPr"); + "wpg:wgp" + } + }; for c in children.iter().take(wordcraft_doc::para::MAX_GROUP_CHILDREN) { let at = (c.x, c.y); match &c.obj { @@ -539,7 +562,16 @@ impl Writer<'_> { _ => {} } } - w.close("wpg:wgp"); + if canvas.is_some() && float.spin() != wordcraft_geom::Spin::default() { + // The canvas schema has no transform of its own: keep its turn in our + // extension (Word draws it unturned, as it can't turn a canvas). + w.open("wpc:extLst", &[]); + w.open("a:ext", &[("uri", crate::CANVAS_SPIN_EXT)]); + xfrm_spin_only(w, float.spin()); + w.close("a:ext"); + w.close("wpc:extLst"); + } + w.close(outer); w.close("a:graphicData"); w.close("a:graphic"); w.close(if float.wrap == Wrap::Inline { "wp:inline" } else { "wp:anchor" }); @@ -1177,6 +1209,11 @@ fn xfrm(w: &mut W, (x, y): (f32, f32), cw: f32, ch: f32, spin: wordcraft_geom::S w.close("a:xfrm"); } +/// An `a:xfrm` holding only a rotation and flips. +fn xfrm_spin_only(w: &mut W, spin: wordcraft_geom::Spin) { + w.empty("a:xfrm", &spin_attrs(spin).iter().map(|(k, v)| (*k, v.as_str())).collect::>()); +} + /// The `a:xfrm` attributes of a rotation and flips (ECMA-376 §20.1.7.6: `rot` in 60000ths of a /// degree, clockwise), none for an unturned object. pub(super) fn spin_attrs(spin: wordcraft_geom::Spin) -> Vec<(&'static str, String)> { @@ -1227,3 +1264,15 @@ fn toc_span(bl: &Blocks) -> Option<(usize, usize)> { .count(); (entries > 0).then_some((start, start + entries)) } + +/// A DrawingML `a:solidFill` in `c`, or `a:noFill`. +fn solid_fill(w: &mut W, c: Option) { + match c { + Some(c) => { + w.open("a:solidFill", &[]); + w.empty("a:srgbClr", &[("val", &c.hex())]); + w.close("a:solidFill"); + } + None => w.empty("a:noFill", &[]), + } +} diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index 4460a7c..5b7e704 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -30,6 +30,7 @@ pub const NAMESPACES: &[(&str, &str)] = &[ ("wp14", "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"), ("wps", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"), ("wpg", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"), + ("wpc", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas"), ("dgm", "http://schemas.openxmlformats.org/drawingml/2006/diagram"), ("dsp", "http://schemas.microsoft.com/office/drawing/2008/diagram"), ("m", "http://schemas.openxmlformats.org/officeDocument/2006/math"), diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index d910a07..389c8f3 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -954,7 +954,7 @@ fn word_group_with_nested_group() { "#; let d = read_body(body); let p = paras(&d); - let InlineObject::Group { w, h, float, ch_w, ch_h, children } = &p[0].objects[0] else { panic!("{:?}", p[0].objects) }; + let InlineObject::Group { w, h, float, ch_w, ch_h, children, .. } = &p[0].objects[0] else { panic!("{:?}", p[0].objects) }; assert_eq!((*w, *h, *ch_w, *ch_h), (200.0, 100.0, 200.0, 100.0)); assert_eq!(float.wrap, Wrap::Square); let got: Vec<(f32, f32, ShapeKind, f32, f32)> = children diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 6c124a8..c41874e 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -926,6 +926,7 @@ fn groups_round_trip() { GroupChild { x: 150.0, y: 10.0, obj: oval.clone() }, GroupChild { x: 50.0, y: 60.0, obj: tb }, ], + canvas: None, }; let mut p = Paragraph::with_text("grouped ", CharProps::default()); let end = p.len(); @@ -933,7 +934,7 @@ fn groups_round_trip() { d.body = vec![para_block(p)]; let r = rt(&d); let got = paras(&r); - let InlineObject::Group { w, h, float: f, ch_w, ch_h, children } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) }; + let InlineObject::Group { w, h, float: f, ch_w, ch_h, children, .. } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) }; assert_eq!((*w, *h, *ch_w, *ch_h), (240.0, 120.0, 200.0, 100.0)); assert_eq!(*f, float); assert_eq!(children.iter().map(|c| (c.x, c.y)).collect::>(), [(0.0, 0.0), (150.0, 10.0), (50.0, 60.0)]); @@ -954,6 +955,106 @@ fn groups_round_trip() { } } +/// A Drawing Canvas (`wpc:wpc`, #344) with a picture and a shape comes back as a canvas: its +/// frame, background and outline, and each member at its offset in the canvas, unscaled. +#[test] +fn drawing_canvas_round_trips() { + use wordcraft_doc::para::{CanvasStyle, GroupChild}; + let mut d = Document::new(); + let key = d.add_media(tiny_png(), "png"); + let pic = InlineObject::Image { media: key, w: 60.0, h: 40.0, alt: "In the canvas".into(), float: Float::default(), crop: [0.0; 4], ole: None }; + let star = InlineObject::Shape { + kind: ShapeKind::Star, + w: 72.0, + h: 72.0, + fill: Some(Rgb(0xE0, 0xA0, 0x20)), + stroke: Some(Rgb(0x40, 0x40, 0x40)), + stroke_width: 1.0, + float: Float::default(), + story: None, + effects: Default::default(), + freeform: None, + }; + let style = CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: Some(Rgb(0x20, 0x40, 0x80)), stroke_width: 1.5 }; + let canvas = InlineObject::Group { + w: 432.0, + h: 216.0, + float: Float::default(), + ch_w: 432.0, + ch_h: 216.0, + children: vec![GroupChild { x: 12.0, y: 18.0, obj: pic }, GroupChild { x: 300.0, y: 100.0, obj: star.clone() }], + canvas: Some(style), + }; + let mut p = Paragraph::with_text("drawn ", CharProps::default()); + let end = p.len(); + p.insert_object(end, canvas, &CharProps::default()).unwrap(); + d.body = vec![para_block(p)]; + let bytes = wordcraft_docx::write(&d).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); + assert!(xml.contains("wordprocessingCanvas") && xml.contains("") && xml.contains("") && xml.contains(""), "{xml}"); + let r = wordcraft_docx::read(&bytes).unwrap(); + let got = paras(&r); + let InlineObject::Group { w, h, ch_w, ch_h, children, canvas, .. } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) }; + assert_eq!((*w, *h, *ch_w, *ch_h), (432.0, 216.0, 432.0, 216.0)); + assert_eq!(*canvas, Some(style)); + assert_eq!(children.iter().map(|c| (c.x, c.y)).collect::>(), [(12.0, 18.0), (300.0, 100.0)]); + assert!(matches!(&children[0].obj, InlineObject::Image { w, h, alt, .. } if (*w, *h, alt.as_str()) == (60.0, 40.0, "In the canvas"))); + assert_eq!(children[1].obj, star); +} + +/// A turned and flipped Drawing Canvas (#344 with #332): the canvas schema has no transform, so +/// its turn rides in our `wpc:extLst` extension, after its members; members keep their own turn. +/// An unturned canvas writes no extension. +#[test] +fn rotated_drawing_canvas_round_trips() { + use wordcraft_doc::para::{CanvasStyle, GroupChild}; + let member = InlineObject::Shape { + kind: ShapeKind::Rectangle, + w: 60.0, + h: 40.0, + fill: Some(Rgb(1, 2, 3)), + stroke: None, + stroke_width: 0.0, + float: Float { rot: 15.0, ..Default::default() }, + story: None, + effects: Default::default(), + freeform: None, + }; + let canvas = |float: Float| InlineObject::Group { + w: 200.0, + h: 100.0, + float, + ch_w: 200.0, + ch_h: 100.0, + children: vec![GroupChild { x: 10.0, y: 20.0, obj: member.clone() }], + canvas: Some(CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: None, stroke_width: 0.0 }), + }; + let square = Float { wrap: Wrap::Square, ..Default::default() }; + let mut p = Paragraph::with_text("c", CharProps::default()); + p.insert_object(1, canvas(Float { rot: 30.0, flip_h: true, ..square.clone() }), &CharProps::default()).unwrap(); + let end = p.len(); + p.insert_object(end, canvas(square), &CharProps::default()).unwrap(); + let bytes = wordcraft_docx::write(&doc_with(vec![p])).expect("write"); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); + assert!( + xml.contains(r#""#), + "{xml}" + ); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + let r = wordcraft_docx::read(&bytes).expect("read"); + let got = ¶s(&r)[0].objects; + let spins: Vec<(f32, bool, bool)> = got.iter().map(|o| o.frame().unwrap().2).map(|f| (f.rot, f.flip_h, f.flip_v)).collect(); + assert_eq!(spins, [(30.0, true, false), (0.0, false, false)]); + let InlineObject::Group { children, canvas, .. } = &got[0] else { panic!("{got:?}") }; + assert!(canvas.is_some()); + assert_eq!(children.len(), 1); + assert_eq!(children[0].obj.frame().unwrap().2.rot, 15.0); +} + #[test] fn compatibility_mode_round_trips() { // New documents are Word 2013+ documents; an older file keeps its mode, so saving it doesn't @@ -1385,6 +1486,7 @@ fn rotation_and_flips_round_trip() { ch_w: 120.0, ch_h: 60.0, children: vec![wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Ellipse, Float { rot: 10.0, ..Default::default() }) }], + canvas: None, }; let mut p = Paragraph::with_text("turned ", CharProps::default()); for o in [pic, tri, flipped, group] { @@ -1440,6 +1542,7 @@ fn rotated_group_and_shadow_rotation_round_trip() { wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj: member(true) }, wordcraft_doc::para::GroupChild { x: 60.0, y: 0.0, obj: member(false) }, ], + canvas: None, }; let mut p = Paragraph::with_text("g", CharProps::default()); p.insert_object(1, group, &CharProps::default()).unwrap(); diff --git a/crates/engine/src/cmd/canvas.rs b/crates/engine/src/cmd/canvas.rs new file mode 100644 index 0000000..57e3733 --- /dev/null +++ b/crates/engine/src/cmd/canvas.rs @@ -0,0 +1,326 @@ +//! Drawing Canvas (#344): Insert › Illustrations › Shapes › New Drawing Canvas. +//! +//! A canvas is an [`InlineObject::Group`] with a [`CanvasStyle`]: a frame with its own fill and +//! outline that holds pictures, shapes and text boxes at offsets in the canvas (points from its +//! top-left corner, unscaled). While a canvas is selected, Insert › Pictures, Shapes and Text Box +//! put the new object inside it ([`add_member`]). Fit, Expand and Scale Drawing change its frame; +//! Shape Fill and Shape Outline colour its background and border. + +use serde_json::{Value, json}; +use wordcraft_doc::para::{CanvasStyle, Float, GroupChild, InlineObject, MAX_GROUP_CHILDREN}; +use wordcraft_doc::{PartKind, Pos, StoryRef}; + +use super::objects::object_selection; +use super::{delete_selection, sel_result}; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +/// Largest canvas side and member offset, points (well past any page). +const MAX_SIDE: f32 = 4000.0; +/// Smallest canvas side, points. +const MIN_SIDE: f32 = 9.0; +/// Room Fit leaves around the members, points (none: Word's Fit hugs the drawing). +const FIT_MARGIN: f32 = 0.0; + +fn has_canvas(s: &Session) -> Option<&'static str> { + if selected_canvas(s).is_some() { None } else { Some("select a drawing canvas first") } +} + +/// The selected Drawing Canvas: its position and the canvas. +pub fn selected_canvas(s: &Session) -> Option<(Pos, &InlineObject)> { + object_selection(s).filter(|(_, o)| o.is_canvas()) +} + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("insert.canvas", "Drawing Canvas", "Insert › Illustrations", insert) + .params(r#"{"width"?: pt (default 432 = 6"), "height"?: pt (default 216 = 3"), "fill"?: "RRGGBB", "outline"?: "RRGGBB"} (in line with text at the caret; it stays selected, so the next Pictures, Shapes or Text Box goes inside it)"#) + .when(|s| { + matches!(s.sel.focus.story, StoryRef::Part(id) if s.doc.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox)) + .then_some("a drawing canvas can't go inside a text box") + }), + CommandSpec::new("canvas.fit", "Fit", "Shape Format › Drawing Canvas", fit) + .params(r#"{} (shrinks the selected canvas's frame to its drawing: the members move to its top-left corner)"#) + .when(has_canvas), + CommandSpec::new("canvas.expand", "Expand", "Shape Format › Drawing Canvas", expand) + .params(r#"{"by"?: pt (default 36)} (grows the frame right and down; at least enough to show every member)"#) + .when(has_canvas), + CommandSpec::new("canvas.scale", "Scale Drawing", "Shape Format › Drawing Canvas", scale) + .params(r#"{"factor": number (0.1–10)} (scales the frame and every member together)"#) + .when(has_canvas), + CommandSpec::new("canvas.member", "Canvas Member", "Shape Format › Drawing Canvas", member) + .params(r#"{"index": n, "x"?: pt, "y"?: pt, "width"?: pt, "height"?: pt, "delete"?: bool} (moves, resizes or removes member `index` of the selected canvas; x/y from the canvas's top-left; with no change, returns the members)"#) + .when(has_canvas), + ] +} + +/// `insert.canvas`: a new empty canvas in line with text at the caret, selected. +fn insert(s: &mut Session, v: &Value) -> CmdResult { + let w = p::f32(v, "width").unwrap_or(432.0).clamp(MIN_SIDE, MAX_SIDE); + let h = p::f32(v, "height").unwrap_or(216.0).clamp(MIN_SIDE, MAX_SIDE); + let style = CanvasStyle { + fill: p::str(v, "fill").and_then(wordcraft_doc::props::Rgb::parse), + stroke: p::str(v, "outline").and_then(wordcraft_doc::props::Rgb::parse), + stroke_width: 0.75, + }; + let props = s.typing_props(); + let at = delete_selection(s)?; + let obj = InlineObject::Group { w, h, float: Float::default(), ch_w: w, ch_h: h, children: Vec::new(), canvas: Some(style) }; + let end = s.doc.insert_object(&at, obj, &props)?; + s.sel = Selection { anchor: at, focus: end }; + sel_result(s) +} + +/// With a Drawing Canvas selected, put `obj` inside it rather than at the caret: centred, each +/// later one a little lower and to the right, shrunk (keeping its proportions) to fit. Returns +/// the member's index, or `None` when no canvas is selected (insert as usual). +pub fn add_member(s: &mut Session, obj: InlineObject) -> Result, CmdError> { + let Some((pos, canvas)) = selected_canvas(s) else { return Ok(None) }; + let InlineObject::Group { w: cw, h: ch, children, .. } = canvas else { return Ok(None) }; + if children.len() >= MAX_GROUP_CHILDREN { + return Err(CmdError::Failed("the drawing canvas is full".into())); + } + let (cw, ch, n) = (*cw, *ch, children.len()); + let mut obj = obj; + let (ow, oh, _) = obj.frame().ok_or_else(|| CmdError::Params("not a picture or shape".into()))?; + let k = (cw / ow.max(1.0)).min(ch / oh.max(1.0)).min(1.0); + let (ow, oh) = ((ow * k).max(1.0), (oh * k).max(1.0)); + obj.set_size(ow, oh); + if let Some(f) = obj.float_mut() { + *f = Float::default(); + } + let step = 12.0 * (n % 6) as f32; + let x = ((cw - ow) / 2.0 + step).clamp(0.0, (cw - ow).max(0.0)); + let y = ((ch - oh) / 2.0 + step).clamp(0.0, (ch - oh).max(0.0)); + edit_canvas(s, &pos, |_, _, children| children.push(GroupChild { x, y, obj: obj.clone() }))?; + Ok(Some(n)) +} + +/// Change the canvas at `pos`: `f` gets its frame size and members (and may change them). +fn edit_canvas(s: &mut Session, pos: &Pos, f: impl FnOnce(&mut f32, &mut f32, &mut Vec)) -> Result { + let para = s.doc.para_mut(pos.story, &pos.path)?; + let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?; + let InlineObject::Group { w, h, ch_w, ch_h, children, canvas: Some(_), .. } = o else { + return Err(CmdError::Disabled("select a drawing canvas first".into())); + }; + f(w, h, children); + *w = clean(*w).clamp(MIN_SIDE, MAX_SIDE); + *h = clean(*h).clamp(MIN_SIDE, MAX_SIDE); + (*ch_w, *ch_h) = (*w, *h); + let o = o.clone(); + para.touch(); + s.touch(); + Ok(o) +} + +fn clean(v: f32) -> f32 { + if v.is_finite() { v } else { 0.0 } +} + +fn canvas_pos(s: &Session) -> Result { + selected_canvas(s).map(|(p, _)| p).ok_or_else(|| CmdError::Disabled("select a drawing canvas first".into())) +} + +fn result(o: &InlineObject) -> CmdResult { + Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)})) +} + +/// The members' bounds (x0, y0, x1, y1) in the canvas, or `None` when it is empty. +fn bounds(children: &[GroupChild]) -> Option<[f32; 4]> { + children + .iter() + .filter_map(|c| c.obj.frame().map(|(w, h, _)| [clean(c.x), clean(c.y), clean(c.x) + clean(w), clean(c.y) + clean(h)])) + .reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]) +} + +/// `canvas.fit`: the frame shrinks (or grows) to the drawing, which moves to its corner. +fn fit(s: &mut Session, _: &Value) -> CmdResult { + let pos = canvas_pos(s)?; + let o = edit_canvas(s, &pos, |w, h, children| { + let Some([x0, y0, x1, y1]) = bounds(children) else { return }; + for c in children.iter_mut() { + c.x = (c.x - x0 + FIT_MARGIN).clamp(-MAX_SIDE, MAX_SIDE); + c.y = (c.y - y0 + FIT_MARGIN).clamp(-MAX_SIDE, MAX_SIDE); + } + *w = x1 - x0 + 2.0 * FIT_MARGIN; + *h = y1 - y0 + 2.0 * FIT_MARGIN; + })?; + result(&o) +} + +/// `canvas.expand`: more room to the right and below, at least enough for every member. +fn expand(s: &mut Session, v: &Value) -> CmdResult { + let by = p::f32(v, "by").unwrap_or(36.0).clamp(0.0, MAX_SIDE); + let pos = canvas_pos(s)?; + let o = edit_canvas(s, &pos, |w, h, children| { + let [_, _, x1, y1] = bounds(children).unwrap_or([0.0; 4]); + *w = (*w + by).max(x1); + *h = (*h + by).max(y1); + })?; + result(&o) +} + +/// `canvas.scale`: the frame and the drawing scale together. +fn scale(s: &mut Session, v: &Value) -> CmdResult { + let k = p::req_f32(v, "factor")?; + if !(0.1..=10.0).contains(&k) { + return Err(CmdError::Params("`factor` must be between 0.1 and 10".into())); + } + let pos = canvas_pos(s)?; + let o = edit_canvas(s, &pos, |w, h, children| { + *w *= k; + *h *= k; + for c in children.iter_mut() { + c.x = (c.x * k).clamp(-MAX_SIDE, MAX_SIDE); + c.y = (c.y * k).clamp(-MAX_SIDE, MAX_SIDE); + if let Some((mw, mh, _)) = c.obj.frame() { + c.obj.set_size((mw * k).clamp(1.0, MAX_SIDE), (mh * k).clamp(1.0, MAX_SIDE)); + } + } + })?; + result(&o) +} + +/// `canvas.member`: move, resize or remove one member; with no change, list the members. +fn member(s: &mut Session, v: &Value) -> CmdResult { + let pos = canvas_pos(s)?; + let Some(i) = p::u64(v, "index").and_then(|i| usize::try_from(i).ok()) else { + let Some((_, InlineObject::Group { children, .. })) = selected_canvas(s) else { + return Err(CmdError::Disabled("select a drawing canvas first".into())); + }; + let list: Vec = children + .iter() + .enumerate() + .map(|(i, c)| { + let (w, h) = c.obj.frame().map(|(w, h, _)| (w, h)).unwrap_or((0.0, 0.0)); + json!({"index": i, "x": c.x, "y": c.y, "width": w, "height": h}) + }) + .collect(); + return Ok(json!({"members": list})); + }; + let count = match selected_canvas(s) { + Some((_, InlineObject::Group { children, .. })) => children.len(), + _ => 0, + }; + if i >= count { + return Err(CmdError::Params(format!("no member {i} (the canvas has {count})"))); + } + let (x, y, nw, nh) = (p::f32(v, "x"), p::f32(v, "y"), p::f32(v, "width"), p::f32(v, "height")); + let delete = p::bool(v, "delete").unwrap_or(false); + let o = edit_canvas(s, &pos, |_, _, children| { + if delete { + if i < children.len() { + children.remove(i); + } + return; + } + let Some(c) = children.get_mut(i) else { return }; + if let Some(x) = x { + c.x = x.clamp(-MAX_SIDE, MAX_SIDE); + } + if let Some(y) = y { + c.y = y.clamp(-MAX_SIDE, MAX_SIDE); + } + if let Some((w0, h0, _)) = c.obj.frame() { + c.obj.set_size(nw.unwrap_or(w0).clamp(1.0, MAX_SIDE), nh.unwrap_or(h0).clamp(1.0, MAX_SIDE)); + } + })?; + result(&o) +} + +/// Shape Fill and Shape Outline on a selected canvas colour its background and border. +pub fn style_mut(o: &mut InlineObject) -> Option<&mut CanvasStyle> { + match o { + InlineObject::Group { canvas: Some(c), .. } => Some(c), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn canvas(s: &Session) -> InlineObject { + selected_canvas(s).map(|(_, o)| o.clone()).unwrap() + } + fn members(o: &InlineObject) -> Vec<(f32, f32, f32, f32)> { + let InlineObject::Group { children, .. } = o else { panic!("{o:?}") }; + children + .iter() + .map(|c| { + let (w, h, _) = c.obj.frame().unwrap(); + (c.x, c.y, w, h) + }) + .collect() + } + + /// Insert › Drawing Canvas, then Shapes and Text Box: the new objects go inside the + /// selected canvas, not into the text, and the canvas's frame keeps its size (#344). + #[test] + fn shapes_inserted_with_a_canvas_selected_go_inside_it() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "Before "})).unwrap(); + s.run("insert.canvas", &json!({})).unwrap(); + let c = canvas(&s); + assert_eq!(c.frame().map(|(w, h, _)| (w, h)), Some((432.0, 216.0))); + s.run("insert.shape", &json!({"kind": "ellipse", "width": 100, "height": 50})).unwrap(); + s.run("insert.shape", &json!({"kind": "star", "width": 60, "height": 60})).unwrap(); + let c = canvas(&s); + let m = members(&c); + assert_eq!(m.len(), 2); + assert_eq!(m[0], (166.0, 83.0, 100.0, 50.0), "centred in the canvas"); + assert_eq!((m[1].2, m[1].3), (60.0, 60.0)); + // Only the canvas sits in the text. + let para = s.doc.body.first().and_then(|b| b.as_para()).unwrap(); + assert_eq!(para.objects.len(), 1); + // Fill and outline colour the canvas. + s.run("shape.fill", &json!({"color": "FFF2CC"})).unwrap(); + s.run("shape.outline", &json!({"color": "1F4E79", "width": 1.5})).unwrap(); + let style = *canvas(&s).canvas_style().unwrap(); + assert_eq!( + style, + CanvasStyle { fill: wordcraft_doc::props::Rgb::parse("FFF2CC"), stroke: wordcraft_doc::props::Rgb::parse("1F4E79"), stroke_width: 1.5 } + ); + // Members move, resize and go within the canvas. + s.run("canvas.member", &json!({"index": 1, "x": 10, "y": 20, "width": 30})).unwrap(); + assert_eq!(members(&canvas(&s))[1], (10.0, 20.0, 30.0, 60.0)); + // A text box goes in too, with its own story. + s.run("select.objects", &json!({"index": 0})).unwrap(); + s.run("insert.textBox", &json!({"text": "Label"})).unwrap(); + let para = s.doc.body.first().and_then(|b| b.as_para()).unwrap(); + assert_eq!(para.objects.len(), 1); + assert_eq!(para.objects[0].text_boxes().len(), 1); + // Laid out: the background, then the members clipped to the frame. + let pages = s.layout().pages.clone(); + assert!(!pages.is_empty()); + } + + /// Fit shrinks the frame to the drawing and moves the drawing to its corner; Expand grows it; + /// Scale Drawing scales frame and members together. + #[test] + fn fit_expand_and_scale_change_the_frame() { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap(); + s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap(); + s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap(); + s.run("canvas.member", &json!({"index": 0, "x": 40, "y": 30})).unwrap(); + s.run("canvas.member", &json!({"index": 1, "x": 200, "y": 100})).unwrap(); + s.run("canvas.fit", &json!({})).unwrap(); + let c = canvas(&s); + assert_eq!(c.frame().map(|(w, h, _)| (w, h)), Some((210.0, 120.0))); + assert_eq!(members(&c), [(0.0, 0.0, 100.0, 40.0), (160.0, 70.0, 50.0, 50.0)]); + s.run("canvas.expand", &json!({"by": 20})).unwrap(); + assert_eq!(canvas(&s).frame().map(|(w, h, _)| (w, h)), Some((230.0, 140.0))); + s.run("canvas.scale", &json!({"factor": 0.5})).unwrap(); + let c = canvas(&s); + assert_eq!(c.frame().map(|(w, h, _)| (w, h)), Some((115.0, 70.0))); + assert_eq!(members(&c)[1], (80.0, 35.0, 25.0, 25.0)); + assert!(s.run("canvas.scale", &json!({"factor": 0.0})).is_err()); + // Undo puts the drawing back as it was before the scale. + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(canvas_in_text(&s).frame().map(|(w, h, _)| (w, h)), Some((230.0, 140.0))); + } + + fn canvas_in_text(s: &Session) -> InlineObject { + s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()).cloned().unwrap() + } +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 41742ce..d33144b 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -240,9 +240,13 @@ fn place_picture(s: &mut Session, bytes: Vec, width: Option, alt: &str) h *= max_w / w; w = max_w; } + let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None }; + // A selected Drawing Canvas takes it in. + if let Some(i) = super::canvas::add_member(s, obj.clone())? { + return Ok(json!({"media": key, "canvasMember": i})); + } let props = s.typing_props(); let at = delete_selection(s)?; - let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None }; let end = s.doc.insert_object(&at, obj, &props)?; s.sel = Selection { anchor: at, focus: end }; Ok(json!({"media": key, "width": w, "height": h})) @@ -291,8 +295,6 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult { None => Some(Rgb(0x15, 0x60, 0x82)), }; let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A))); - let props = s.typing_props(); - let at = delete_selection(s)?; let obj = InlineObject::Shape { kind, w, @@ -305,6 +307,12 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult { freeform: None, effects: Default::default(), }; + // A selected Drawing Canvas takes it in (and stays selected for the next one). + if super::canvas::add_member(s, obj.clone())?.is_some() { + return sel_result(s); + } + let props = s.typing_props(); + let at = delete_selection(s)?; let end = s.doc.insert_object(&at, obj, &props)?; s.sel = Selection { anchor: at, focus: end }; sel_result(s) @@ -315,8 +323,6 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult { let w = p::f32(v, "width").unwrap_or(144.0).clamp(18.0, 2000.0); let h = p::f32(v, "height").unwrap_or(72.0).clamp(18.0, 2000.0); let id = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(&text, CharProps::default()))]); - let props = s.typing_props(); - let at = delete_selection(s)?; let obj = InlineObject::Shape { kind: ShapeKind::TextBox, w, @@ -329,7 +335,12 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult { freeform: None, effects: Default::default(), }; - s.doc.insert_object(&at, obj, &props)?; + // A selected Drawing Canvas takes it in; otherwise it goes at the caret. + if super::canvas::add_member(s, obj.clone())?.is_none() { + let props = s.typing_props(); + let at = delete_selection(s)?; + s.doc.insert_object(&at, obj, &props)?; + } // Like Word, type straight into the new box. s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id))); Ok(json!({"story": id})) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f104b52..b772743 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,5 +1,6 @@ //! Command implementations, one module per area. Each module exposes `specs()`. +pub mod canvas; pub mod caret; pub mod citations; pub mod column; @@ -65,6 +66,7 @@ pub fn registry() -> Registry { v.extend(citations::specs()); v.extend(objects::specs()); v.extend(draw::specs()); + v.extend(canvas::specs()); v.extend(tools::specs()); v.extend(speech::specs()); Registry::new(v) diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index b80adb0..74b85ad 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -40,10 +40,18 @@ fn has_movable(s: &Session) -> Option<&'static str> { } fn has_group(s: &Session) -> Option<&'static str> { match selected(s) { + Some((_, o)) if o.is_canvas() => Some("a drawing canvas can't be ungrouped"), Some((_, InlineObject::Group { .. })) => None, _ => Some("select a group first"), } } +/// Shape Fill and Shape Outline: a shape, or a Drawing Canvas's background and border. +fn has_shape_or_canvas(s: &Session) -> Option<&'static str> { + match selected(s) { + Some((_, o)) if o.is_canvas() => None, + _ => has_shape(s), + } +} fn can_group(s: &Session) -> Option<&'static str> { if picked_objects(s).len() >= 2 { None } else { Some("select two or more pictures or shapes first (Shift+click)") } } @@ -329,25 +337,32 @@ pub fn specs() -> Vec { with_obj(s, |o| { if let InlineObject::Shape { fill, .. } = o { *fill = c; + } else if let Some(style) = super::canvas::style_mut(o) { + style.fill = c; } }) }) - .params(r#"{"color": "RRGGBB" | null}"#) - .when(has_shape), + .params(r#"{"color": "RRGGBB" | null} (a selected drawing canvas: its background)"#) + .when(has_shape_or_canvas), CommandSpec::new("shape.outline", "Shape Outline", "Shape Format › Shape Styles", |s, v| { let c = p::str(v, "color").and_then(Rgb::parse); - let w = p::f32(v, "width"); + let w = p::f32(v, "width").map(|w| w.clamp(0.0, 72.0)); with_obj(s, |o| { if let InlineObject::Shape { stroke, stroke_width, .. } = o { *stroke = c; if let Some(w) = w { - *stroke_width = w.clamp(0.0, 72.0); + *stroke_width = w; + } + } else if let Some(style) = super::canvas::style_mut(o) { + style.stroke = c; + if let Some(w) = w { + style.stroke_width = w; } } }) }) - .params(r#"{"color": "RRGGBB" | null, "width"?: pt}"#) - .when(has_shape), + .params(r#"{"color": "RRGGBB" | null, "width"?: pt} (a selected drawing canvas: its border)"#) + .when(has_shape_or_canvas), CommandSpec::new("shape.effects", "Shape Effects", "Shape Format › Shape Styles", shape_effects) .params( r#"{"shadow"?: preset|{"preset"?, "color"?: "RRGGBB", "transparency"?: %, "blur"?: pt, "distance"?: pt, "angle"?: deg}|null, "glow"?: pt|{"color"?: "RRGGBB", "size"?: pt, "transparency"?: %}|null, "softEdge"?: pt|null} (shadow presets: offsetBottomRight, offsetBottom, offsetBottomLeft, offsetRight, offsetCenter, offsetLeft, offsetTopRight, offsetTop, offsetTopLeft; an omitted key is left as it is; applies to every shape in the selection)"#, @@ -961,6 +976,9 @@ fn group(s: &mut Session, _: &Value) -> CmdResult { if matches!(obj, InlineObject::Graphic { .. }) { return Err(CmdError::Disabled(FROZEN.into())); } + if obj.is_canvas() { + return Err(CmdError::Disabled("a drawing canvas can't be grouped".into())); + } if !obj.is_floating() { return Err(CmdError::Disabled("objects in line with text can't be grouped: choose a text wrapping for them first".into())); } @@ -1007,7 +1025,7 @@ fn group(s: &mut Session, _: &Value) -> CmdResult { .collect(); let mut float = found.first().and_then(|(o, _)| o.frame()).map(|(_, _, f)| f.clone()).unwrap_or_default(); float.set_spin(Spin::default()); - let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children }; + let grouped = InlineObject::Group { w, h, float, ch_w: w, ch_h: h, children, canvas: None }; // Take the members out, last first so the earlier positions hold, and put the group where // the first one was. let len = wordcraft_doc::para::OBJ.len_utf8(); diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 36dd90d..b4df44b 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -107,14 +107,20 @@ pub enum Draw { turn: TextDirection, items: Vec, }, - /// A rotated or flipped picture, shape or chart: `items` drawn turned by `spin` about page - /// point (`cx`, `cy`) (see [`Spin::matrix`]). + /// A rotated or flipped picture, shape, chart, group or Drawing Canvas: `items` drawn + /// turned by `spin` about page point (`cx`, `cy`) (see [`Spin::matrix`]). Rotated { cx: f32, cy: f32, spin: Spin, items: Vec, }, + /// `items` clipped to `rect` (a Drawing Canvas's members to its frame). Inside a + /// [`Draw::Rotated`], `rect` is in the turned frame's (unturned) coordinates. + Clip { + rect: Rect, + items: Vec, + }, /// A formatting-mark label (a section break's name) in the UI's mark colour, left edge at /// `x`, drawn with the same face as [`Draw::Mark`]. Screen only, like every mark. MarkText { @@ -401,6 +407,12 @@ fn char_box(b: &Border, x0: f32, x1: f32, top: f32, bottom: f32, alpha: f32, out } fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mut Vec) { + item_in(doc, it, opts, alpha, Spin::default(), out) +} + +/// [`item`] drawn inside the turn `outer` (a turned Drawing Canvas's members): a shape's +/// effects follow all the turns it is drawn inside. +fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer: Spin, out: &mut Vec) { match it { Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha }), Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)), @@ -409,10 +421,17 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu } Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin } => { let mut items = Vec::new(); - shape_draws(*rect, *kind, *fill, *stroke, *stroke_width, effects_in(*effects, *spin), freeform.as_deref(), opts, &mut items); + shape_draws(*rect, *kind, *fill, *stroke, *stroke_width, effects_in(*effects, spin.within(outer)), freeform.as_deref(), opts, &mut items); spun(*spin, *rect, items, out) } Placed::Graphic { rect, graphic, spin, .. } => spun(*spin, *rect, graphic_draws(doc, graphic, *rect, alpha), out), + Placed::Clip { rect, spin, items } => { + let mut inner = Vec::new(); + for it in items { + item_in(doc, it, opts, alpha, spin.within(outer), &mut inner); + } + spun(*spin, *rect, vec![Draw::Clip { rect: *rect, items: inner }], out) + } Placed::Cell { .. } | Placed::Object { .. } => {} Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => { let mut items = Vec::new(); @@ -1243,7 +1262,12 @@ fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &Di for ([x, y, w, h], c) in o.group_rects(rect.x, rect.y, rect.w, rect.h) { object_draws(c, Rect::new(x, y, w, h), own.within(outer), alpha, opts, &mut members); } - spun(own, rect, members, out) + match o.canvas_style() { + // A Drawing Canvas: its background and outline, then its members clipped to its + // frame, all inside its turn. + Some(c) => spun(own, rect, vec![canvas_background(rect, c), Draw::Clip { rect, items: members }], out), + None => spun(own, rect, members, out), + } } _ => {} } @@ -1277,3 +1301,8 @@ pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f let (fw, fh) = (w * k, h * k); Rect::new(room.x + (room.w - fw) / 2.0, room.y + (room.h - fh) / 2.0, fw, fh) } + +/// A Drawing Canvas's background and outline over its frame `rect`. +fn canvas_background(rect: Rect, c: &wordcraft_doc::para::CanvasStyle) -> Draw { + Draw::Shape { rect, kind: ShapeKind::Rectangle, fill: c.fill, stroke: c.stroke, stroke_width: c.stroke_width, effects: Default::default() } +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 38b94f3..b557aa0 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -118,6 +118,14 @@ pub enum Placed { off: usize, spin: Spin, }, + /// `items` clipped to `rect`: a floating Drawing Canvas's members, kept inside its frame. + /// `rect` is the unturned frame and `items` sit in it; `spin` turns the clipped whole about + /// the frame's centre. + Clip { + rect: Rect, + spin: Spin, + items: Vec, + }, /// A table cell's area (for hit testing and cell selection). Cell { rect: Rect, @@ -165,6 +173,13 @@ impl Placed { origin.x += dx; origin.y += dy; } + Placed::Clip { rect, items, .. } => { + rect.x += dx; + rect.y += dy; + for it in items { + it.translate(dx, dy); + } + } Placed::Rule { x0, y0, x1, y1, .. } => { *x0 += dx; *x1 += dx; @@ -212,6 +227,12 @@ impl Placed { *rect = turn_rect(turn, x, y, *rect); (origin.x, origin.y) = turn_point(turn, x, y, origin.x, origin.y); } + Placed::Clip { rect, items, .. } => { + *rect = turn_rect(turn, x, y, *rect); + for it in items { + it.turn(turn, x, y); + } + } Placed::Rule { x0, y0, x1, y1, .. } => { (*x0, *y0) = turn_point(turn, x, y, *x0, *y0); (*x1, *y1) = turn_point(turn, x, y, *x1, *y1); @@ -770,6 +791,7 @@ fn fit_box(items: Vec, height: f32) -> Vec { Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } + | Placed::Clip { rect, .. } | Placed::Cell { rect, .. } | Placed::Object { rect, .. } if rect.y < limit => @@ -892,6 +914,7 @@ fn v_span(it: &Placed) -> (f32, f32) { | Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } + | Placed::Clip { rect, .. } | Placed::Cell { rect, .. } | Placed::Object { rect, .. } => (rect.y, rect.y + rect.h), Placed::Rule { y0, y1, .. } => (y0.min(*y1), y0.max(*y1)), @@ -1701,7 +1724,31 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR } InlineObject::Group { .. } => { let centre = Point::new(rect.x + rect.w / 2.0, rect.y + rect.h / 2.0); - group_members(o, rect).into_iter().flat_map(|(r, c)| float_items(c, r, (spin, centre), story, path, off)).collect() + match o.canvas_style() { + // A Drawing Canvas: its background and outline, then its members clipped to its + // frame, all turned together: the members sit in the unturned frame and the clip + // carries the turn (drawn as the turn around the clip). + Some(c) => { + let members = group_members(o, rect) + .into_iter() + .flat_map(|(r, m)| float_items(m, r, (Spin::default(), centre), story, path, off)) + .collect(); + vec![ + Placed::Shape { + rect, + kind: wordcraft_doc::para::ShapeKind::Rectangle, + fill: c.fill, + stroke: c.stroke, + stroke_width: c.stroke_width, + effects: Default::default(), + freeform: None, + spin, + }, + Placed::Clip { rect, spin, items: members }, + ] + } + None => group_members(o, rect).into_iter().flat_map(|(r, c)| float_items(c, r, (spin, centre), story, path, off)).collect(), + } } _ => Vec::new(), } diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index eec5e39..dfe33ee 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -684,7 +684,7 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> { } } } - Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } => { + Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Clip { rect, .. } => { if rect.bottom() <= cut + 0.01 { kept_any = true; } else { @@ -751,7 +751,11 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> { }); } } - Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Object { rect, .. } => { + Placed::Image { rect, .. } + | Placed::Shape { rect, .. } + | Placed::Graphic { rect, .. } + | Placed::Clip { rect, .. } + | Placed::Object { rect, .. } => { if rect.bottom() <= cut + 0.01 { a.push(it.clone()); } else { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 951b40c..9429935 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2865,6 +2865,7 @@ fn rotated_group_turns_its_members_and_their_shadows() { ch_w: 120.0, ch_h: 40.0, children: vec![GroupChild { x: 0.0, y: 0.0, obj: member(false, 0.0) }, GroupChild { x: 60.0, y: 0.0, obj: member(true, 10.0) }], + canvas: None, }; let mut d = Document::from_text("Text under the group."); d.insert_object(&Pos::body(0, 0), group, &Default::default()).unwrap(); @@ -3207,6 +3208,97 @@ fn wrap_text_keeps_text_on_one_side() { assert!(!near_right.is_empty() && near_right.iter().all(|l| l.2 < 1.0 && l.3 <= 259.0 + 0.01), "{near_right:?}"); } +/// #344 with #332: a Drawing Canvas turns as a whole, like a group: its background and outline, +/// then its members clipped to its frame, all drawn inside its turn (the clip in the unturned +/// frame, so the clip turns too); floating or inline. Hit testing follows the turned canvas. +#[test] +fn rotated_canvas_draws_its_clipped_members_inside_the_turn() { + use crate::display::Draw; + use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap}; + let member = InlineObject::Shape { + kind: ShapeKind::Rectangle, + w: 60.0, + h: 40.0, + fill: Some(wordcraft_doc::props::Rgb(1, 2, 3)), + stroke: None, + stroke_width: 0.0, + float: Float { rot: 10.0, ..Default::default() }, + story: None, + effects: Default::default(), + freeform: None, + }; + let canvas = |float: Float| InlineObject::Group { + w: 200.0, + h: 100.0, + float, + ch_w: 200.0, + ch_h: 100.0, + children: vec![GroupChild { x: 20.0, y: 30.0, obj: member.clone() }], + canvas: Some(CanvasStyle { fill: Some(wordcraft_doc::props::Rgb(0xF0, 0xF4, 0xFF)), stroke: None, stroke_width: 0.0 }), + }; + // Turned draws: (turn, background rect, clip rect, member rect and its own turn). + let turned = |draws: &[Draw]| -> (Vec, Vec, Vec<(Rect, Vec<(Rect, f32)>)>) { + let (mut turns, mut bgs, mut clips) = (Vec::new(), Vec::new(), Vec::new()); + for d in draws { + let Draw::Rotated { spin, items, .. } = d else { continue }; + turns.push(spin.deg); + for i in items { + match i { + Draw::Shape { rect, fill: Some(c), .. } if c.0 == 0xF0 => bgs.push(*rect), + Draw::Clip { rect, items } => { + let members = items + .iter() + .map(|m| match m { + Draw::Rotated { spin, items, .. } => match items.first() { + Some(Draw::Shape { rect, .. }) => (*rect, spin.deg), + o => panic!("{o:?}"), + }, + o => panic!("{o:?}"), + }) + .collect(); + clips.push((*rect, members)); + } + _ => {} + } + } + } + (turns, bgs, clips) + }; + for floating in [true, false] { + let float = if floating { + Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 100.0, y: 100.0, rot: 90.0, ..Default::default() } + } else { + Float { rot: 90.0, ..Default::default() } + }; + let mut d = Document::from_text("Text by the canvas."); + d.insert_object(&Pos::body(0, 0), canvas(float), &Default::default()).unwrap(); + let l = lay(&d); + let p = &l.pages[0]; + let hit = l.object(&Pos::body(0, 0), 0).unwrap(); + assert_eq!(hit.spin.deg, 90.0); + let f = hit.rect; + let (turns, bgs, clips) = turned(&crate::display::page_display(&d, p, &Default::default())); + assert!(turns.contains(&90.0), "floating {floating}: {turns:?}"); + // Background and clip are the canvas's unturned frame; the member sits in it, unturned + // but for its own 10°. + assert_eq!(bgs.len(), 1, "floating {floating}"); + assert!((bgs[0].x - f.x).abs() < 0.01 && (bgs[0].w - 200.0).abs() < 0.01, "{bgs:?} vs {f:?}"); + assert_eq!(clips.len(), 1, "floating {floating}"); + let (clip, members) = &clips[0]; + assert!((clip.x - bgs[0].x).abs() < 0.01 && (clip.y - bgs[0].y).abs() < 0.01 && (clip.h - 100.0).abs() < 0.01, "{clip:?}"); + assert_eq!(members.len(), 1, "{members:?}"); + let (m, own) = members[0]; + assert!((m.x - (clip.x + 20.0)).abs() < 0.01 && (m.y - (clip.y + 30.0)).abs() < 0.01, "{m:?} in {clip:?}"); + assert_eq!(own, 10.0); + if floating { + // Turned a quarter, the canvas's frame's left end is empty; above its centre isn't. + let (cx, cy) = (f.x + f.w / 2.0, f.y + f.h / 2.0); + assert!(l.object_at(0, f.x + 5.0, cy, 0.0).is_none()); + assert!(l.object_at(0, cx, cy - 80.0, 0.0).is_some()); + } + } +} + /// Ten short paragraphs in a two-column section (`widths`: unequal columns), then one paragraph /// in a one-column section starting `start`. fn two_column_section(start: wordcraft_doc::section::SectionStart, widths: Vec<(f32, f32)>) -> Document { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7531fec..28eee4d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -819,6 +819,14 @@ impl Exporter<'_> { s.set_stroke(None); } Draw::Mark { .. } | Draw::MarkText { .. } => {} + Draw::Clip { rect, items } => { + let Some(clip) = rect_path(rect) else { return }; + s.push_clip_path(&clip, &FillRule::NonZero); + for it in items { + self.draw(s, it); + } + s.pop(); + } Draw::Turned { x, y, turn, items } => { if !ok(*x) || !ok(*y) { return; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e61770b..a2d4641 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -719,6 +719,19 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.stroke_path(&path); } } + Draw::Clip { rect, items } => { + let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); + if !r.is_finite() || !r.overlaps(*visible) { + return; + } + ctx.set_transform(view); + ctx.push_clip_layer(&r.to_path(0.1)); + let local = r.intersect(*visible); + for it in items { + draw(ctx, doc, it, view, &local, opts); + } + ctx.pop_layer(); + } Draw::Turned { x, y, turn, items } => { let m = Affine::new(Draw::turn_matrix(*turn, *x, *y).map(f64::from)); let local = m.inverse().transform_rect_bbox(*visible); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2bdf75c..0f2085b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -284,6 +284,7 @@ fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 { (std::sync::Arc::as_ptr(graphic) as usize, rect.x.to_bits(), rect.y.to_bits(), rect.w.to_bits(), rect.h.to_bits()).hash(&mut h); format!("{spin:?}").hash(&mut h) } + Placed::Clip { rect, spin, items } => format!("{rect:?}{spin:?}{items:?}").hash(&mut h), Placed::Cell { .. } | Placed::Object { .. } => {} } } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9471fcf..56a4e8e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1412,6 +1412,12 @@ New Group Nuevo grupo Rename… Cambiar nombre… Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido: + Drawing Canvas Lienzo de dibujo + New Drawing Canvas Nuevo lienzo de dibujo + Fit Ajustar + Expand Expandir + Scale Drawing Escalar dibujo + Canvas Member Elemento del lienzo Arrows Flechas Communication Comunicación Office Oficina diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 876ab51..a29ae24 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1398,6 +1398,12 @@ Ribbon Lint Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba: Ribbon tabs Lindi vahekaardid + Drawing Canvas Joonistuslõuend + New Drawing Canvas Uus joonistuslõuend + Fit Sobita + Expand Laienda + Scale Drawing Muuda joonise mõõtkava + Canvas Member Lõuendi element Arrows Nooled Communication Suhtlus Office Kontor diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 27854a8..dc9d518 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1396,6 +1396,12 @@ New Group 新しいグループ Rename… 名前の変更… Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー: + Drawing Canvas 描画キャンバス + New Drawing Canvas 新しい描画キャンバス + Fit サイズに合わせる + Expand 拡大 + Scale Drawing 描画の拡大縮小 + Canvas Member キャンバス内の図形 Arrows 矢印 Communication コミュニケーション Office オフィス diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ae8b807..6d50826 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1408,6 +1408,12 @@ New Group Novo Grupo Rename… Renomear… Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido: + Drawing Canvas Tela de desenho + New Drawing Canvas Nova tela de desenho + Fit Ajustar + Expand Expandir + Scale Drawing Dimensionar desenho + Canvas Member Item da tela Arrows Setas Communication Comunicação Office Escritório diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7fbf5a1..ffb8092 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1790,6 +1790,12 @@ preview Title Пример Left Only Только слева Right Only Только справа Largest Only Только по большей стороне + Drawing Canvas Полотно для рисования + New Drawing Canvas Новое полотно для рисования + Fit По размеру + Expand Расширить + Scale Drawing Масштабировать рисунок + Canvas Member Элемент полотна Choose the sheet that holds the recipients. Выберите лист с получателями. First row of data contains column headers Первая строка данных содержит заголовки столбцов Communication Связь diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 6c70e31..55036c7 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1406,6 +1406,12 @@ New Group Nova grupa Rename… Preimenuj… Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup: + Drawing Canvas Platno za crtanje + New Drawing Canvas Novo platno za crtanje + Fit Prilagodi + Expand Proširi + Scale Drawing Skaliraj crtež + Canvas Member Član platna Arrows Strelice Communication Komunikacija Office Kancelarija diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f7b00cb..0c7db51 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1410,6 +1410,12 @@ New Group Нова група Rename… Преименуј… Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ: + Drawing Canvas Платно за цртање + New Drawing Canvas Ново платно за цртање + Fit Прилагоди + Expand Прошири + Scale Drawing Скалирај цртеж + Canvas Member Члан платна Arrows Стрелице Communication Комуникација Office Канцеларија diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a5d5164..257e73d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1408,6 +1408,12 @@ Left Only Yalnızca Sol Right Only Yalnızca Sağ Largest Only Yalnızca En Geniş + Drawing Canvas Çizim Tuvali + New Drawing Canvas Yeni Çizim Tuvali + Fit Sığdır + Expand Genişlet + Scale Drawing Çizimi Ölçeklendir + Canvas Member Tuval Öğesi Choose the sheet that holds the recipients. Alıcıları içeren sayfayı seçin. First row of data contains column headers Verinin ilk satırı sütun başlıklarını içerir Arrows Oklar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b14b173..5c07889 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1408,6 +1408,12 @@ New Group Нова група Rename… Перейменувати… Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу: + Drawing Canvas Полотно малювання + New Drawing Canvas Нове полотно малювання + Fit Підігнати + Expand Розширити + Scale Drawing Масштабувати малюнок + Canvas Member Об’єкт полотна Arrows Стрілки Communication Спілкування Office Офіс diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c342483..792fff0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1408,6 +1408,12 @@ New Group 新建组 Rename… 重命名… Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏: + Drawing Canvas 绘图画布 + New Drawing Canvas 新建绘图画布 + Fit 适应 + Expand 扩展 + Scale Drawing 缩放绘图 + Canvas Member 画布成员 Arrows 箭头 Communication 通信 Office 办公 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 896b89c..5ea284c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1397,6 +1397,12 @@ New Group 新增群組 Rename… 重新命名… Ribbon and Quick Access Toolbar: 功能區和快速存取工具列: + Drawing Canvas 繪圖畫布 + New Drawing Canvas 新增繪圖畫布 + Fit 調整大小 + Expand 擴展 + Scale Drawing 縮放繪圖 + Canvas Member 畫布成員 Arrows 箭頭 Communication 通訊 Office 辦公 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d54d71d..bb821e6 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -14,9 +14,12 @@ pub fn has_picture_selected(s: &wordcraft_engine::Session) -> bool { matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Image { .. }))) } -/// Whether the selection is a shape or text box (so Shape Format is shown). +/// Whether the selection is a shape, text box or Drawing Canvas (so Shape Format is shown). pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool { - matches!(wordcraft_engine::cmd::objects::selected(s), Some((_, wordcraft_doc::para::InlineObject::Shape { .. }))) + match wordcraft_engine::cmd::objects::selected(s) { + Some((_, o)) => matches!(o, wordcraft_doc::para::InlineObject::Shape { .. }) || o.is_canvas(), + None => false, + } } /// Contextual tabs for the current selection (pure, tested). @@ -610,6 +613,8 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { ] { mi(ui, app, l, "insert.shape", json!({"kind": k})); } + ui.separator(); + mi(ui, app, "New Drawing Canvas", "insert.canvas", json!({})); }); stack(ui, |ui| { small(ui, app, "icons", Some("Icons"), "Icons", "insert.icon", json!({}), false); @@ -1341,6 +1346,19 @@ fn shape_format(app: &mut WordApp, ui: &mut Ui) { }); }); }); + if wordcraft_engine::cmd::canvas::selected_canvas(&app.session).is_some() { + group(ui, "Drawing Canvas", None, app, |ui, app| { + menu_button(ui, app, "canvas", Some("Drawing\nCanvas"), "Drawing Canvas", true, |ui, app| { + mi(ui, app, "Fit", "canvas.fit", json!({})); + mi(ui, app, "Expand", "canvas.expand", json!({})); + ui.menu_button(tl!("Scale Drawing"), |ui| { + for pct in [50, 75, 125, 150, 200] { + mi(ui, app, &format!("{pct}%"), "canvas.scale", json!({"factor": pct as f32 / 100.0})); + } + }); + }); + }); + } group(ui, "Arrange", None, app, |ui, app| { big(ui, app, "position", "Position", "arrange.position", json!({}), false); wrap_menu(ui, app); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 01f9396..3b83be0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -57,9 +57,9 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Content controls (`w:sdt`) | 🟡 | ❌ | Content kept, the control (type, binding, placeholder, lock) dropped | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | -| Group shapes (`wpg:`), drawing canvas | 🟡 | 🟡 | Group shapes read and written (#267); drawing canvas (`wpc:`) dropped | | DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | +| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases | | VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures and text boxes, best effort (#242) | | Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292); not editable. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319) | | OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited | @@ -85,6 +85,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | Drawing canvas (`wpc:wpc`) read and written (#344); group shapes row brought up to date (#267) | | 2026-10-11 | trivial | Spreadsheets (.xlsx/.xlsm/.ods) read as mail-merge recipient lists (#334) | | 2026-10-11 | trivial | Import/export fixes (#433–#440): TXT opens through the encoding-sniffing reader (Windows-1252, unmarked UTF-16) and saves list labels; RTF `\uN` in document info, bookmark names and field instructions; ODT common paragraph styles' text formatting and table-cell background colours; HTML borderless tables export without cell borders and `white-space:pre` keeps edge spaces; LaTeX brace groups scope `\centering`/`\raggedleft`/`\raggedright` | | 2026-10-11 | trivial | `.doc` import fixes per [MS-DOC]: piece Prm0/Prm1 decoding (#458), section-break kinds (#459), explicit orientation (#460), negative `sprmPIlfo` list references (#461), zero-based `istdBase` style inheritance (#462), font-table entries with alternate names (#463) | diff --git a/docs/gaps.md b/docs/gaps.md index d052c7d..2579f54 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -20,7 +20,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 7 | **Dialog depth**: 35 modal dialogs vs Word's ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) | | 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) | | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | -| 10 | **Objects**: no group (#267 open), rotated text-box text (free rotation and flips landed, #332; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, Drawing Canvas | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | +| 10 | **Objects**: no group (#267 open), rotated text-box text (free rotation and flips landed, #332; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); missing: Lasso Select, Add Pen, Ink to Shape, Ink to Math, Ink Replay, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 5/11) | Pen and tablet users | 8–14 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (own renderer, chart data editing, SmartArt layouts) | | `insert.chart`, `insert.smartArt` missing | Reports and business documents | 45–70 | [target-app-parity.md](target-app-parity.md) | @@ -85,6 +85,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-10 | trivial | #10: wrap side (both sides, left, right, largest) for Square, Tight and Through objects (#354) | | 2026-10-10 | trivial | #10: Tight and Through wrapping follow the object's outline (#354) | | 2026-10-10 | trivial | #10: free rotation and flips for pictures, shapes and text boxes, with a rotation handle (#332); text boxes rotate their frame; their text stays upright | +| 2026-10-10 | trivial | #10: Drawing Canvas landed (#344); in-canvas dragging of members remains | | 2026-10-10 | trivial | #29: long display equations wrap across lines (#326) | | 2026-10-10 | trivial | #3: Windows lag on GPUs without a DirectX 12 driver fixed with an OpenGL fallback (#316) | | 2026-10-10 | trivial | #6: desktop File › Print opens the system PDF viewer (#286) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 811ef45..382fed2 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,15 +2,15 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**421 of 448 catalog features have commands (94.0%).** +**423 of 448 catalog features have commands (94.4%).** | Tab | Live | Total | |---|---|---| | File | 20 | 20 | | Quick Access | 4 | 4 | | Home | 101 | 102 | -| Insert | 44 | 53 | -| Draw | 5 | 11 | +| Insert | 45 | 53 | +| Draw | 6 | 11 | | Design | 10 | 10 | | Layout | 19 | 19 | | References | 24 | 24 | @@ -39,7 +39,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Illustrations › SmartArt (`insert.smartArt`) - Illustrations › Chart (`insert.chart`) - Illustrations › Screenshot (`insert.screenshot`) -- Illustrations › Drawing Canvas (`insert.canvas`) - Add-ins › Get Add-ins (`addins.get`) - Media › Online Video (`insert.video`) - Symbols › Ink Equation (`insert.inkEquation`) @@ -50,7 +49,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Pens › Add Pen (`draw.addPen`) - Convert › Ink to Shape (`draw.inkToShape`) - Convert › Ink to Math (`draw.inkToMath`) -- Insert › Drawing Canvas (`insert.canvas`) - Replay › Ink Replay (`draw.replay`) ### Review diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3f611b8..620d14d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **403 / 434 (92.9%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | +| Ribbon/menu catalog coverage | **423 / 448 (94.4%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | | Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated | | Feature depth (weighted by use, table below) | **~68%** | estimated | | **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) | @@ -68,7 +68,7 @@ full parity for the area. | Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Missing: move tracking, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | | Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. | -| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, Drawing Canvas, online pictures, screenshot. | +| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, online pictures, screenshot. | | Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. | | Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; lasso, ink to shape/math and replay missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). | | View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane. Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Outline view depth, Immersive Reader depth. | @@ -249,6 +249,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | Drawing Canvas (#344): Insert › Shapes › New Drawing Canvas and Draw › Drawing Canvas live; catalog 402/434 → 404/434 (93.1%) | | 2026-10-11 | trivial | Mailings: Excel and OpenDocument spreadsheets as recipient lists (#334); score unchanged | | 2026-10-11 | trivial | Insert › Icons (#309): catalog 402/434 → 403/434 (92.9%); moved from missing to done under pictures and shapes | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 402/434 → 403/434 (92.9%) |