diff --git a/ROADMAP.md b/ROADMAP.md index d6159cf..6dcceb8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (Draw Table and Eraser landed, #303: catalog 396/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and agent control. This page is the summary; the assessment is @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **396 / 434 (91.2%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **402 / 434 (92.6%)** | 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) | @@ -130,6 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-10 | Draw tab: pen, pencil and highlighter ink, stroke eraser, Select, Review › Hide Ink; ink saved to DOCX as DrawingML freeforms (#307); catalog 399/431 (92.6%) | | 2026-10-10 | Draw Table and Eraser (#303): a pen draws one-cell tables and splits cells along drawn lines; the eraser merges the cells beside a border. Catalog 393/431 (91.2%) | | 2026-10-10 | DOCX charts, SmartArt and OLE objects survive open and save with their parts (#319) | | 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); v0.4.0 released | @@ -146,6 +147,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | Progress log: Draw tab ink (#307) | | 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) | | 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) | | 2026-10-10 | trivial | Progress log: free rotation and flips (#332) | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 99488c1..f8369cd 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -572,6 +572,7 @@ mod tests { stroke_width: 0.75, float: Default::default(), story, + freeform: None, effects: Default::default(), } } diff --git a/crates/doc/src/freeform.rs b/crates/doc/src/freeform.rs new file mode 100644 index 0000000..06d80c0 --- /dev/null +++ b/crates/doc/src/freeform.rs @@ -0,0 +1,201 @@ +//! Freeform shapes: the geometry of a shape drawn point by point (DrawingML `a:custGeom`), such as +//! an ink stroke drawn with a pen, pencil or highlighter (Draw tab). + +use serde::{Deserialize, Serialize}; + +/// Most points one path keeps (hostile files and commands): a long pen stroke has a few hundred. +pub const MAX_POINTS: usize = 5_000; +/// Most paths one freeform keeps. +pub const MAX_PATHS: usize = 256; +/// Most ink strokes on one page: drawing more is refused. +pub const MAX_INK_PER_PAGE: usize = 2_000; +/// Largest coordinate kept, in the path's own coordinate space. +const MAX_COORD: f32 = 1.0e6; + +/// The pen an ink stroke was drawn with. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InkTool { + #[default] + Pen, + Pencil, + Highlighter, +} + +impl InkTool { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "pen" => Some(InkTool::Pen), + "pencil" => Some(InkTool::Pencil), + "highlighter" => Some(InkTool::Highlighter), + _ => None, + } + } + pub fn name(self) -> &'static str { + match self { + InkTool::Pen => "pen", + InkTool::Pencil => "pencil", + InkTool::Highlighter => "highlighter", + } + } + /// The colour a new stroke gets (black pen, grey pencil, yellow highlighter). + pub fn default_color(self) -> crate::props::Rgb { + match self { + InkTool::Pen => crate::props::Rgb(0, 0, 0), + InkTool::Pencil => crate::props::Rgb(0x59, 0x59, 0x59), + InkTool::Highlighter => crate::props::Rgb(0xFF, 0xFF, 0x00), + } + } + /// The width a new stroke gets, points. + pub fn default_width(self) -> f32 { + match self { + InkTool::Pen => 1.5, + InkTool::Pencil => 1.0, + InkTool::Highlighter => 12.0, + } + } + /// How opaque its strokes are: a highlighter's let the text show through, a pencil's are a + /// little lighter than a pen's. + pub fn alpha(self) -> f32 { + match self { + InkTool::Pen => 1.0, + InkTool::Pencil => 0.85, + InkTool::Highlighter => 0.5, + } + } +} + +/// One path of a freeform: points joined by straight lines, closed back to the first or not. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct FreePath { + pub pts: Vec<[f32; 2]>, + pub closed: bool, +} + +/// A freeform shape's geometry: paths in a `w` × `h` coordinate space that is stretched over the +/// shape's frame (DrawingML's `a:path` `w` and `h`). Drawn with the shape's outline colour and +/// width (and its fill, for closed paths that aren't ink). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Freeform { + pub w: f32, + pub h: f32, + pub paths: Vec, + /// Opacity of the outline, 0–1. + pub alpha: f32, + /// Set when this is ink: drawn with a pen (Draw tab), hidden by Review › Hide Ink and removed + /// by the eraser. + #[serde(skip_serializing_if = "Option::is_none")] + pub ink: Option, +} + +impl Default for Freeform { + fn default() -> Self { + Freeform { w: 0.0, h: 0.0, paths: Vec::new(), alpha: 1.0, ink: None } + } +} + +impl Freeform { + /// An ink stroke through `pts` (shape-local points, already in the `w` × `h` frame). + pub fn ink(tool: InkTool, w: f32, h: f32, pts: Vec<[f32; 2]>) -> Freeform { + Freeform { w, h, paths: vec![FreePath { pts, closed: false }], alpha: tool.alpha(), ink: Some(tool) }.sanitized() + } + + /// The same geometry with non-finite points dropped, coordinates clamped, the counts capped + /// and the opacity in 0–1: safe to draw whatever file or command it came from. + pub fn sanitized(mut self) -> Freeform { + let fin = |v: f32| if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 }; + self.w = fin(self.w).max(0.0); + self.h = fin(self.h).max(0.0); + self.alpha = if self.alpha.is_finite() { self.alpha.clamp(0.0, 1.0) } else { 1.0 }; + self.paths.truncate(MAX_PATHS); + for p in &mut self.paths { + p.pts.retain(|[x, y]| x.is_finite() && y.is_finite()); + p.pts.truncate(MAX_POINTS); + for pt in &mut p.pts { + *pt = pt.map(fin); + } + } + self.paths.retain(|p| !p.pts.is_empty()); + self + } + + pub fn is_ink(&self) -> bool { + self.ink.is_some() + } + + /// The paths mapped onto a frame at (`x`, `y`) of size `fw` × `fh` (page points). A zero + /// coordinate space takes the frame's own size. + pub fn placed(&self, x: f32, y: f32, fw: f32, fh: f32) -> Vec<(Vec<[f32; 2]>, bool)> { + let sx = if self.w > 0.0 { fw / self.w } else { 1.0 }; + let sy = if self.h > 0.0 { fh / self.h } else { 1.0 }; + let (sx, sy) = (if sx.is_finite() { sx } else { 1.0 }, if sy.is_finite() { sy } else { 1.0 }); + self.paths + .iter() + .take(MAX_PATHS) + .map(|p| (p.pts.iter().take(MAX_POINTS).map(|[px, py]| [x + px * sx, y + py * sy]).collect(), p.closed)) + .collect() + } + + /// Distance from (`px`, `py`) to the nearest path of this freeform placed on a frame at + /// (`x`, `y`), `fw` × `fh` (page points); `None` when it has no points. + pub fn distance(&self, x: f32, y: f32, fw: f32, fh: f32, px: f32, py: f32) -> Option { + let mut best: Option = None; + for (pts, closed) in self.placed(x, y, fw, fh) { + let n = pts.len(); + let segs = if closed && n > 2 { n } else { n.saturating_sub(1) }; + let mut consider = |a: [f32; 2], b: [f32; 2]| { + let d = seg_distance(a, b, [px, py]); + if d.is_finite() && best.is_none_or(|bd| d < bd) { + best = Some(d); + } + }; + if n == 1 + && let Some(a) = pts.first() + { + consider(*a, *a); + } + for i in 0..segs { + if let (Some(a), Some(b)) = (pts.get(i), pts.get((i + 1) % n)) { + consider(*a, *b); + } + } + } + best + } +} + +/// Distance from `p` to the segment `a`–`b`. +fn seg_distance(a: [f32; 2], b: [f32; 2], p: [f32; 2]) -> f32 { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; + let (cx, cy) = (a[0] + t * dx, a[1] + t * dy); + ((p[0] - cx).powi(2) + (p[1] - cy).powi(2)).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hostile_geometry_is_sanitized() { + let mut pts = vec![[f32::NAN, 1.0], [2.0, f32::INFINITY], [1.0, 2.0], [1e30, -1e30]]; + pts.extend(std::iter::repeat_n([3.0, 3.0], MAX_POINTS * 2)); + let f = Freeform { w: f32::NAN, h: -4.0, paths: vec![FreePath { pts, closed: false }], alpha: 7.0, ink: None }.sanitized(); + assert_eq!((f.w, f.h, f.alpha), (0.0, 0.0, 1.0)); + let p = &f.paths[0].pts; + assert_eq!(p.len(), MAX_POINTS); + assert_eq!(p[0], [1.0, 2.0]); + assert_eq!(p[1], [MAX_COORD, -MAX_COORD]); + } + + #[test] + fn distance_to_a_stroke_scales_with_its_frame() { + let f = Freeform::ink(InkTool::Pen, 10.0, 10.0, vec![[0.0, 0.0], [10.0, 0.0]]); + // Frame twice the size at (100, 100): the stroke runs from (100,100) to (120,100). + assert_eq!(f.distance(100.0, 100.0, 20.0, 20.0, 110.0, 103.0), Some(3.0)); + assert_eq!(Freeform::default().distance(0.0, 0.0, 1.0, 1.0, 0.0, 0.0), None); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 60de7da..9026f81 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -15,6 +15,7 @@ pub mod edit; pub mod effects; pub mod encoding; pub mod fields; +pub mod freeform; pub mod graphic; pub mod math; pub mod math_edit; diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 3fdeef6..6c447bd 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -207,6 +207,8 @@ pub enum ShapeKind { Heart, /// A text box (rectangle with a text story). TextBox, + /// A shape drawn point by point, such as ink (its geometry is the shape's `freeform`). + Freeform, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] @@ -262,6 +264,9 @@ pub enum InlineObject { /// Text box content: `Document::parts` id. #[serde(default)] story: Option, + /// A freeform's geometry (`ShapeKind::Freeform`): its paths, or an ink stroke. + #[serde(default, skip_serializing_if = "Option::is_none")] + freeform: Option>, /// Shadow, glow and soft edges. #[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")] effects: crate::effects::ShapeEffects, @@ -455,6 +460,13 @@ impl InlineObject { _ => false, } } + /// The geometry of an ink stroke (a freeform shape drawn with a pen), if this is one. + pub fn ink(&self) -> Option<&crate::freeform::Freeform> { + match self { + InlineObject::Shape { freeform: Some(f), .. } if f.is_ink() => Some(f), + _ => None, + } + } /// The text this object contributes to plain-text extraction. pub fn plain_text(&self) -> &str { match self { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 84ecf5d..559ed0b 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -88,6 +88,7 @@ fn text_box_anchor_finds_the_owning_shape() { stroke_width: 0.75, float: Default::default(), story, + freeform: None, effects: Default::default(), }; // A plain shape before it doesn't count. @@ -109,6 +110,7 @@ fn word_count_including_notes_counts_used_boxes_and_notes_only() { stroke_width: 0.75, float: Default::default(), story: Some(story), + freeform: None, effects: Default::default(), }; let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))]; @@ -148,6 +150,7 @@ fn counted_stories_survive_self_nested_boxes() { stroke_width: 0.0, float: Default::default(), story: Some(id), + freeform: None, effects: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape.clone(), &CharProps::default()).unwrap(); @@ -166,6 +169,7 @@ fn prune_text_boxes_drops_only_unshown_ones() { stroke_width: 0.0, float: Default::default(), story: Some(story), + freeform: None, effects: Default::default(), }; let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))]; @@ -199,6 +203,7 @@ fn notes_in_text_box_follow_nested_boxes_once() { stroke_width: 0.0, float: Default::default(), story: Some(story), + freeform: None, effects: Default::default(), }; let note = |id| InlineObject::NoteRef { kind: para::NoteKind::Footnote, id, custom: String::new() }; diff --git a/crates/docx/src/read/freeform.rs b/crates/docx/src/read/freeform.rs new file mode 100644 index 0000000..50a6c77 --- /dev/null +++ b/crates/docx/src/read/freeform.rs @@ -0,0 +1,111 @@ +//! Freeform shapes (DrawingML custom geometry, `a:custGeom`) and ink strokes drawn with them. + +use wordcraft_doc::freeform::{FreePath, Freeform, InkTool, MAX_PATHS, MAX_POINTS}; + +use crate::units::int; +use crate::xml::El; + +/// Straight pieces a Bézier curve is flattened into. +const CURVE_STEPS: usize = 8; +/// Largest path coordinate kept, in EMUs. +const MAX_EMU: f64 = 1.0e12; + +/// The paths of the custom geometry in `sppr` (`wps:spPr`), mapped onto a `w` × `h` shape +/// (points): each `a:path` in its own `w` × `h` space (EMUs of the shape's size when it gives +/// none). Lines and Bézier curves (flattened) are kept; arcs are drawn straight to their end. +/// `None` when there is no `a:custGeom` or it has no points. +pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option { + let list = sppr.child("a:custGeom")?.child("a:pathLst")?; + let mut paths = Vec::new(); + for p in list.children("a:path").take(MAX_PATHS) { + // Subpaths count too: stop once the shape has as many paths as it may keep. + if paths.len() >= MAX_PATHS { + break; + } + let dim = |n: &str, shape: f32| p.attr(n).and_then(int).filter(|v| *v > 0).map_or(shape as f64 * 12_700.0, |v| v as f64); + let (pw, ph) = (dim("w", w), dim("h", h)); + let scale = |[x, y]: [f64; 2]| -> [f32; 2] { + let sx = if pw > 0.0 { w as f64 / pw } else { 1.0 / 12_700.0 }; + let sy = if ph > 0.0 { h as f64 / ph } else { 1.0 / 12_700.0 }; + [(x * sx) as f32, (y * sy) as f32] + }; + let pt = |e: &El| -> Option<[f64; 2]> { + let c = |n: &str| e.attr(n).and_then(int).map(|v| (v as f64).clamp(-MAX_EMU, MAX_EMU)); + Some([c("x")?, c("y")?]) + }; + // Each subpath (from an `a:moveTo`) becomes a path of its own. + let mut cur: Vec<[f64; 2]> = Vec::new(); + let mut done: Vec<(Vec<[f64; 2]>, bool)> = Vec::new(); + let fill_none = p.attr("fill") == Some("none"); + for seg in p.els() { + if cur.len() >= MAX_POINTS || done.len() >= MAX_PATHS { + break; + } + let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).collect(); + match seg.local() { + "moveTo" => { + if !cur.is_empty() { + done.push((std::mem::take(&mut cur), false)); + } + cur.extend(pts.first()); + } + "lnTo" => cur.extend(pts.first()), + "cubicBezTo" | "quadBezTo" => { + let Some(&start) = cur.last() else { + cur.extend(pts.last()); + continue; + }; + let ctrl: Vec<[f64; 2]> = std::iter::once(start).chain(pts.iter().copied()).collect(); + for i in 1..=CURVE_STEPS { + cur.push(bezier(&ctrl, i as f64 / CURVE_STEPS as f64)); + } + } + "close" if !cur.is_empty() => done.push((std::mem::take(&mut cur), true)), + _ => {} + } + } + if !cur.is_empty() { + done.push((cur, false)); + } + for (pts, closed) in done.into_iter().take(MAX_PATHS.saturating_sub(paths.len())) { + paths.push(FreePath { pts: pts.into_iter().take(MAX_POINTS).map(scale).collect(), closed: closed && !fill_none }); + } + } + let f = Freeform { w, h, paths, alpha: 1.0, ink: None }.sanitized(); + (!f.paths.is_empty()).then_some(f) +} + +/// The pen of an ink stroke written by WordCraft, from its drawing's name (`Ink Pen 3`, +/// `Ink Highlighter 4`). +pub fn ink_tool(name: &str) -> Option { + let mut words = name.split_whitespace(); + if words.next() != Some("Ink") { + return None; + } + words.next().and_then(InkTool::parse) +} + +/// The outline opacity of `ln` (`a:ln`): its colour's `a:alpha`, 0–1. +pub fn line_alpha(ln: Option<&El>) -> f32 { + ln.and_then(|l| l.child("a:solidFill")) + .and_then(|f| f.els().next()) + .and_then(|c| c.child("a:alpha")) + .and_then(|a| a.attr("val")) + .and_then(int) + .map_or(1.0, |v| (v.clamp(0, 100_000) as f32) / 100_000.0) +} + +/// The point at `t` on the Bézier curve with control points `c` (de Casteljau). +fn bezier(c: &[[f64; 2]], t: f64) -> [f64; 2] { + let mut p: Vec<[f64; 2]> = c.to_vec(); + while p.len() > 1 { + p = p + .windows(2) + .filter_map(|w| match w { + [a, b] => Some([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]), + _ => None, + }) + .collect(); + } + p.first().copied().unwrap_or([0.0, 0.0]) +} diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index a754b84..7ab95c7 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -4,6 +4,7 @@ mod chart; mod diagram; mod drawing_color; mod embed; +mod freeform; mod math; mod props; mod story; diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 5d7907c..bc884cf 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -683,7 +683,19 @@ impl Reader<'_> { } else if gd.find("a:blip").is_some() { self.read_pic(gd, rels, w, h, alt, float)? } else { - self.read_wsp(sc, gd.find("wps:wsp")?, rels, w, h, float) + let wsp = gd.find("wps:wsp")?; + let mut obj = self.read_wsp(sc, wsp, rels, w, h, float); + // A freeform: its custom geometry, and whether it's ink WordCraft wrote. + if let InlineObject::Shape { kind, freeform, .. } = &mut obj + && let Some(sppr) = wsp.child("wps:spPr") + && let Some(mut f) = super::freeform::cust_geom(sppr, w, h) + { + f.alpha = super::freeform::line_alpha(sppr.child("a:ln")); + f.ink = c.child("wp:docPr").and_then(|p| p.attr("name")).and_then(super::freeform::ink_tool); + *kind = ShapeKind::Freeform; + *freeform = Some(Arc::new(f)); + } + obj }; // Word's effect extent also covers a rotated object's overhang; the model keeps that // apart (it follows from the angle), so take it back out. @@ -855,7 +867,7 @@ impl Reader<'_> { Some(t) if sc.story_depth < MAX_STORY_DEPTH => Some(self.read_textbox(sc, t, rels)), _ => None, }; - InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, effects } + InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform: None, effects } } fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 { @@ -894,6 +906,7 @@ impl Reader<'_> { stroke_width: 0.75, float, story, + freeform: None, effects: Default::default(), }); } diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 65aaa4d..031bd87 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -424,13 +424,14 @@ impl Writer<'_> { w.close("w:r"); self.rev_close(w, props); } - InlineObject::Shape { w: sw, h: sh, float, effects, .. } => { + InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, .. } => { self.rev_open(w, props); w.open("w:r", &[]); rpr(w, props); w.open("w:drawing", &[]); let docpr = self.next_docpr(); - let name = format!("Shape {docpr}"); + // Ink is told apart by its name (and its pen), which reading looks for. + let name = shape_name(*kind, freeform.as_deref(), &docpr); // The effect extent leaves room for the shadow and glow. let mut float = *float; for (e, fx) in float.effect.iter_mut().zip(effects.extent()) { @@ -611,10 +612,11 @@ impl Writer<'_> { /// A shape's or text box's `wps:wsp`, at `off` in its group (or 0, 0). In a group it carries /// its own `wps:cNvPr` with drawing id `id`. fn wsp(&mut self, w: &mut W, o: &InlineObject, id: Option<&str>, off: (f32, f32), rels: &mut PartRels, depth: usize) { - let InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, story, effects, float } = o else { return }; + let InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, story, effects, float, freeform } = o else { return }; + let geom = freeform.as_deref().filter(|_| *kind == ShapeKind::Freeform); w.open("wps:wsp", &[]); if let Some(id) = id { - let name = format!("Shape {id}"); + let name = shape_name(*kind, freeform.as_deref(), id); w.empty("wps:cNvPr", &[("id", id), ("name", &name)]); } if *kind == ShapeKind::TextBox { @@ -625,7 +627,7 @@ impl Writer<'_> { w.open("wps:spPr", &[]); xfrm(w, off, *sw, *sh, float.spin()); let prst = match kind { - ShapeKind::Rectangle | ShapeKind::TextBox => "rect", + ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => "rect", ShapeKind::RoundedRectangle => "roundRect", ShapeKind::Ellipse => "ellipse", ShapeKind::Triangle => "triangle", @@ -635,10 +637,17 @@ impl Writer<'_> { ShapeKind::Star => "star5", ShapeKind::Heart => "heart", }; - w.open("a:prstGeom", &[("prst", prst)]); - w.empty("a:avLst", &[]); - w.close("a:prstGeom"); - match fill { + match geom { + Some(f) => cust_geom(w, f, *sw, *sh), + None => { + w.open("a:prstGeom", &[("prst", prst)]); + w.empty("a:avLst", &[]); + w.close("a:prstGeom"); + } + } + let alpha = geom.map_or(1.0, |f| f.alpha); + let ink = geom.is_some_and(|f| f.is_ink()); + match fill.filter(|_| !ink) { Some(c) => { w.open("a:solidFill", &[]); w.empty("a:srgbClr", &[("val", &c.hex())]); @@ -648,10 +657,21 @@ impl Writer<'_> { } match stroke { Some(c) => { - w.open("a:ln", &[("w", &emu(stroke_width.clamp(0.0, 100.0)))]); + let width = emu(stroke_width.clamp(0.0, 100.0)); + let ln: &[(&str, &str)] = if ink { &[("w", width.as_str()), ("cap", "rnd")] } else { &[("w", width.as_str())] }; + w.open("a:ln", ln); w.open("a:solidFill", &[]); - w.empty("a:srgbClr", &[("val", &c.hex())]); + if alpha < 1.0 { + w.open("a:srgbClr", &[("val", &c.hex())]); + w.empty("a:alpha", &[("val", &n((alpha.clamp(0.0, 1.0) * 100_000.0).round() as i64))]); + w.close("a:srgbClr"); + } else { + w.empty("a:srgbClr", &[("val", &c.hex())]); + } w.close("a:solidFill"); + if ink { + w.empty("a:round", &[]); + } w.close("a:ln"); } None => { @@ -882,6 +902,61 @@ impl Writer<'_> { } } +/// A shape's `cNvPr`/`docPr` name: ink is "Ink ", which reading looks for. +fn shape_name(kind: ShapeKind, freeform: Option<&wordcraft_doc::freeform::Freeform>, id: &str) -> String { + match freeform.filter(|_| kind == ShapeKind::Freeform).and_then(|f| f.ink) { + Some(tool) => format!("Ink {} {id}", ink_name(tool)), + None => format!("Shape {id}"), + } +} + +fn ink_name(tool: wordcraft_doc::freeform::InkTool) -> &'static str { + match tool { + wordcraft_doc::freeform::InkTool::Pen => "Pen", + wordcraft_doc::freeform::InkTool::Pencil => "Pencil", + wordcraft_doc::freeform::InkTool::Highlighter => "Highlighter", + } +} + +/// A freeform's geometry as DrawingML custom geometry (`a:custGeom`): each path in EMUs of its +/// own coordinate space (the shape's size when it has none), from `a:moveTo` along `a:lnTo`. +fn cust_geom(w: &mut W, f: &wordcraft_doc::freeform::Freeform, sw: f32, sh: f32) { + let (pw, ph) = (if f.w > 0.0 { f.w } else { sw.max(0.0) }, if f.h > 0.0 { f.h } else { sh.max(0.0) }); + w.open("a:custGeom", &[]); + w.empty("a:avLst", &[]); + w.empty("a:gdLst", &[]); + w.empty("a:ahLst", &[]); + w.empty("a:cxnLst", &[]); + w.empty("a:rect", &[("l", "0"), ("t", "0"), ("r", "r"), ("b", "b")]); + w.open("a:pathLst", &[]); + let pt = |w: &mut W, tag: &str, [x, y]: [f32; 2]| { + w.open(tag, &[]); + w.empty("a:pt", &[("x", &emu(x)), ("y", &emu(y))]); + w.close(tag); + }; + for p in f.paths.iter().take(wordcraft_doc::freeform::MAX_PATHS) { + let mut attrs = vec![("w", emu(pw)), ("h", emu(ph))]; + if !p.closed || f.is_ink() { + attrs.push(("fill", "none".to_string())); + } + let attrs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect(); + w.open("a:path", &attrs); + for (i, xy) in p.pts.iter().take(wordcraft_doc::freeform::MAX_POINTS).enumerate() { + pt(w, if i == 0 { "a:moveTo" } else { "a:lnTo" }, *xy); + } + // A tap: a zero-length line, so it shows as a dot. + if let (1, Some(xy)) = (p.pts.len(), p.pts.first()) { + pt(w, "a:lnTo", *xy); + } + if p.closed { + w.empty("a:close", &[]); + } + w.close("a:path"); + } + w.close("a:pathLst"); + w.close("a:custGeom"); +} + /// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26), children in schema order. fn effect_list(w: &mut W, effects: &ShapeEffects) { let fx = effects.sanitized(); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 94b2bba..6a74f40 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -689,6 +689,7 @@ fn toc_field_skips_nested_stories() { stroke_width: 0.0, float: Float::default(), story: Some(story), + freeform: None, effects: Default::default(), } }; @@ -811,6 +812,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() { stroke_width: 1.0, float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() }, story: Some(story), + freeform: None, effects: Default::default(), }; let star = InlineObject::Shape { @@ -822,6 +824,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() { stroke_width: 0.0, float: Float::default(), story: None, + freeform: None, effects: Default::default(), }; let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false, math: Default::default() }; @@ -880,6 +883,7 @@ fn groups_round_trip() { stroke_width: 0.0, float: Float::default(), story: None, + freeform: None, // A member's shape effects (#275) come back too. effects: wordcraft_doc::effects::ShapeEffects { shadow: Some(wordcraft_doc::effects::Shadow { @@ -903,6 +907,7 @@ fn groups_round_trip() { stroke_width: 0.75, float: Float::default(), story: Some(story), + freeform: None, effects: Default::default(), }; let float = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 20.0, y: 10.0, dist: 9.0, ..Default::default() }; @@ -1138,6 +1143,7 @@ fn self_showing_text_box_saves_bounded() { stroke_width: 0.0, float: Float::default(), story: Some(id), + freeform: None, effects: Default::default(), }; for _ in 0..30 { @@ -1220,6 +1226,54 @@ fn list_level_overrides_round_trip() { assert_eq!(c.next_label(&back.numbering, restart, 1).unwrap().0, "1.01"); } +/// Ink strokes and freeform shapes are written as DrawingML custom geometry (`a:custGeom`, +/// `a:moveTo`/`a:lnTo`) in floating drawings and read back with their points, pen and opacity. +#[test] +fn ink_and_freeforms_round_trip_as_custom_geometry() { + use wordcraft_doc::freeform::{FreePath, Freeform, InkTool}; + let shape = |fill, stroke_width, wrap, f: Freeform| InlineObject::Shape { + kind: ShapeKind::Freeform, + w: f.w, + h: f.h, + fill, + stroke: Some(Rgb(0xC0, 0, 0)), + stroke_width, + float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 30.0, y: 12.0, ..Default::default() }, + story: None, + freeform: Some(Arc::new(f)), + effects: Default::default(), + }; + let pen = shape(None, 2.0, Wrap::InFrontOfText, Freeform::ink(InkTool::Pen, 60.0, 20.0, vec![[1.0, 1.0], [30.0, 19.0], [59.0, 4.0]])); + let marker = shape(None, 12.0, Wrap::BehindText, Freeform::ink(InkTool::Highlighter, 80.0, 12.0, vec![[6.0, 6.0], [74.0, 6.0]])); + let triangle = Freeform { + w: 40.0, + h: 40.0, + paths: vec![FreePath { pts: vec![[0.0, 40.0], [20.0, 0.0], [40.0, 40.0]], closed: true }], + ..Default::default() + }; + let tri = shape(Some(Rgb(0, 0x80, 0)), 1.0, Wrap::Square, triangle); + let mut p = Paragraph::with_text("Inked", CharProps::default()); + for o in [pen.clone(), marker, tri.clone()] { + p.insert_object(0, o, &CharProps::default()).unwrap(); + } + let d = doc_with(vec![p]); + let bytes = wordcraft_docx::write(&d).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); + assert_eq!(xml.matches("").count(), 3, "{xml}"); + assert!(xml.contains(r#""#), "{xml}"); + assert!(xml.contains(r#""#) && xml.contains(r#"cap="rnd""#), "{xml}"); + let r = rt(&d); + let objs = ¶s(&r)[0].objects; + assert_eq!(objs.len(), 3); + // Written in reverse (each inserted at the start): the triangle, the highlighter, the pen. + assert_eq!(objs[0], tri, "a filled closed freeform comes back as it was"); + let InlineObject::Shape { freeform: Some(m), float, stroke_width, .. } = &objs[1] else { panic!("{:?}", objs[1]) }; + assert_eq!((m.ink, m.alpha, float.wrap, *stroke_width), (Some(InkTool::Highlighter), 0.5, Wrap::BehindText, 12.0)); + assert_eq!(objs[2], pen, "the pen stroke comes back point for point"); +} + /// Shape effects (#275): `a:effectLst` with an outer shadow, a glow and soft edges round-trips, /// and the effect extent leaves room for them. #[test] @@ -1239,6 +1293,7 @@ fn shape_effects_round_trip() { stroke_width: 0.0, float: Float::default(), story: None, + freeform: None, effects, }; let mut p = Paragraph::with_text("x", CharProps::default()); @@ -1313,6 +1368,7 @@ fn rotation_and_flips_round_trip() { stroke_width: 0.0, float, story: None, + freeform: None, effects: Default::default(), }; let square = Float { wrap: Wrap::Square, ..Default::default() }; @@ -1367,6 +1423,7 @@ fn rotated_group_and_shadow_rotation_round_trip() { stroke_width: 0.0, float: Float { rot: 20.0, ..Default::default() }, story: None, + freeform: None, effects: ShapeEffects { shadow: Some(Shadow { rot_with_shape, ..Default::default() }), ..Default::default() }, }; let group = InlineObject::Group { diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs new file mode 100644 index 0000000..584cba9 --- /dev/null +++ b/crates/engine/src/cmd/draw.rs @@ -0,0 +1,393 @@ +//! Draw tab and Review › Ink: pens, the eraser and ink strokes. +//! +//! An ink stroke is a floating freeform shape ([`Freeform`] with its pen set) anchored to the +//! paragraph under where it starts, in front of the text (a highlighter's behind it). The pen +//! commands only choose what dragging on the page does (session view state); `draw.stroke` and +//! `draw.erase` change the document. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use wordcraft_doc::freeform::{Freeform, InkTool, MAX_INK_PER_PAGE, MAX_POINTS}; +use wordcraft_doc::para::{Anchor, Float, InlineObject, OBJ, ShapeKind, Wrap}; +use wordcraft_doc::props::Rgb; +use wordcraft_doc::{CharProps, Pos, StoryRef}; +use wordcraft_layout::{DocLayout, Placed}; + +use super::pos_json; +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// What dragging on the page does (Draw tab). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DrawMode { + /// Ordinary selection of text and drawings. + #[default] + Select, + /// Dragging over ink strokes deletes them. + Eraser, + /// Dragging draws ink with this pen. + Pen(InkTool), +} + +impl DrawMode { + /// The pen it draws with, if it's one. + pub fn pen(self) -> Option { + match self { + DrawMode::Pen(t) => Some(t), + _ => None, + } + } +} + +/// A pen's colour and thickness. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PenSettings { + pub color: Rgb, + /// Points. + pub width: f32, +} + +impl PenSettings { + fn of(tool: InkTool) -> PenSettings { + PenSettings { color: tool.default_color(), width: tool.default_width() } + } +} + +/// The Draw tab's state: the tool in use and each pen's settings. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct DrawState { + pub mode: DrawMode, + pub pen: PenSettings, + pub pencil: PenSettings, + pub highlighter: PenSettings, +} + +impl Default for DrawState { + fn default() -> Self { + DrawState { + mode: DrawMode::Select, + pen: PenSettings::of(InkTool::Pen), + pencil: PenSettings::of(InkTool::Pencil), + highlighter: PenSettings::of(InkTool::Highlighter), + } + } +} + +impl DrawState { + pub fn settings(&self, tool: InkTool) -> PenSettings { + match tool { + InkTool::Pen => self.pen, + InkTool::Pencil => self.pencil, + InkTool::Highlighter => self.highlighter, + } + } + fn settings_mut(&mut self, tool: InkTool) -> &mut PenSettings { + match tool { + InkTool::Pen => &mut self.pen, + InkTool::Pencil => &mut self.pencil, + InkTool::Highlighter => &mut self.highlighter, + } + } +} + +/// Thinnest and thickest ink, points. +const MIN_WIDTH: f32 = 0.25; +const MAX_WIDTH: f32 = 72.0; +/// How far from a stroke (beyond its half width) the eraser still takes it, points. +const ERASE_SLOP: f32 = 3.0; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("draw.select", "Select", "Draw › Tools", |s, _| { + s.view.draw.mode = DrawMode::Select; + state(s) + }) + .pure(), + CommandSpec::new("draw.eraser", "Eraser", "Draw › Tools", |s, v| { + // With a point, erase there (as a script would); without, pick up the eraser. + if v.get("page").is_some() || v.get("x").is_some() || v.get("y").is_some() { + return s.run("draw.erase", v); + } + s.view.draw.mode = DrawMode::Eraser; + state(s) + }) + .params(r#"{"page"?: n, "x"?: pt, "y"?: pt} (no point: use the eraser; a point: delete the ink stroke there)"#) + .pure(), + CommandSpec::new("draw.pen", "Pen", "Draw › Pens", |s, v| pen(s, v, InkTool::Pen)).params(PEN_PARAMS).pure(), + CommandSpec::new("draw.pencil", "Pencil", "Draw › Pens", |s, v| pen(s, v, InkTool::Pencil)).params(PEN_PARAMS).pure(), + CommandSpec::new("draw.highlighter", "Highlighter", "Draw › Pens", |s, v| pen(s, v, InkTool::Highlighter)).params(PEN_PARAMS).pure(), + CommandSpec::new("draw.stroke", "Draw Ink", "Draw › Pens", stroke).params( + r#"{"page": n, "points": [[x, y], …] (page points, at most 5000), "tool"?: "pen|pencil|highlighter", "color"?: "RRGGBB", "width"?: pt}"#, + ), + CommandSpec::new("draw.erase", "Erase Ink", "Draw › Tools", erase).params(r#"{"page": n, "x": pt, "y": pt}"#), + CommandSpec::new("review.hideInk", "Hide Ink", "Review › Ink", |s, v| { + s.view.hide_ink = p::bool(v, "value").unwrap_or(!s.view.hide_ink); + Ok(json!({"value": s.view.hide_ink})) + }) + .params(r#"{"value"?: bool}"#) + .pure(), + ] +} + +const PEN_PARAMS: &str = r#"{"color"?: "RRGGBB", "width"?: pt}"#; + +fn state(s: &Session) -> CmdResult { + serde_json::to_value(&s.view.draw).map_err(|e| CmdError::Failed(e.to_string())) +} + +/// Pick up a pen, optionally changing its colour and thickness. +fn pen(s: &mut Session, v: &Value, tool: InkTool) -> CmdResult { + let color = p::str(v, "color").map(|c| Rgb::parse(c).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))).transpose()?; + let set = s.view.draw.settings_mut(tool); + if let Some(c) = color { + set.color = c; + } + if let Some(w) = p::f32(v, "width") { + set.width = w.clamp(MIN_WIDTH, MAX_WIDTH); + } + s.view.draw.mode = DrawMode::Pen(tool); + state(s) +} + +/// Ink strokes laid out on `page`: their position and geometry on the page. +fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<(Pos, wordcraft_geom::Rect, Arc, f32)> { + let Some(pg) = layout.pages.get(page) else { return Vec::new() }; + pg.items + .iter() + .filter_map(|it| match it { + Placed::Object { rect, story, path, off, .. } => { + let pos = Pos { story: *story, path: path.clone(), off: *off }; + match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) { + Some(InlineObject::Shape { freeform: Some(f), stroke_width, .. }) if f.is_ink() => Some((pos, *rect, f.clone(), *stroke_width)), + _ => None, + } + } + _ => None, + }) + .collect() +} + +/// The ink stroke on `page` under (`x`, `y`), topmost first; none while ink is hidden. +pub fn ink_at(s: &mut Session, page: usize, x: f32, y: f32) -> Option { + if s.view.hide_ink || !x.is_finite() || !y.is_finite() { + return None; + } + let layout = s.layout(); + ink_on(s, &layout, page).into_iter().rev().find_map(|(pos, r, f, sw)| { + let reach = (if sw.is_finite() { sw.clamp(0.0, MAX_WIDTH) } else { 1.0 }) / 2.0 + ERASE_SLOP; + f.distance(r.x, r.y, r.w, r.h, x, y).filter(|d| *d <= reach).map(|_| pos) + }) +} + +fn page_param(s: &mut Session, v: &Value) -> Result<(usize, Arc), CmdError> { + let page = p::u64(v, "page").ok_or_else(|| CmdError::Params("`page` (0-based) is required".into()))?; + let layout = s.layout(); + let page = usize::try_from(page).ok().filter(|i| *i < layout.pages.len()).ok_or_else(|| CmdError::Params(format!("no page {page}")))?; + Ok((page, layout)) +} + +/// Delete the ink stroke under a point. +fn erase(s: &mut Session, v: &Value) -> CmdResult { + let (page, _) = page_param(s, v)?; + let (x, y) = (p::req_f32(v, "x")?, p::req_f32(v, "y")?); + let pos = ink_at(s, page, x, y).ok_or_else(|| CmdError::Failed("no ink stroke there".into()))?; + let len = OBJ.len_utf8(); + s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?; + for at in [&mut s.sel.anchor, &mut s.sel.focus] { + if at.story == pos.story && at.path == pos.path && at.off > pos.off { + at.off = at.off.saturating_sub(len).max(pos.off); + } + } + Ok(json!({"erased": pos_json(&pos)})) +} + +/// Draw an ink stroke through page points. +fn stroke(s: &mut Session, v: &Value) -> CmdResult { + let (page, layout) = page_param(s, v)?; + let tool = match p::str(v, "tool") { + Some(t) => InkTool::parse(t).ok_or_else(|| CmdError::Params("`tool` is pen, pencil or highlighter".into()))?, + None => s.view.draw.mode.pen().unwrap_or(InkTool::Pen), + }; + let set = s.view.draw.settings(tool); + let color = match p::str(v, "color") { + Some(c) => Rgb::parse(c).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))?, + None => set.color, + }; + let width = p::f32(v, "width").unwrap_or(set.width).clamp(MIN_WIDTH, MAX_WIDTH); + let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?; + let (pw, ph) = (pg.w.max(1.0), pg.h.clamp(1.0, 1e5)); + // Points on the page; NaN, infinities and malformed entries are dropped. + let pts: Vec<[f32; 2]> = v + .get("points") + .and_then(Value::as_array) + .ok_or_else(|| CmdError::Params("`points` ([[x, y], …]) is required".into()))? + .iter() + .take(MAX_POINTS) + .filter_map(|pt| { + let a = pt.as_array()?; + let (x, y) = (a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); + (x.is_finite() && y.is_finite()).then(|| [x.clamp(0.0, pw), y.clamp(0.0, ph)]) + }) + .collect(); + let first = *pts.first().ok_or_else(|| CmdError::Params("`points` has no usable point".into()))?; + if ink_on(s, &layout, page).len() >= MAX_INK_PER_PAGE { + return Err(CmdError::Failed(format!("page {page} already has {MAX_INK_PER_PAGE} ink strokes"))); + } + // The frame: the points' bounds, grown by half the width so the stroke's ends fit. + let pad = width / 2.0; + let (mut x0, mut y0, mut x1, mut y1) = (first[0], first[1], first[0], first[1]); + for [x, y] in &pts { + (x0, y0, x1, y1) = (x0.min(*x), y0.min(*y), x1.max(*x), y1.max(*y)); + } + let (fx, fy) = (x0 - pad, y0 - pad); + let (fw, fh) = ((x1 - x0 + width).max(1.0), (y1 - y0 + width).max(1.0)); + let local: Vec<[f32; 2]> = pts.iter().map(|[x, y]| [x - fx, y - fy]).collect(); + let wrap = if tool == InkTool::Highlighter { Wrap::BehindText } else { Wrap::InFrontOfText }; + let obj = InlineObject::Shape { + kind: ShapeKind::Freeform, + w: fw, + h: fh, + fill: None, + stroke: Some(color), + stroke_width: width, + float: Float { wrap, ..Default::default() }, + story: None, + freeform: Some(Arc::new(Freeform::ink(tool, fw, fh, local))), + effects: Default::default(), + }; + let at = place(s, &layout, obj, page, (fx, fy), first[1])?; + Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh]})) +} + +/// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph +/// under `anchor_y` (positioned from its column and top), or, where no paragraph starts on the +/// page, to the nearest line and positioned on the page. Returns where its character went. +fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor_y: f32) -> Result { + let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y) { + Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false), + None => { + (super::objects::nearest_line(layout, page, anchor_y).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true) + } + }; + s.doc.insert_object(&at, obj, &CharProps::default())?; + let len = OBJ.len_utf8(); + for p in [&mut s.sel.anchor, &mut s.sel.focus] { + if p.story == at.story && p.path == at.path && p.off > at.off { + p.off += len; + } + } + let (rel, origin) = if on_page { + (Anchor::Page, wordcraft_geom::Point::new(0.0, 0.0)) + } else { + s.touch(); // lay out the new object to find where its paragraph puts it + let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the stroke isn't laid out".into()))?.origin; + (Anchor::Paragraph, origin) + }; + let para = s.doc.para_mut(at.story, &at.path)?; + if let Some(InlineObject::Shape { float, .. }) = para.object_at_mut(at.off) { + float.h_rel = if on_page { Anchor::Page } else { Anchor::Column }; + float.v_rel = rel; + float.x = x - origin.x; + float.y = y - origin.y; + } + para.touch(); + Ok(at) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_doc::StoryRef; + use wordcraft_doc::freeform::InkTool; + use wordcraft_doc::para::{InlineObject, Wrap}; + + use crate::Session; + + fn session() -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("document.setText", &json!({"text": "First paragraph of text.\nSecond paragraph of text."})).unwrap(); + s + } + + fn ink(s: &Session) -> Vec { + s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).filter(|o| o.ink().is_some()).cloned().collect() + } + + #[test] + fn a_stroke_inserts_floating_ink_through_its_points_in_one_undo_step() { + let mut s = session(); + let text = s.doc.plain_text(StoryRef::Body); + let r = s + .run("draw.stroke", &json!({"page": 0, "points": [[100, 90], [150, 120], [f64::NAN, 3], [200, 95]], "color": "C00000", "width": 2})) + .unwrap(); + let [x, y] = [r["rect"][0].as_f64().unwrap() as f32, r["rect"][1].as_f64().unwrap() as f32]; + assert_eq!((x, y), (99.0, 89.0), "the points' bounds, grown by half the width"); + let objs = ink(&s); + let [InlineObject::Shape { stroke, stroke_width, float, freeform: Some(f), .. }] = objs.as_slice() else { panic!("one stroke: {objs:?}") }; + assert_eq!((*stroke, *stroke_width, float.wrap), (Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, Wrap::InFrontOfText)); + assert_eq!(f.ink, Some(InkTool::Pen)); + assert_eq!(f.paths[0].pts, vec![[1.0, 1.0], [51.0, 31.0], [101.0, 6.0]], "NaN dropped, shape-local"); + // Laid out where it was drawn. + let at = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }; + let hit = s.layout().object(&at, 0).unwrap(); + assert!((hit.rect.x - 99.0).abs() < 0.01 && (hit.rect.y - 89.0).abs() < 0.01, "{:?}", hit.rect); + s.run("edit.undo", &json!({})).unwrap(); + assert!(ink(&s).is_empty()); + assert_eq!(s.doc.plain_text(StoryRef::Body), text); + // A highlighter goes behind the text, translucent. + s.run("draw.highlighter", &json!({"color": "00FF00"})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[80, 80]]})).unwrap(); + let objs = ink(&s); + let [InlineObject::Shape { float, freeform: Some(f), stroke, .. }] = objs.as_slice() else { panic!() }; + assert_eq!((float.wrap, f.ink, *stroke), (Wrap::BehindText, Some(InkTool::Highlighter), Some(wordcraft_doc::Rgb(0, 0xFF, 0)))); + assert!(f.alpha < 1.0); + // Hostile input is refused, not drawn. + assert!(s.run("draw.stroke", &json!({"page": 99, "points": [[1, 1]]})).is_err()); + assert!(s.run("draw.stroke", &json!({"page": 0, "points": [["x", 1], [1]]})).is_err()); + assert!(s.run("draw.stroke", &json!({"page": 0, "points": [[1, 1]], "tool": "crayon"})).is_err()); + } + + #[test] + fn the_eraser_removes_the_stroke_under_the_point_and_hide_ink_hides_it() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap(); + assert!(s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 130})).is_err(), "nothing there"); + assert_eq!(ink(&s).len(), 1); + // Review › Hide Ink: not drawn, not erasable, still in the document. + assert_eq!(s.run("review.hideInk", &json!({})).unwrap()["value"], true); + assert!(s.view.hide_ink); + assert!(s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).is_err()); + s.run("review.hideInk", &json!({"value": false})).unwrap(); + s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).unwrap(); + assert!(ink(&s).is_empty()); + // Without a point it picks up the eraser; Select puts it down. + s.run("draw.eraser", &json!({})).unwrap(); + assert_eq!(s.view.draw.mode, super::DrawMode::Eraser); + s.run("draw.pencil", &json!({"width": 1e9})).unwrap(); + assert_eq!((s.view.draw.mode, s.view.draw.pencil.width), (super::DrawMode::Pen(InkTool::Pencil), super::MAX_WIDTH)); + s.run("draw.select", &json!({})).unwrap(); + assert_eq!(s.view.draw.mode, super::DrawMode::Select); + } + + /// `draw.eraser` is a view command, but with a point it erases through `draw.erase`, which + /// changes the document: that must be one undo step and mark the document changed. + #[test] + fn erasing_through_the_eraser_command_is_undoable_and_marks_the_document_dirty() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap(); + s.dirty = false; + let steps = s.undo_depth(); + s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).unwrap(); + assert!(ink(&s).is_empty()); + assert!(s.dirty, "erasing is an unsaved change"); + assert_eq!(s.undo_depth(), steps + 1, "one undo step"); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(ink(&s).len(), 1, "undo brings the stroke back"); + s.run("edit.redo", &json!({})).unwrap(); + assert!(ink(&s).is_empty()); + } +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 053c4a1..279b887 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -263,6 +263,7 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult { stroke_width: 1.0, float: Float { wrap: Wrap::Inline, ..Default::default() }, story: None, + freeform: None, effects: Default::default(), }; let end = s.doc.insert_object(&at, obj, &props)?; @@ -286,6 +287,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult { stroke_width: 0.75, float: Float::default(), story: Some(id), + freeform: None, effects: Default::default(), }; s.doc.insert_object(&at, obj, &props)?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index cbb16da..7a7965b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -4,6 +4,7 @@ pub mod caret; pub mod citations; pub mod column; pub mod design; +pub mod draw; pub mod edit; pub mod equation; pub mod file; @@ -58,6 +59,7 @@ pub fn registry() -> Registry { v.extend(mailings::specs()); v.extend(citations::specs()); v.extend(objects::specs()); + v.extend(draw::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 4c1361d..558b5cc 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -604,7 +604,7 @@ fn unalign(s: &mut Session) -> Result<(), CmdError> { /// The top-level body paragraph under `y` on `page`: the last one starting at or above it (else /// the first starting on the page). -fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option { +pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option { let starts: Vec<(&wordcraft_doc::Path, f32)> = layout .pages .get(page)? @@ -619,7 +619,7 @@ fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) - } /// The start of the top-level body line on `page` nearest to `y`. -fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option { +pub(crate) fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option { let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, StoryRef::Body); let best = lines.iter().filter(|l| l.path.depth() == 0).min_by(|a, b| { let d = |l: &wordcraft_layout::hit::LineHit| if y < l.top { l.top - y } else { (y - l.bottom).max(0.0) }; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 2d5371f..1e24f96 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -77,6 +77,12 @@ pub struct ViewState { pub track_changes_pane: bool, /// Check spelling and grammar as you type. pub proofing: bool, + /// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document). + #[serde(default)] + pub hide_ink: bool, + /// Draw tab: the tool dragging on the page uses, and each pen's colour and thickness. + #[serde(default)] + pub draw: crate::cmd::draw::DrawState, } fn on() -> bool { @@ -106,6 +112,8 @@ impl Default for ViewState { show_markup: true, track_changes_pane: false, proofing: true, + hide_ink: false, + draw: Default::default(), } } } diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 13fe696..2bfe601 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -69,6 +69,14 @@ pub enum Draw { /// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]). effects: wordcraft_doc::effects::ShapeEffects, }, + /// An ink stroke: a line through `pts` (page points) with round ends and joins, `width` + /// wide, in `color` at opacity `alpha`. + Ink { + pts: Vec<(f32, f32)>, + color: Rgb, + width: f32, + alpha: f32, + }, /// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked. Path { segs: Vec, @@ -140,13 +148,23 @@ pub struct DisplayOptions { pub markup: bool, /// Show on-screen-only marks: equation placeholders and prompts (never in print or PDF). pub placeholders: bool, + /// Review › Hide Ink: leave ink strokes out (they stay in the document). + pub hide_ink: bool, /// How tracked changes and comments are marked when `markup` is on (Track Changes Options). pub revisions: MarkupOptions, } impl Default for DisplayOptions { fn default() -> Self { - DisplayOptions { marks: false, dim_header: true, dim_body: false, markup: true, placeholders: false, revisions: MarkupOptions::default() } + DisplayOptions { + marks: false, + dim_header: true, + dim_body: false, + markup: true, + placeholders: false, + hide_ink: false, + revisions: MarkupOptions::default(), + } } } @@ -383,19 +401,11 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu Placed::Image { rect, media, crop, spin, .. } => { spun(*spin, *rect, vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }], out) } - Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, spin } => spun( - *spin, - *rect, - vec![Draw::Shape { - rect: *rect, - kind: *kind, - fill: *fill, - stroke: *stroke, - stroke_width: *stroke_width, - effects: effects_in(*effects, *spin), - }], - out, - ), + 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); + spun(*spin, *rect, items, out) + } Placed::Graphic { rect, graphic, spin, .. } => spun(*spin, *rect, graphic_draws(doc, graphic, *rect, alpha), out), Placed::Cell { .. } | Placed::Object { .. } => {} Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => { @@ -778,7 +788,7 @@ fn lines( let spin = obj.and_then(InlineObject::frame).map(|(_, _, f)| f.spin()).unwrap_or_default(); match obj { Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) => { - object_draws(o, rect, Spin::default(), alpha, out) + object_draws(o, rect, Spin::default(), alpha, opts, out) } Some(InlineObject::Graphic { graphic, alt, .. }) => { let mut draws = Vec::new(); @@ -1027,6 +1037,50 @@ fn page_segs(segs: &[PathSeg], map: impl Fn(f32, f32) -> (f32, f32)) -> Vec, + stroke: Option, + stroke_width: f32, + effects: wordcraft_doc::effects::ShapeEffects, + freeform: Option<&wordcraft_doc::freeform::Freeform>, + opts: &DisplayOptions, + out: &mut Vec, +) { + let Some(f) = freeform.filter(|_| kind == ShapeKind::Freeform) else { + out.push(Draw::Shape { rect, kind, fill, stroke, stroke_width, effects }); + return; + }; + if f.is_ink() && opts.hide_ink { + return; + } + let width = if stroke_width.is_finite() { stroke_width.clamp(0.0, 200.0) } else { 0.75 }; + for (pts, closed) in f.placed(rect.x, rect.y, rect.w, rect.h) { + if f.is_ink() || (!closed && fill.is_none()) { + if let Some(color) = stroke { + let mut pts: Vec<(f32, f32)> = pts.iter().map(|[x, y]| (*x, *y)).collect(); + if closed && let Some(first) = pts.first().copied() { + pts.push(first); + } + out.push(Draw::Ink { pts, color, width: width.max(0.25), alpha: f.alpha }); + } + continue; + } + let mut segs: Vec = Vec::with_capacity(pts.len() + 1); + for (i, [x, y]) in pts.iter().enumerate() { + segs.push(if i == 0 { PathSeg::Move(*x, *y) } else { PathSeg::Line(*x, *y) }); + } + if closed { + segs.push(PathSeg::Close); + } + out.push(Draw::Path { segs, fill: if closed { fill } else { None }, stroke, stroke_width: width }); + } +} + pub fn seg_path(segs: &[PathSeg]) -> BezPath { let mut p = BezPath::new(); for s in segs { @@ -1122,18 +1176,20 @@ fn spun(spin: Spin, rect: Rect, items: Vec, out: &mut Vec) { /// A picture, shape or group drawn in `rect` (its unturned frame), turned by its own spin about /// the rect's centre; a group's members are turned inside it. `outer` is the spin it is already /// drawn inside (its group's). Nothing for anything else. -fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, out: &mut Vec) { +fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &DisplayOptions, out: &mut Vec) { let own = o.frame().map(|(_, _, f)| f.spin()).unwrap_or_default(); match o { InlineObject::Image { media, crop, .. } => spun(own, rect, vec![Draw::Image { rect, media: media.clone(), crop: *crop, alpha }], out), - InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => { + InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, .. } => { let effects = effects_in(*effects, own.within(outer)); - spun(own, rect, vec![Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects }], out) + let mut items = Vec::new(); + shape_draws(rect, *kind, *fill, *stroke, *stroke_width, effects, freeform.as_deref(), opts, &mut items); + spun(own, rect, items, out) } InlineObject::Group { .. } => { let mut members = Vec::new(); 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, &mut members); + object_draws(c, Rect::new(x, y, w, h), own.within(outer), alpha, opts, &mut members); } spun(own, rect, members, out) } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 0c61b0c..0012fc9 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -99,6 +99,8 @@ pub enum Placed { fill: Option, stroke: Option, stroke_width: f32, + /// A freeform's paths (ink strokes among them). + freeform: Option>, effects: wordcraft_doc::effects::ShapeEffects, spin: Spin, }, @@ -1410,8 +1412,17 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR InlineObject::Image { media, crop, .. } => { vec![Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off, spin }] } - InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => { - vec![Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects, spin }] + InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, .. } => { + vec![Placed::Shape { + rect, + kind: *kind, + fill: *fill, + stroke: *stroke, + stroke_width: *stroke_width, + effects: *effects, + freeform: freeform.clone(), + spin, + }] } InlineObject::Graphic { graphic, .. } => { vec![Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off, spin }] diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7f123c1..2a509fb 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -232,6 +232,7 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() { stroke_width: 1.0, float, story: None, + freeform: None, effects: Default::default(), }; let obj = wordcraft_doc::para::OBJ.to_string(); @@ -1048,6 +1049,7 @@ fn text_wraps_around_square_float() { stroke_width: 1.0, float, story: None, + freeform: None, effects: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); @@ -1086,6 +1088,7 @@ fn deleted_float_leaves_no_wrap_area_without_markup() { stroke_width: 1.0, float, story: None, + freeform: None, effects: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); @@ -1138,6 +1141,7 @@ fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() { stroke_width: 1.0, float, story: None, + freeform: None, effects: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); @@ -1175,6 +1179,7 @@ fn line_numbers_borders_text_boxes() { stroke_width: 1.0, float: Default::default(), story: Some(id), + freeform: None, effects: Default::default(), }; d.insert_object(&Pos::body(2, 5), tb, &Default::default()).unwrap(); @@ -1469,6 +1474,7 @@ fn text_box_at(d: &mut Document, pos: &Pos, text: &str, w: f32, h: f32, float: w stroke_width: 0.75, float, story: Some(id), + freeform: None, effects: Default::default(), }; d.insert_object(pos, shape, &Default::default()).unwrap(); @@ -1540,6 +1546,7 @@ fn presses_grab_pictures_anywhere_and_text_boxes_by_their_border() { stroke_width: 1.0, float: Default::default(), story: None, + freeform: None, effects: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); @@ -1760,6 +1767,7 @@ fn box_fan_out(levels: usize, fan: usize) -> Document { stroke_width: 0.0, float: Default::default(), story: Some(story), + freeform: None, effects: Default::default(), }; for (k, id) in ids.iter().enumerate() { @@ -1833,6 +1841,7 @@ fn picture(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject { stroke_width: 1.0, float, story: None, + freeform: None, effects: Default::default(), } } @@ -1981,6 +1990,7 @@ fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject stroke_width: 1.0, float, story: None, + freeform: None, effects: Default::default(), } } @@ -2150,6 +2160,7 @@ fn floating_header_text_box_leaves_the_body_at_the_top_margin() { stroke_width: 0.75, float, story: Some(story), + freeform: None, effects: Default::default(), }; anchor.insert_object(0, tb, &Default::default()).unwrap(); @@ -2759,6 +2770,7 @@ fn rotated_float_wraps_around_its_rotated_bounds() { stroke_width: 1.0, float, story: None, + freeform: None, effects: Default::default(), }; d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap(); @@ -2802,6 +2814,7 @@ fn rotated_group_turns_its_members_and_their_shadows() { stroke_width: 0.0, float: Float { rot, ..Default::default() }, story: None, + freeform: None, effects: ShapeEffects { shadow: Some(Shadow { angle: 0.0, rot_with_shape, ..Default::default() }), ..Default::default() }, }; let float = diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e981540..7531fec 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -389,7 +389,8 @@ fn shape_path(kind: ShapeKind, r: &Rect) -> Option { pb.close(); }; match kind { - ShapeKind::Rectangle | ShapeKind::TextBox => poly(&mut pb, &[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]), + // A freeform is drawn from its own paths; without them, its frame. + ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => poly(&mut pb, &[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]), ShapeKind::RoundedRectangle => { let rad = r.w.min(r.h) * 0.16; let k = rad * (1.0 - K); @@ -755,6 +756,36 @@ impl Exporter<'_> { s.set_fill(None); s.set_stroke(None); } + Draw::Ink { pts, color, width, alpha } => { + let mut pb = PathBuilder::new(); + let mut used = 0usize; + let mut last = (0.0, 0.0); + for &(x, y) in pts.iter().filter(|(x, y)| ok(*x) && ok(*y)) { + if used > 0 { + pb.line_to(x, y); + } else { + pb.move_to(x, y); + } + used += 1; + last = (x, y); + } + // A tap (one usable point): a zero-length line, which round caps draw as a dot. + if used == 1 { + pb.line_to(last.0, last.1); + } + let Some(p) = pb.finish() else { return }; + s.set_fill(None); + s.set_stroke(Some(Stroke { + paint: rgb::Color::new(color.0, color.1, color.2).into(), + width: if width.is_finite() { width.clamp(0.25, 200.0) } else { 1.0 }, + opacity: norm(*alpha), + line_cap: LineCap::Round, + line_join: LineJoin::Round, + ..Default::default() + })); + s.draw_path(&p); + s.set_stroke(None); + } Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects } => { let Some(p) = shape_path(*kind, rect) else { return }; let can_fill = *kind != ShapeKind::Line; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 792672c..e569b25 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -639,6 +639,17 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.stroke_path(&path); } } + Draw::Ink { pts, color: c, width, alpha } => { + let Some(path) = ink_path(pts) else { return }; + if !path.bounding_box().inflate(*width as f64, *width as f64).overlaps(*visible) { + return; + } + ctx.set_transform(view); + ctx.set_paint(color(opts.ink(*c), *alpha)); + let w = if width.is_finite() { width.clamp(0.25, 200.0) } else { 1.0 }; + ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } Draw::Shape { rect, kind, fill, stroke, stroke_width, effects } => { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); let path = shape_path(*kind, r); @@ -714,6 +725,28 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl } } +/// The line through an ink stroke's points (finite ones only); a single point is a dot. +fn ink_path(pts: &[(f32, f32)]) -> Option { + let mut p = BezPath::new(); + let mut last = None; + for &(x, y) in pts.iter().filter(|(x, y)| x.is_finite() && y.is_finite() && x.abs() < 1e6 && y.abs() < 1e6) { + let pt = (x as f64, y as f64); + if last.is_none() { + p.move_to(pt); + } else { + p.line_to(pt); + } + last = Some(pt); + } + // Only sanitized points reach the path: a tap is drawn at the one usable point. + let only = last?; + if p.elements().len() == 1 { + // A tap: a zero-length line, which round caps draw as a dot. + p.line_to(only); + } + Some(p) +} + /// Outline of a basic shape in a rectangle. pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath { let (cx, cy) = (r.center().x, r.center().y); @@ -730,7 +763,8 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath { p }; match kind { - ShapeKind::Rectangle | ShapeKind::TextBox => r.to_path(0.1), + // A freeform is drawn from its own paths; without them, its frame. + ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => r.to_path(0.1), ShapeKind::RoundedRectangle => kurbo::RoundedRect::from_rect(r, r.width().min(r.height()) * 0.16).to_path(0.1), ShapeKind::Ellipse => kurbo::Ellipse::from_rect(r).to_path(0.1), ShapeKind::Triangle => poly(&[(cx, r.y0), (r.x1, r.y1), (r.x0, r.y1)]), @@ -842,6 +876,7 @@ mod tests { stroke_width: 0.0, float: Default::default(), story: None, + freeform: None, effects, }; let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3fe1e38..ce1b29d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -46,6 +46,10 @@ pub struct CanvasState { pub mini_anchor: Option, /// A picture, shape or text box being dragged by its frame. pub(crate) obj_drag: Option, + /// An ink stroke being drawn with a pen (Draw tab). + pub(crate) ink: Option, + /// The eraser is down: whether it has erased anything in this drag yet. + pub(crate) erasing: Option, /// Wheel/touchpad scrolling (smooth notches, touchpad momentum). pub(crate) wheel: crate::scroll::CanvasScroll, /// The scroll offset and its maximum at the end of last frame. @@ -100,6 +104,8 @@ impl Default for CanvasState { context_issue: None, context_synonyms: None, obj_drag: None, + ink: None, + erasing: None, context_menu_open: false, mini_anchor: None, pasted: false, @@ -249,7 +255,7 @@ fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); scale_px.to_bits().hash(&mut h); let v = &app.session.view; - (v.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER).hash(&mut h); + (v.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink).hash(&mut h); app.session.prefs.markup.hash(&mut h); dim_body.hash(&mut h); format!("{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.watermark).hash(&mut h); @@ -264,8 +270,9 @@ fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 { Placed::Fill { rect, color } => format!("{rect:?}{color:?}").hash(&mut h), Placed::Rule { x0, y0, x1, y1, border } => format!("{x0}{y0}{x1}{y1}{border:?}").hash(&mut h), Placed::Image { rect, media, spin, .. } => format!("{rect:?}{media}{spin:?}").hash(&mut h), - Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, spin } => { - format!("{rect:?}{kind:?}{fill:?}{stroke:?}{stroke_width}{effects:?}{spin:?}").hash(&mut h) + Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin } => { + format!("{rect:?}{kind:?}{fill:?}{stroke:?}{stroke_width}{effects:?}{spin:?}").hash(&mut h); + freeform.as_ref().map(|f| std::sync::Arc::as_ptr(f) as usize).hash(&mut h); } Placed::Graphic { rect, graphic, spin, .. } => { (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); @@ -289,7 +296,7 @@ fn texel_aligned_rect(layout: Rect, texture_px: egui::Vec2, ppp: f32) -> Rect { Rect::from_min_size(pos2(snap(layout.min.x), snap(layout.min.y)), texture_px / ppp) } -fn page_screen_scale(rect: Rect, page: &Page, fallback: f32) -> f32 { +pub(crate) fn page_screen_scale(rect: Rect, page: &Page, fallback: f32) -> f32 { if page.w > 0.0 { rect.width() / page.w } else { fallback } } @@ -441,6 +448,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { opts.display.marks = app.session.view.marks; opts.display.placeholders = true; opts.display.markup = app.session.view.show_markup; + opts.display.hide_ink = app.session.view.hide_ink; opts.display.revisions = app.session.prefs.markup.clone(); opts.dark = dark_page; opts.dark_paper = dark_paper; @@ -639,6 +647,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } crate::objects::paint(app, &painter, &t, &layout, &rects, geo.scale); + crate::ink::paint(app, &painter, &rects, &layout, geo.scale); crate::table_pen::paint(app, &painter, &t, &layout, &rects, geo.scale); (resp, rects) }); @@ -698,7 +707,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { // Stand down while the context menu owns the pointer so the two never double up; // `context_menu_opened` also covers the click that dismisses the menu. app.canvas.context_menu_open = resp.context_menu_opened(); - if resp.hovered() || app.canvas.obj_drag.is_some() { + if let Some(c) = crate::ink::cursor(app).filter(|_| resp.hovered() || app.canvas.ink.is_some()) { + ui.ctx().set_cursor_icon(c); + } else if resp.hovered() || app.canvas.obj_drag.is_some() { let over_object = ui.input(|i| i.pointer.latest_pos()).and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p)); ui.ctx().set_cursor_icon(over_object.unwrap_or(egui::CursorIcon::Text)); } @@ -982,6 +993,10 @@ pub fn nearest_page(rects: &[Rect], p: Pos2) -> Option { } fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) { + // A pen or the eraser (Draw tab) owns the pointer. + if crate::ink::pointer(app, ui, resp, rects, layout, scale) { + return; + } let pointer = resp.interact_pointer_pos().or_else(|| resp.hover_pos()); // An object drag follows the pointer anywhere until it's released. let object_pointer = pointer.or_else(|| app.canvas.obj_drag.as_ref().and_then(|_| ui.input(|i| i.pointer.latest_pos()))); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8278653..e1f1fd5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1129,6 +1129,12 @@ Leave the equation (Esc) Salir de la ecuación (Esc) Preview Vista previa getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink Dibujar tinta + Erase Ink Borrar tinta + Hide Ink Ocultar tinta + Hide\nInk Ocultar\ntinta + Pens Plumas + Thickness Grosor # Columns, Symbol and Field dialogs (#321). (All) (Todos) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e6f29ff..0122e99 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1113,6 +1113,12 @@ Leave the equation (Esc) 数式を終了 (Esc) Preview プレビュー getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink インクで描く + Erase Ink インクを消す + Hide Ink インクを隠す + Hide\nInk インクを\n隠す + Pens ペン + Thickness 太さ # Columns, Symbol and Field dialogs (#321). (All) (すべて) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 912a958..4d416e8 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1125,6 +1125,12 @@ Leave the equation (Esc) Sair da equação (Esc) Preview Visualizar getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink Desenhar tinta + Erase Ink Apagar tinta + Hide Ink Ocultar tinta + Hide\nInk Ocultar\ntinta + Pens Canetas + Thickness Espessura # Columns, Symbol and Field dialogs (#321). (All) (Todos) diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 8ab4256..aefc265 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1178,6 +1178,12 @@ Insert a new equation (Alt+=) Umetni novu jednačinu (Alt+=) Leave the equation (Esc) Napusti jednačinu (Esc) getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink Crtaj mastilom + Erase Ink Obriši mastilo + Hide Ink Sakrij mastilo + Hide\nInk Sakrij\nmastilo + Pens Pera + Thickness Debljina # Columns, Symbol and Field dialogs (#321). (All) (Sve) diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index e5ce439..e24dec4 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1182,6 +1182,12 @@ Insert a new equation (Alt+=) Уметни нову једначину (Alt+=) Leave the equation (Esc) Напусти једначину (Esc) getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink Цртај мастилом + Erase Ink Обриши мастило + Hide Ink Сакриј мастило + Hide\nInk Сакриј\nмастило + Pens Пера + Thickness Дебљина # Columns, Symbol and Field dialogs (#321). (All) (Све) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ddf28d7..738e51b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1125,6 +1125,12 @@ Leave the equation (Esc) Вийти з рівняння (Esc) Preview Попередній перегляд getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink Малювати чорнилом + Erase Ink Стерти чорнило + Hide Ink Сховати чорнило + Hide\nInk Сховати\nчорнило + Pens Пера + Thickness Товщина # Columns, Symbol and Field dialogs (#321). (All) (Усі) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ea3c77c..a04bac9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1124,6 +1124,12 @@ Leave the equation (Esc) 退出公式 (Esc) Preview 预览 getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink 绘制墨迹 + Erase Ink 擦除墨迹 + Hide Ink 隐藏墨迹 + Hide\nInk 隐藏\n墨迹 + Pens 笔 + Thickness 粗细 # Columns, Symbol and Field dialogs (#321). (All) (全部) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e378b92..d53d951 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1114,6 +1114,12 @@ Leave the equation (Esc) 離開方程式 (Esc) Preview 預覽 getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft + Draw Ink 繪製筆跡 + Erase Ink 擦除筆跡 + Hide Ink 隱藏筆跡 + Hide\nInk 隱藏\n筆跡 + Pens 畫筆 + Thickness 粗細 # Columns, Symbol and Field dialogs (#321). (All) (全部) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index bc8c0fe..589667e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -463,6 +463,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.closed(&[(3.0, 13.0), (11.0, 5.0), (17.0, 11.0), (11.0, 17.0), (7.0, 17.0)], c); pen.fill(&[(3.0, 13.0), (7.0, 9.0), (13.0, 15.0), (11.0, 17.0), (7.0, 17.0)], Color32::from_rgb(0xE8, 0x6F, 0xA0)); } + // Ink thickness: three strokes, thin to thick. + "thickness" => { + pen.frect(3.0, 4.5, 17.0, 5.5, c); + pen.frect(3.0, 9.0, 17.0, 11.0, c); + pen.frect(3.0, 14.0, 17.0, 17.0, a); + } "lasso" => { pen.closed(&[(4.0, 7.0), (9.0, 3.0), (16.0, 5.0), (16.0, 11.0), (10.0, 13.0), (4.0, 11.0)], c); pen.line_c(&[(6.0, 12.5), (5.0, 17.5)], a); diff --git a/crates/ui-egui/src/ink.rs b/crates/ui-egui/src/ink.rs new file mode 100644 index 0000000..d3ca7c6 --- /dev/null +++ b/crates/ui-egui/src/ink.rs @@ -0,0 +1,111 @@ +//! Drawing on the page with the Draw tab's pens and eraser. While a pen is in use, a drag draws a +//! live stroke and its release commits it with `draw.stroke` (one command, one undo step); with +//! the eraser, a click or drag over a stroke deletes it with `draw.erase` (one undo step per +//! drag). Select (or Esc) goes back to ordinary selection. + +use egui::{Color32, Painter, Pos2, Rect, Stroke, Ui}; +use serde_json::json; +use wordcraft_doc::freeform::{InkTool, MAX_POINTS}; +use wordcraft_engine::cmd::draw::DrawMode; +use wordcraft_layout::DocLayout; + +use crate::WordApp; +use crate::canvas::page_at; + +/// A stroke being drawn: its page and points (page points). +pub struct InkDrag { + page: usize, + tool: InkTool, + pts: Vec<[f32; 2]>, +} + +/// Smallest move that adds a point to a live stroke, screen points. +const MIN_STEP_PX: f32 = 1.5; + +/// Handle the pointer while a pen or the eraser is in use; `true` when it did (no text +/// selection or object dragging then). +pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) -> bool { + let mode = app.session.view.draw.mode; + if mode == DrawMode::Select { + app.canvas.ink = None; + app.canvas.erasing = None; + return false; + } + let (pressed, down, released, at) = + ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.latest_pos())); + let starts = pressed && resp.contains_pointer(); + match mode { + DrawMode::Pen(tool) => { + if starts && let Some((page, x, y)) = at.and_then(|p| page_at(rects, layout, scale, p)) { + app.canvas.ink = Some(InkDrag { page, tool, pts: vec![[x, y]] }); + } else if down + && let Some(p) = at + && let Some(d) = app.canvas.ink.as_mut() + && let Some((x, y)) = on_page(rects, layout, scale, d.page, p) + { + let far = d.pts.last().is_none_or(|[lx, ly]| ((x - lx).powi(2) + (y - ly).powi(2)).sqrt() * scale >= MIN_STEP_PX); + if far && d.pts.len() < MAX_POINTS { + d.pts.push([x, y]); + } + } + if released && let Some(d) = app.canvas.ink.take() { + let _ = app.run("draw.stroke", json!({"page": d.page, "points": d.pts, "tool": d.tool.name()})); + } + } + DrawMode::Eraser => { + if starts { + app.canvas.erasing = Some(false); + } + if (starts || down) + && let Some(erased) = app.canvas.erasing + && let Some((page, x, y)) = at.and_then(|p| page_at(rects, layout, scale, p)) + && wordcraft_engine::cmd::draw::ink_at(&mut app.session, page, x, y).is_some() + { + // The first stroke a drag erases starts an undo step; the rest join it. + if erased { + app.session.join_next_undo(); + } + if app.run("draw.erase", json!({"page": page, "x": x, "y": y})).is_ok() { + app.canvas.erasing = Some(true); + } + } + if released { + app.canvas.erasing = None; + } + } + DrawMode::Select => {} + } + true +} + +/// Page point under screen point `p` on `page` (outside the page too, clamped by the command). +fn on_page(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize, p: Pos2) -> Option<(f32, f32)> { + let r = rects.get(page)?; + let s = layout.pages.get(page).map_or(scale, |pg| crate::canvas::page_screen_scale(*r, pg, scale)); + (s > 0.0).then(|| ((p.x - r.min.x) / s, (p.y - r.min.y) / s)) +} + +/// The live stroke, drawn over the page until it is committed. +pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) { + let Some(d) = &app.canvas.ink else { return }; + let Some(r) = rects.get(d.page) else { return }; + let s = layout.pages.get(d.page).map_or(scale, |pg| crate::canvas::page_screen_scale(*r, pg, scale)); + let set = app.session.view.draw.settings(d.tool); + let alpha = (d.tool.alpha() * 255.0).round() as u8; + let color = Color32::from_rgba_unmultiplied(set.color.0, set.color.1, set.color.2, alpha); + let pts: Vec = d.pts.iter().map(|[x, y]| egui::pos2(r.min.x + x * s, r.min.y + y * s)).collect(); + let width = (set.width * s).max(1.0); + if let [only] = pts.as_slice() { + painter.circle_filled(*only, width / 2.0, color); + } else { + painter.add(egui::Shape::line(pts, Stroke::new(width, color))); + } +} + +/// The pointer over the page while a pen or the eraser is in use. +pub fn cursor(app: &WordApp) -> Option { + match app.session.view.draw.mode { + DrawMode::Select => None, + DrawMode::Eraser | DrawMode::Pen(_) => Some(egui::CursorIcon::Crosshair), + } +} diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 7e8602d..182027e 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -234,6 +234,12 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { app.session.painter = None; continue; } + // Escape puts the pen or eraser down (back to selecting). + if key == Key::Escape && app.session.view.draw.mode != wordcraft_engine::cmd::draw::DrawMode::Select { + app.canvas.ink = None; + let _ = app.run("draw.select", json!({})); + continue; + } if key == Key::Escape && crate::table_pen::stop(app) { continue; } diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index b88bed1..3f00be4 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -305,6 +305,7 @@ fn controls(tab: &str) -> Vec { c("Compare", "review.compare"), c("Block Authors", "review.blockAuthors"), c("Restrict Editing", "review.restrict"), + c("Hide Ink", "review.hideInk"), ], "View" => vec![ c("Read Mode", "view.readMode"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6c156e8..5cd18ea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ pub mod file_dialogs; pub mod frame; pub mod i18n; pub mod icons; +pub mod ink; pub mod keys; pub mod keytips; pub mod mini_toolbar; diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 2ffc3d7..ca232ad 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -276,6 +276,7 @@ mod tests { stroke_width: 1.0, float: Default::default(), story: None, + freeform: None, effects: Default::default(), }; let (app, hit) = app_with(shape); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 3868fb7..73cf88a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -4,7 +4,7 @@ use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; use crate::theme::{Tokens, medium, regular, semibold}; -use crate::widgets::{CONTENT_H, LABEL_H, big, color_grid, combo, group, menu_button, small, split}; +use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, color_grid, combo, group, menu_button, small, split}; use crate::{WordApp, icons}; pub const TABS: [&str; 12] = ["File", "Home", "Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Zotero", "Help"]; @@ -645,13 +645,41 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { } fn draw(app: &mut WordApp, ui: &mut Ui) { + use wordcraft_doc::freeform::InkTool; + use wordcraft_engine::cmd::draw::DrawMode; + let mode = app.session.view.draw.mode; + // The pen the colour and thickness menus change: the one in use, else the pen. + let tool = mode.pen().unwrap_or(InkTool::Pen); + let pen_id = |t: InkTool| match t { + InkTool::Pen => "draw.pen", + InkTool::Pencil => "draw.pencil", + InkTool::Highlighter => "draw.highlighter", + }; group(ui, "Drawing Tools", None, app, |ui, app| { - big(ui, app, "select", "Select", "draw.select", json!({}), false); + big_toggle(ui, app, "select", "Select", "draw.select", mode == DrawMode::Select); big(ui, app, "lasso", "Lasso", "draw.lasso", json!({}), false); - big(ui, app, "eraser", "Eraser", "draw.eraser", json!({}), false); - big(ui, app, "pen", "Pen", "draw.pen", json!({}), false); - big(ui, app, "pencil", "Pencil", "draw.pencil", json!({}), false); - big(ui, app, "highlight", "Highlighter", "draw.highlighter", json!({}), false); + big_toggle(ui, app, "eraser", "Eraser", "draw.eraser", mode == DrawMode::Eraser); + big_toggle(ui, app, "pen", "Pen", "draw.pen", mode == DrawMode::Pen(InkTool::Pen)); + big_toggle(ui, app, "pencil", "Pencil", "draw.pencil", mode == DrawMode::Pen(InkTool::Pencil)); + big_toggle(ui, app, "highlight", "Highlighter", "draw.highlighter", mode == DrawMode::Pen(InkTool::Highlighter)); + let set = app.session.view.draw.settings(tool); + stack(ui, |ui| { + let sw = Some(crate::theme::c32(set.color)); + split(ui, app, "fontcolor", "Color", pen_id(tool), json!({}), false, sw, |ui, app| { + let theme = app.session.doc.settings.theme_colors.clone(); + if let Some(hex) = color_grid(ui, &theme) { + let _ = app.run(pen_id(tool), json!({"color": hex})); + ui.close(); + } + }); + menu_button(ui, app, "thickness", None, "Thickness", false, |ui, app| { + let widths: &[f32] = if tool == InkTool::Highlighter { &[4.0, 8.0, 12.0, 18.0, 24.0] } else { &[0.5, 1.0, 1.5, 2.5, 3.5, 5.0] }; + for w in widths { + let label = crate::i18n::fmt(tl!("{n} pt"), &[("n", &w.to_string())]); + mi_check(ui, app, &label, (set.width - w).abs() < 0.01, pen_id(tool), json!({"width": w})); + } + }); + }); }); group(ui, "Convert", None, app, |ui, app| { big(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", json!({}), false); @@ -1087,6 +1115,10 @@ fn review(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "blockAuthors", "Block\nAuthors", "review.blockAuthors", json!({}), false); big(ui, app, "restrict", "Restrict\nEditing", "review.restrict", json!({}), false); }); + group(ui, "Ink", None, app, |ui, app| { + let hidden = app.session.view.hide_ink; + big_toggle(ui, app, "hideInk", "Hide\nInk", "review.hideInk", hidden); + }); } fn view(app: &mut WordApp, ui: &mut Ui) { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index f1c26ab..9907e94 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -58,10 +58,11 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written, laid out as square | | 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 | | 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 set in WordCraft (#319). Can't be opened or edited | -| Ink, 3D models | ❌ | ❌ | Dropped on read, not preserved on save | +| Word ink (`w14:contentPart`, InkML), 3D models | ❌ | ❌ | Dropped on read, not preserved on save | | Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) | | Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read | | Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom properties round-trip; bibliography sources pending (#169) | @@ -87,3 +88,4 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | 2026-10-10 | trivial | Equation breaks and `m:mathPr` wrapping options round-trip (#326) | | 2026-10-10 | trivial | Custom table styles round-trip (#256 merged) | | 2026-10-10 | major | First version: Word's full format list, read/write status, DOCX element coverage from the source, fidelity and hours | +| 2026-10-10 | trivial | Freeform shapes and ink as DrawingML custom geometry, read and written (#307) | diff --git a/docs/gaps.md b/docs/gaps.md index 9a88818..fc570cb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -24,7 +24,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 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 (tight/through) wrap, shape effects, WordArt, connectors, Drawing Canvas, Icons | | [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**: all 11 commands missing | | [parity-checklist.md](parity-checklist.md) (Draw 0/11) | Pen and tablet users | 15–25 | [hardware-parity.md](hardware-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) | | 14 | **`.doc` can't be written; metafile pictures and Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: 4 of Word's 12 bibliography styles; sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Word's `Resources/Style` lists 12 styles | Students and researchers | 10–15 | [target-app-parity.md](target-app-parity.md) | @@ -80,3 +80,4 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-10 | trivial | Merged main: landed features removed from the feature-gap list | | 2026-10-10 | minor | Beta note restated: full number back to the additive ~60% (method aligned with the standard, no new evidence) | | 2026-10-10 | major | First version, from the full re-measure: 29 ranked gaps with evidence, impact and hours | +| 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink landed; ink gap narrowed | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5fba32c..460e233 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ 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. -**396 of 434 catalog features have commands (91.2%).** +**402 of 434 catalog features have commands (92.6%).** | Tab | Live | Total | |---|---|---| @@ -10,12 +10,12 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Quick Access | 4 | 4 | | Home | 99 | 102 | | Insert | 43 | 53 | -| Draw | 0 | 11 | +| Draw | 5 | 11 | | Design | 10 | 10 | | Layout | 19 | 19 | | References | 24 | 24 | | Mailings | 17 | 17 | -| Review | 27 | 30 | +| Review | 28 | 30 | | View | 25 | 29 | | Table Design | 8 | 8 | | Table Layout | 30 | 30 | @@ -49,12 +49,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- ### Draw -- Tools › Select (`draw.select`) - Tools › Lasso Select (`draw.lasso`) -- Tools › Eraser (`draw.eraser`) -- Pens › Pen (`draw.pen`) -- Pens › Pencil (`draw.pencil`) -- Pens › Highlighter (`draw.highlighter`) - Pens › Add Pen (`draw.addPen`) - Convert › Ink to Shape (`draw.inkToShape`) - Convert › Ink to Math (`draw.inkToMath`) @@ -65,7 +60,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Language › Translate (`review.translate`) - Protect › Block Authors (`review.blockAuthors`) -- Ink › Hide Ink (`review.hideInk`) ### View diff --git a/docs/roadmap.md b/docs/roadmap.md index e5d0127..31f5c21 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -55,7 +55,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | M8 | View | mostly done; window commands missing | 3–5 h | | M9 | Mailings | done (first version); Excel/Outlook data sources and email merge missing | 6–10 h | | M10 | File/Backstage | mostly done; native printing, Options depth missing | 12–20 h | -| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332) done; ink, grouping, contour wrap missing | 35–55 h | +| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332), ink pens and eraser (#307) done; lasso and ink conversion, grouping, contour wrap missing | 35–55 h | | M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT, LaTeX, `.doc` import) | done (first versions); `.doc` write, encryption, minor formats missing | 60–100 h | | M13 | Performance budgets | on track (1.4 ms relayout); large real-world documents unmeasured | 10–20 h | | M14 | 1.0 polish, packaging, signing | releases v0.1.0–v0.4.0 published (signed macOS, Windows MSI x64/x86/arm64, Linux, FreeBSD, web) | — | @@ -76,3 +76,4 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | 2026-10-10 | minor | Current focus heading back to ~150–230 h to beta: full number restored to the additive weighted sum (~60%), method aligned with the standard, no new evidence | | 2026-10-10 | minor | Alpha gate table added (core-workflow gate from the progress-docs standard): all six workflows pass, stage stays alpha | | 2026-10-10 | major | Created: milestones moved from ROADMAP.md with remaining hours; Current focus from gaps.md; M15–M16 added | +| 2026-10-10 | trivial | M11: ink pens and eraser landed (#307) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ff8dcb7..4c47c0f 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 | **396 / 434 (91.2%)** | **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 | **402 / 434 (92.6%)** | **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)) | @@ -70,7 +70,7 @@ full parity for the area. | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules, preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts 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). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, Drawing Canvas, Icons, 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 | Nothing yet: all 11 Draw tab commands missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). | +| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | 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. | | Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. | | Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. | @@ -249,6 +249,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink; catalog 384/430 → 390/430 (90.7%) | | 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 384/430 → 387/430 (90.0%) | | 2026-10-10 | trivial | Pictures, shapes, text boxes: free rotation and flips, rotation handle, rotated wrap bounds, DOCX `a:xfrm` rot/flipH/flipV (#332); no percentage change | | 2026-10-10 | minor | Merged main (12 PRs): Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info fields, Serbian. Catalog 378/428 → 384/430 (89.3%); typing 82→85%, styles 72→74%, tables 74→76%; feature depth 67→68%; mainstream 54→55%; full stays ~60%, essentials ~63% | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b04359d..e086185 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -10,7 +10,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Area | WordCraft | Word | Parity | Hours | |---|---|---|---|---| -| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation; ribbon overflow (#43, #129). Missing: Draw tab content | Same plus Draw | 85% | (Draw in features) | +| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation; ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); missing: Lasso, Add Pen, Ink to Shape/Math, Ink Replay | Same plus Draw | 85% | (Draw in features) | | Keytips (Alt / ⌃⌥ letters) | ✅ (#43) | ✅ | 85% | 1–2 | | Mini-toolbar on selection | ✅ (#43) | ✅ | 80% | 1–2 | | Quick Access Toolbar | Undo, Redo, Repeat, AutoSave | customizable | 50% | 2–3 | @@ -47,3 +47,4 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) | | 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed | | 2026-10-10 | major | First version: ribbon, dialog, shortcut and interaction inventory | +| 2026-10-10 | trivial | Draw tab tools and Review › Hide Ink (#307) |