Draw: Lasso Select, Add Pen, Ink to Shape and Ink Replay (#395) (#408)

* Draw: Lasso Select, Add Pen, Ink to Shape and Ink Replay (#395)

- draw.lasso: with no points picks up the lasso; with a loop on a page it
  selects the strokes mostly inside it and drawings whose centre is inside.
  draw.lassoMove / draw.lassoColor / draw.lassoDelete move, recolour and
  delete the selection (one undo step each); it lasts until the document
  changes otherwise. In the app: drag a loop, drag the selection to move it,
  Delete deletes, Esc lets go, the Color menu recolours it.
- draw.addPen / draw.removePen: a pen gallery (kind, colour, width; capped,
  validated) shown after the built-in pens, kept in ui.json; damaged entries
  are dropped on reading. Right-click a gallery pen to remove it.
- draw.inkToShape: toggles Ink to Shape (pen and pencil strokes that look
  like a line, rectangle/square, ellipse/circle, triangle or polygon of up to
  eight corners become that shape, in the same undo step); with ink
  lasso-selected it converts the selection (draw.convertInk). Our own
  recogniser (cmd/ink_shape.rs): chord test for lines, closure test,
  ellipse RMS fit, closed RDP simplification with corner merging.
- draw.replay: Ink Replay plays the strokes back in drawing order with a
  Rewind / Play-Pause / Forward / Close bar; pages are drawn without ink
  meanwhile. Each new stroke records its drawing order (Freeform::order),
  written to DOCX in the drawing name (`Ink Pen 3 #12`) and read back.

Catalog 402/434 -> 406/434 (93.5%). New strings in all nine catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Regenerate parity checklist after rebase (#395)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-11 21:53:47 +02:00 committed by GitHub
parent 6526b134be
commit 4ca58d67e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1612 additions and 90 deletions

View File

@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **429 / 451 (95.1%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| Ribbon/menu catalog coverage | **433 / 451 (96.0%)** | 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) |
@ -147,6 +147,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) |
| 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents |
| 2026-10-11 | Formatting revisions (#41): `w:rPrChange`/`w:pPrChange`, table/row/cell, section and numbering changes kept in DOCX; formatting recorded as revisions while tracking; accept/reject, Reviewing Pane descriptions, "Formatted: …" balloons and change bars |
| 2026-10-11 | Draw: Lasso Select (move, recolour, delete the strokes inside a loop), Add Pen gallery kept between runs, Ink to Shape (own recogniser: line, rectangle/square, ellipse/circle, triangle, polygon), Ink Replay in drawing order with play/pause/rewind/forward; drawing order kept in DOCX (#395); catalog 406/434 (93.5%) |
| 2026-10-10 | Estonian (`et`) interface (#294): 1,161 translated entries, locale and saved-preference tests, bundled-font checks; no Estonian proofing resources |
| 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%) |
@ -162,6 +163,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 |
| 2026-10-11 | trivial | Progress log: document grid (#391) |
| 2026-10-11 | trivial | Progress log: text direction, Align Text and linked text boxes (#369) |
| 2026-10-10 | trivial | Progress: Drawing Canvas (#344): insert, shapes/pictures/text boxes inside, Fit/Expand/Scale, DOCX `wpc:wpc`, turns and flips as a whole; catalog 404/434 |

View File

@ -88,18 +88,22 @@ pub struct Freeform {
/// by the eraser.
#[serde(skip_serializing_if = "Option::is_none")]
pub ink: Option<InkTool>,
/// Ink: its place in the order the strokes were drawn (1, 2, 3…), for Draw › Ink Replay.
/// `None` for ink from files that don't record it (replayed first, in document order).
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<u32>,
}
impl Default for Freeform {
fn default() -> Self {
Freeform { w: 0.0, h: 0.0, paths: Vec::new(), alpha: 1.0, ink: None }
Freeform { w: 0.0, h: 0.0, paths: Vec::new(), alpha: 1.0, ink: None, order: 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()
Freeform { w, h, paths: vec![FreePath { pts, closed: false }], alpha: tool.alpha(), ink: Some(tool), order: None }.sanitized()
}
/// The same geometry with non-finite points dropped, coordinates clamped, the counts capped
@ -183,7 +187,7 @@ mod tests {
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();
let f = Freeform { w: f32::NAN, h: -4.0, paths: vec![FreePath { pts, closed: false }], alpha: 7.0, ink: None, order: 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);

View File

@ -71,7 +71,7 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
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();
let f = Freeform { w, h, paths, alpha: 1.0, ink: None, order: None }.sanitized();
(!f.paths.is_empty()).then_some(f)
}
@ -85,6 +85,12 @@ pub fn ink_tool(name: &str) -> Option<InkTool> {
words.next().and_then(InkTool::parse)
}
/// The drawing order of an ink stroke written by WordCraft, from its drawing's name
/// (`Ink Pen 3 #12`: the twelfth stroke drawn), for Ink Replay.
pub fn ink_order(name: &str) -> Option<u32> {
name.split_whitespace().skip(3).find_map(|w| w.strip_prefix('#')?.parse().ok())
}
/// 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"))

View File

@ -816,7 +816,9 @@ impl Reader<'_> {
&& 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);
let name = c.child("wp:docPr").and_then(|p| p.attr("name"));
f.ink = name.and_then(super::freeform::ink_tool);
f.order = name.filter(|_| f.ink.is_some()).and_then(super::freeform::ink_order);
*kind = ShapeKind::Freeform;
*freeform = Some(Arc::new(f));
}

View File

@ -1191,11 +1191,15 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
}
}
/// A shape's `cNvPr`/`docPr` name: ink is "Ink <pen> <id>", which reading looks for.
/// A shape's `cNvPr`/`docPr` name: ink is "Ink <pen> <id>" (and "#<order>" when its drawing
/// order is known), 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}"),
let f = freeform.filter(|_| kind == ShapeKind::Freeform);
match (f.and_then(|f| f.ink), f.and_then(|f| f.order)) {
// The drawing order rides along for Ink Replay: `Ink Pen 3 #12`.
(Some(tool), Some(n)) => format!("Ink {} {id} #{n}", ink_name(tool)),
(Some(tool), None) => format!("Ink {} {id}", ink_name(tool)),
(None, _) => format!("Shape {id}"),
}
}

View File

@ -1373,7 +1373,8 @@ fn list_level_overrides_round_trip() {
}
/// 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.
/// `a:moveTo`/`a:lnTo`) in floating drawings and read back with their points, pen, opacity and
/// drawing order.
#[test]
fn ink_and_freeforms_round_trip_as_custom_geometry() {
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool};
@ -1389,7 +1390,13 @@ fn ink_and_freeforms_round_trip_as_custom_geometry() {
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]]));
// The pen stroke was the seventh drawn: Ink Replay's order comes back too.
let pen = shape(
None,
2.0,
Wrap::InFrontOfText,
Freeform { order: Some(7), ..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,

View File

@ -1,20 +1,28 @@
//! Draw tab and Review › Ink: pens, the eraser and ink strokes.
//! Draw tab and Review › Ink: pens, the eraser, the lasso 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.
//!
//! Draw › Lasso Select picks the strokes (and drawings) inside a loop drawn on a page; the
//! selection can then be moved, recoloured, deleted or turned into shapes. Ink to Shape replaces
//! strokes that look like a line, rectangle, ellipse, triangle or polygon with that shape
//! ([`ink_shape`](super::ink_shape)). Ink Replay plays the strokes back in the order they were
//! drawn (each stroke keeps its place in that order). Add Pen keeps custom pens in a gallery.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Value, json};
use wordcraft_doc::freeform::{Freeform, InkTool, MAX_INK_PER_PAGE, MAX_POINTS};
use wordcraft_doc::freeform::{FreePath, 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_geom::Rect;
use wordcraft_layout::{DocLayout, Placed};
use super::ink_shape::{InkShape, recognise};
use super::pos_json;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@ -25,6 +33,8 @@ pub enum DrawMode {
/// Ordinary selection of text and drawings.
#[default]
Select,
/// Dragging draws a loop that selects the ink inside it; dragging the selection moves it.
Lasso,
/// Dragging over ink strokes deletes them.
Eraser,
/// Dragging draws ink with this pen.
@ -56,7 +66,69 @@ impl PenSettings {
}
}
/// The Draw tab's state: the tool in use and each pen's settings.
/// A pen added to the pen gallery (Draw › Add Pen): its kind, colour and thickness. Saved as
/// `{"kind": "pen", "color": "RRGGBB", "width": 1.5}`.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "Value", into = "Value")]
pub struct CustomPen {
pub kind: InkTool,
pub color: Rgb,
/// Points.
pub width: f32,
}
impl TryFrom<Value> for CustomPen {
type Error = CmdError;
fn try_from(v: Value) -> Result<Self, CmdError> {
custom_pen(&v)
}
}
impl From<CustomPen> for Value {
fn from(p: CustomPen) -> Value {
json!({"kind": p.kind.name(), "color": p.color.hex(), "width": p.width})
}
}
/// Most pens the gallery keeps.
pub const MAX_PENS: usize = 24;
/// A pen from `{"kind"?, "color"?, "width"?}` (the kind's defaults for what's left out; the
/// width clamped). Anything but an object, an unknown kind or a malformed colour is refused.
pub fn custom_pen(v: &Value) -> Result<CustomPen, CmdError> {
if !v.is_object() {
return Err(CmdError::Params("a pen is {\"kind\", \"color\", \"width\"}".into()));
}
let kind = match p::str(v, "kind") {
Some(k) => InkTool::parse(k).ok_or_else(|| CmdError::Params("`kind` is pen, pencil or highlighter".into()))?,
None => InkTool::Pen,
};
let color = match p::str(v, "color") {
Some(c) => Rgb::parse(c).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))?,
None => kind.default_color(),
};
let width = p::f32(v, "width").filter(|w| w.is_finite()).unwrap_or(kind.default_width()).clamp(MIN_WIDTH, MAX_WIDTH);
Ok(CustomPen { kind, color, width })
}
/// Read a saved pen gallery, dropping entries that aren't pens (a hand-edited or damaged
/// settings file loses those pens, never the rest of the settings).
pub fn lenient_pens<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<CustomPen>, D::Error> {
let v = Value::deserialize(d)?;
Ok(v.as_array().map(|a| a.iter().filter_map(|x| custom_pen(x).ok()).take(MAX_PENS).collect()).unwrap_or_default())
}
/// Strokes and drawings picked with the lasso on `page`. Good while the document is as it was
/// when they were picked (or last moved, recoloured or converted through the lasso).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LassoSelection {
pub page: usize,
pub items: Vec<Pos>,
rev: u64,
}
/// The Draw tab's state: the tool in use, each pen's settings, the pen gallery, Ink to Shape,
/// the lasso selection and whether ink is being replayed.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DrawState {
@ -64,6 +136,16 @@ pub struct DrawState {
pub pen: PenSettings,
pub pencil: PenSettings,
pub highlighter: PenSettings,
/// Pens added with Add Pen (kept between runs by the app).
#[serde(deserialize_with = "lenient_pens")]
pub pens: Vec<CustomPen>,
/// Ink to Shape is on: strokes drawn from now on that look like a shape become that shape.
pub ink_to_shape: bool,
#[serde(skip)]
pub lasso: Option<LassoSelection>,
/// Ink Replay is showing (the app plays the strokes back and turns this off when done).
#[serde(skip)]
pub replay: bool,
}
impl Default for DrawState {
@ -73,6 +155,10 @@ impl Default for DrawState {
pen: PenSettings::of(InkTool::Pen),
pencil: PenSettings::of(InkTool::Pencil),
highlighter: PenSettings::of(InkTool::Highlighter),
pens: Vec::new(),
ink_to_shape: false,
lasso: None,
replay: false,
}
}
}
@ -99,6 +185,12 @@ 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;
/// Most points of a lasso loop that are kept (a longer loop is thinned out).
const MAX_LASSO_POINTS: usize = 512;
/// Points of a stroke tested against the lasso (spread along it).
const LASSO_SAMPLES: usize = 64;
/// Farthest the lasso moves its selection in one step, points.
const MAX_MOVE: f32 = 10_000.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
@ -107,6 +199,12 @@ pub fn specs() -> Vec<CommandSpec> {
state(s)
})
.pure(),
CommandSpec::new("draw.lasso", "Lasso Select", "Draw › Tools", lasso)
.params(r#"{"page"?: n, "points"?: [[x, y], …], "clear"?: bool} (no points: use the lasso; points: select the ink inside that loop; clear: drop the selection)"#)
.pure(),
CommandSpec::new("draw.lassoMove", "Move Selected Ink", "Draw › Tools", lasso_move).params(r#"{"dx": pt, "dy": pt}"#),
CommandSpec::new("draw.lassoDelete", "Delete Selected Ink", "Draw › Tools", lasso_delete),
CommandSpec::new("draw.lassoColor", "Recolor Selected Ink", "Draw › Tools", lasso_color).params(r#"{"color": "RRGGBB"}"#),
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() {
@ -120,10 +218,42 @@ pub fn specs() -> Vec<CommandSpec> {
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.addPen", "Add Pen", "Draw › Pens", add_pen)
.params(r#"{"kind"?: "pen|pencil|highlighter", "color"?: "RRGGBB", "width"?: pt} (adds the pen to the gallery and picks it up)"#)
.pure(),
CommandSpec::new("draw.removePen", "Remove Pen", "Draw › Pens", |s, v| {
let i = p::u64(v, "index").and_then(|i| usize::try_from(i).ok()).ok_or_else(|| CmdError::Params("`index` (0-based) is required".into()))?;
if i >= s.view.draw.pens.len() {
return Err(CmdError::Params(format!("no pen {i}")));
}
let pen = s.view.draw.pens.remove(i);
Ok(json!({"removed": Value::from(pen), "pens": s.view.draw.pens.len()}))
})
.params(r#"{"index": n}"#)
.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("draw.inkToShape", "Ink to Shape", "Draw › Convert", |s, v| {
if let Some(on) = p::bool(v, "value") {
s.view.draw.ink_to_shape = on;
return Ok(json!({"value": on}));
}
// With strokes lasso-selected it converts them; otherwise it turns the mode on or off.
let ink_selected = lasso_selection(s).is_some_and(|l| l.items.iter().any(|pos| s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).and_then(InlineObject::ink).is_some()));
if ink_selected {
return s.run("draw.convertInk", &json!({}));
}
s.view.draw.ink_to_shape = !s.view.draw.ink_to_shape;
Ok(json!({"value": s.view.draw.ink_to_shape}))
})
.params(r#"{"value"?: bool} (no value: convert the lasso-selected strokes, or turn Ink to Shape on or off)"#)
.pure(),
CommandSpec::new("draw.convertInk", "Convert to Shapes", "Draw › Convert", convert_selection),
CommandSpec::new("draw.replay", "Ink Replay", "Draw › Replay", replay)
.params(r#"{"value"?: bool} (starts or stops Ink Replay; returns the strokes in the order they were drawn)"#)
.pure(),
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}))
@ -146,15 +276,54 @@ fn pen(s: &mut Session, v: &Value, tool: InkTool) -> CmdResult {
if let Some(c) = color {
set.color = c;
}
if let Some(w) = p::f32(v, "width") {
if let Some(w) = p::f32(v, "width").filter(|w| w.is_finite()) {
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, unturned frame, turn and geometry on the page.
fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<(Pos, wordcraft_geom::Rect, wordcraft_geom::Spin, Arc<Freeform>, f32)> {
/// Add a pen to the gallery and pick it up.
fn add_pen(s: &mut Session, v: &Value) -> CmdResult {
let pen = custom_pen(v)?;
if s.view.draw.pens.len() >= MAX_PENS {
return Err(CmdError::Failed(format!("the pen gallery holds at most {MAX_PENS} pens")));
}
s.view.draw.pens.push(pen);
*s.view.draw.settings_mut(pen.kind) = PenSettings { color: pen.color, width: pen.width };
s.view.draw.mode = DrawMode::Pen(pen.kind);
Ok(json!({"pen": Value::from(pen), "index": s.view.draw.pens.len() - 1, "pens": s.view.draw.pens.len()}))
}
/// An ink stroke laid out on a page.
#[derive(Clone, Debug)]
pub struct PlacedInk {
pub pos: Pos,
/// Its unturned frame on the page.
pub rect: Rect,
/// Its turn about the frame's centre.
pub spin: wordcraft_geom::Spin,
pub freeform: Arc<Freeform>,
pub color: Option<Rgb>,
pub width: f32,
}
impl PlacedInk {
/// Its paths in page points, turned as it is drawn.
pub fn paths(&self) -> Vec<Vec<[f32; 2]>> {
let r = self.rect;
let paths = self.freeform.placed(r.x, r.y, r.w, r.h).into_iter().map(|(pts, _)| pts);
if self.spin.is_identity() {
return paths.collect();
}
let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0);
paths.map(|pts| pts.into_iter().map(|[x, y]| <[f32; 2]>::from(self.spin.apply(cx, cy, x, y))).collect()).collect()
}
}
/// Ink strokes laid out on `page`, in the page's drawing order: their position, unturned frame,
/// turn and geometry on the page.
pub fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<PlacedInk> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
pg.items
.iter()
@ -162,8 +331,8 @@ fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<(Pos, wordcraft_g
Placed::Object { rect, spin, 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, *spin, f.clone(), *stroke_width))
Some(InlineObject::Shape { freeform: Some(f), stroke_width, stroke, .. }) if f.is_ink() => {
Some(PlacedInk { pos, rect: *rect, spin: *spin, freeform: f.clone(), color: *stroke, width: *stroke_width })
}
_ => None,
}
@ -179,12 +348,13 @@ pub fn ink_at(s: &mut Session, page: usize, x: f32, y: f32) -> Option<Pos> {
return None;
}
let layout = s.layout();
ink_on(s, &layout, page).into_iter().rev().find_map(|(pos, r, spin, f, sw)| {
let reach = (if sw.is_finite() { sw.clamp(0.0, MAX_WIDTH) } else { 1.0 }) / 2.0 + ERASE_SLOP;
ink_on(s, &layout, page).into_iter().rev().find_map(|k| {
let reach = (if k.width.is_finite() { k.width.clamp(0.0, MAX_WIDTH) } else { 1.0 }) / 2.0 + ERASE_SLOP;
// A turned stroke is drawn turned about its frame's centre: test the point turned back
// into the stroke's own frame (a turn keeps distances, so the reach holds).
let (u, v) = spin.unapply(r.x + r.w / 2.0, r.y + r.h / 2.0, x, y);
f.distance(r.x, r.y, r.w, r.h, u, v).filter(|d| *d <= reach).map(|_| pos)
let r = k.rect;
let (u, v) = k.spin.unapply(r.x + r.w / 2.0, r.y + r.h / 2.0, x, y);
k.freeform.distance(r.x, r.y, r.w, r.h, u, v).filter(|d| *d <= reach).map(|_| k.pos)
})
}
@ -195,19 +365,55 @@ fn page_param(s: &mut Session, v: &Value) -> Result<(usize, Arc<DocLayout>), Cmd
Ok((page, layout))
}
/// `points` as page points: at most `max`, NaN, infinities and malformed entries dropped, each
/// clamped to the `pw` × `ph` page.
fn points_param(v: &Value, max: usize, pw: f32, ph: f32) -> Result<Vec<[f32; 2]>, CmdError> {
Ok(v.get("points")
.and_then(Value::as_array)
.ok_or_else(|| CmdError::Params("`points` ([[x, y], …]) is required".into()))?
.iter()
.take(max)
.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())
}
/// 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()))?;
delete_object(s, &pos)?;
Ok(json!({"erased": pos_json(&pos)}))
}
/// Delete the object at `pos`, keeping the text selection on the same text.
fn delete_object(s: &mut Session, pos: &Pos) -> Result<(), CmdError> {
let len = OBJ.len_utf8();
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
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)}))
Ok(())
}
/// The next stroke's place in the drawing order: one after the latest drawn.
fn next_order(s: &Session) -> u32 {
let latest = s
.doc
.para_paths(StoryRef::Body)
.iter()
.filter_map(|path| s.doc.para(StoryRef::Body, path))
.flat_map(|p| p.objects.iter())
.filter_map(|o| o.ink().and_then(|f| f.order))
.max()
.unwrap_or(0);
latest.saturating_add(1)
}
/// Draw an ink stroke through page points.
@ -222,22 +428,10 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult {
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 width = p::f32(v, "width").filter(|w| w.is_finite()).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 pts = points_param(v, MAX_POINTS, pw, ph)?;
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")));
@ -252,6 +446,7 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult {
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 order = next_order(s);
let obj = InlineObject::Shape {
kind: ShapeKind::Freeform,
w: fw,
@ -261,11 +456,13 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult {
stroke_width: width,
float: Float { wrap, ..Default::default() },
story: None,
freeform: Some(Arc::new(Freeform::ink(tool, fw, fh, local))),
freeform: Some(Arc::new(Freeform { order: Some(order), ..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]}))
// Ink to Shape: a stroke that looks like a shape becomes it (in the same undo step).
let shape = if s.view.draw.ink_to_shape && tool != InkTool::Highlighter { convert_at(s, &at)? } else { None };
Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh], "shape": shape}))
}
/// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph
@ -303,12 +500,271 @@ fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x
Ok(at)
}
// --- Lasso -------------------------------------------------------------------------------
/// The current lasso selection, if the document hasn't changed under it.
pub fn lasso_selection(s: &Session) -> Option<&LassoSelection> {
s.view.draw.lasso.as_ref().filter(|l| l.rev == s.rev() && !l.items.is_empty())
}
/// Keep `sel` selected after the mutating command running now (which bumps the revision once).
fn keep_lasso(s: &mut Session, sel: LassoSelection) {
s.view.draw.lasso = Some(LassoSelection { rev: s.rev().wrapping_add(1), ..sel });
}
/// Whether (`x`, `y`) is inside the closed loop `poly` (even–odd rule).
fn inside(poly: &[[f32; 2]], x: f32, y: f32) -> bool {
let n = poly.len();
let mut odd = false;
for i in 0..n {
let (Some(a), Some(b)) = (poly.get(i), poly.get((i + n - 1) % n)) else { continue };
if (a[1] > y) != (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0] {
odd = !odd;
}
}
odd
}
/// Select the ink inside a loop (most of a stroke's points inside it) and the drawings whose
/// centre is inside it; without a loop, pick up the lasso.
fn lasso(s: &mut Session, v: &Value) -> CmdResult {
if p::bool(v, "clear") == Some(true) {
s.view.draw.lasso = None;
return Ok(json!({"selected": [], "count": 0}));
}
if v.get("points").is_none() {
s.view.draw.mode = DrawMode::Lasso;
return state(s);
}
let (page, layout) = page_param(s, v)?;
let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?;
let mut poly = points_param(v, MAX_POINTS, pg.w.max(1.0), pg.h.clamp(1.0, 1e5))?;
if poly.len() > MAX_LASSO_POINTS {
let step = poly.len().div_ceil(MAX_LASSO_POINTS);
poly = poly.into_iter().step_by(step).collect();
}
if poly.len() < 3 {
return Err(CmdError::Params("a lasso loop needs at least three points".into()));
}
let mut items = Vec::new();
for it in &pg.items {
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
let pos = Pos { story: *story, path: path.clone(), off: *off };
let Some(obj) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
let picked = match obj.ink() {
Some(_) if s.view.hide_ink => false,
Some(f) => {
// Where the stroke is drawn: turned about its frame's centre.
let (cx, cy) = (rect.x + rect.w / 2.0, rect.y + rect.h / 2.0);
let pts: Vec<[f32; 2]> = f
.placed(rect.x, rect.y, rect.w, rect.h)
.into_iter()
.flat_map(|(p, _)| p)
.map(|[x, y]| <[f32; 2]>::from(spin.apply(cx, cy, x, y)))
.collect();
let step = pts.len().div_ceil(LASSO_SAMPLES).max(1);
let (n, hits) = pts.iter().step_by(step).fold((0usize, 0usize), |(n, h), [x, y]| (n + 1, h + usize::from(inside(&poly, *x, *y))));
n > 0 && hits * 10 >= n * 6
}
None => obj.frame().is_some() && inside(&poly, rect.x + rect.w / 2.0, rect.y + rect.h / 2.0),
};
if picked && !items.contains(&pos) {
items.push(pos);
}
}
let selected: Vec<Value> = items.iter().map(pos_json).collect();
s.view.draw.lasso = (!items.is_empty()).then(|| LassoSelection { page, items, rev: s.rev() });
Ok(json!({"page": page, "count": selected.len(), "selected": selected}))
}
fn lasso_required(s: &Session) -> Result<LassoSelection, CmdError> {
lasso_selection(s).cloned().ok_or_else(|| CmdError::Failed("nothing is selected with the lasso".into()))
}
/// The lasso selection's bounds on its page in `layout` (the document's current layout), for
/// the app to draw and drag.
pub fn lasso_bounds(s: &Session, layout: &DocLayout) -> Option<(usize, Rect)> {
let sel = lasso_selection(s)?;
let mut r: Option<Rect> = None;
for pos in &sel.items {
if let Some(hit) = layout.object(pos, sel.page) {
r = Some(r.map_or(hit.rect, |u| u.union(&hit.rect)));
}
}
r.map(|r| (sel.page, r))
}
/// Move the lasso-selected drawings by (`dx`, `dy`) points (one undo step).
fn lasso_move(s: &mut Session, v: &Value) -> CmdResult {
let sel = lasso_required(s)?;
let d = |k: &str| p::req_f32(v, k).map(|d| if d.is_finite() { d.clamp(-MAX_MOVE, MAX_MOVE) } else { 0.0 });
let (dx, dy) = (d("dx")?, d("dy")?);
let mut moved = 0;
for pos in &sel.items {
let para = s.doc.para_mut(pos.story, &pos.path)?;
let mut hit = false;
if let Some(o) = para.object_at_mut(pos.off)
&& o.is_floating()
&& let Some(f) = o.float_mut()
{
if f.h_align.is_none() {
f.x += dx;
}
if f.v_align.is_none() {
f.y += dy;
}
hit = true;
}
if hit {
para.touch();
moved += 1;
}
}
if moved == 0 {
return Err(CmdError::Failed("nothing selected can be moved".into()));
}
keep_lasso(s, sel);
Ok(json!({"moved": moved}))
}
/// Delete the lasso-selected strokes and drawings (one undo step).
fn lasso_delete(s: &mut Session, _: &Value) -> CmdResult {
let mut items = lasso_required(s)?.items;
// Last first, so the earlier ones keep their place.
items.sort();
items.dedup();
let mut deleted = 0;
for pos in items.iter().rev() {
if s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).is_some() {
delete_object(s, pos)?;
deleted += 1;
}
}
s.view.draw.lasso = None;
if deleted == 0 {
return Err(CmdError::Failed("nothing selected to delete".into()));
}
Ok(json!({"deleted": deleted}))
}
/// Give the lasso-selected strokes and shapes this outline colour (one undo step).
fn lasso_color(s: &mut Session, v: &Value) -> CmdResult {
let sel = lasso_required(s)?;
let color = Rgb::parse(p::req_str(v, "color")?).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))?;
let mut changed = 0;
for pos in &sel.items {
let para = s.doc.para_mut(pos.story, &pos.path)?;
let mut hit = false;
if let Some(InlineObject::Shape { stroke, .. }) = para.object_at_mut(pos.off) {
*stroke = Some(color);
hit = true;
}
if hit {
para.touch();
changed += 1;
}
}
if changed == 0 {
return Err(CmdError::Failed("nothing selected has an outline to recolour".into()));
}
keep_lasso(s, sel);
Ok(json!({"recolored": changed, "color": color.hex()}))
}
// --- Ink to Shape --------------------------------------------------------------------------
/// Turn the lasso-selected strokes that look like shapes into those shapes (one undo step).
fn convert_selection(s: &mut Session, _: &Value) -> CmdResult {
let sel = lasso_required(s)?;
let mut converted = Vec::new();
for pos in &sel.items {
if let Some(name) = convert_at(s, pos)? {
converted.push(json!({"pos": pos_json(pos), "shape": name}));
}
}
if converted.is_empty() {
return Err(CmdError::Failed("none of the selected strokes looks like a shape".into()));
}
keep_lasso(s, sel);
Ok(json!({"count": converted.len(), "converted": converted}))
}
/// Replace the ink stroke at `pos` by the shape it looks like, in its colour and thickness and
/// where it was drawn. `None` (nothing changed) when it isn't a pen or pencil stroke, it is
/// turned or flipped, or it doesn't look like a shape.
fn convert_at(s: &mut Session, pos: &Pos) -> Result<Option<&'static str>, CmdError> {
let para = s.doc.para_mut(pos.story, &pos.path)?;
let Some(InlineObject::Shape { kind, w, h, fill, float, freeform, .. }) = para.object_at_mut(pos.off) else { return Ok(None) };
let Some(f) = freeform.as_deref().filter(|f| matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && f.paths.len() == 1) else {
return Ok(None);
};
if float.rot != 0.0 || float.flip_h || float.flip_v {
return Ok(None);
}
// The stroke in its frame's own points (the frame may have been resized since).
let Some((pts, _)) = f.placed(0.0, 0.0, *w, *h).into_iter().next() else { return Ok(None) };
let Some(found) = recognise(&pts) else { return Ok(None) };
let [bx, by, bw, bh] = found.bounds();
let (bw, bh) = (bw.max(1.0), bh.max(1.0));
let outline = |pts: Vec<[f32; 2]>, closed: bool| {
Some(Arc::new(Freeform { w: bw, h: bh, paths: vec![FreePath { pts, closed }], ..Default::default() }.sanitized()))
};
let local = |c: &[[f32; 2]]| c.iter().map(|[x, y]| [x - bx, y - by]).collect::<Vec<_>>();
let (new_kind, geometry) = match &found {
InkShape::Rectangle { .. } => (ShapeKind::Rectangle, None),
InkShape::Ellipse { .. } => (ShapeKind::Ellipse, None),
InkShape::Line { a, b } => (ShapeKind::Freeform, outline(local(&[*a, *b]), false)),
InkShape::Polygon(c) => (ShapeKind::Freeform, outline(local(c), true)),
};
*kind = new_kind;
*freeform = geometry;
*fill = None;
*w = bw;
*h = bh;
float.x += bx;
float.y += by;
para.touch();
Ok(Some(found.name()))
}
// --- Ink Replay ----------------------------------------------------------------------------
/// Every ink stroke in the document with its page, in the order it was drawn: strokes that
/// don't record their order (from other files) first, in document order, then the rest by order.
pub fn strokes_in_drawing_order(s: &mut Session) -> Vec<(usize, PlacedInk)> {
if s.view.hide_ink {
return Vec::new();
}
let layout = s.layout();
let mut all: Vec<(usize, PlacedInk)> =
(0..layout.pages.len()).flat_map(|page| ink_on(s, &layout, page).into_iter().map(move |k| (page, k))).collect();
all.sort_by_key(|(_, k)| k.freeform.order.unwrap_or(0));
all
}
/// Start or stop Ink Replay; the strokes in the order they'll be replayed.
fn replay(s: &mut Session, v: &Value) -> CmdResult {
let on = p::bool(v, "value").unwrap_or(!s.view.draw.replay);
let strokes = if on { strokes_in_drawing_order(s) } else { Vec::new() };
if on && strokes.is_empty() {
return Err(CmdError::Failed("there is no ink to replay".into()));
}
s.view.draw.replay = on;
let list: Vec<Value> = strokes
.iter()
.map(|(page, k)| {
json!({"page": page, "pos": pos_json(&k.pos), "order": k.freeform.order, "tool": k.freeform.ink.map(InkTool::name), "points": k.freeform.paths.iter().map(|p| p.pts.len()).sum::<usize>()})
})
.collect();
Ok(json!({"replaying": on, "strokes": list}))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use wordcraft_doc::StoryRef;
use wordcraft_doc::freeform::InkTool;
use wordcraft_doc::para::{InlineObject, Wrap};
use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap};
use crate::Session;
@ -404,6 +860,20 @@ mod tests {
assert!(s.run("draw.erase", &json!({"page": 0, "x": 150, "y": 60})).is_err());
}
/// The lasso, like the eraser, finds a turned stroke where it is drawn.
#[test]
fn the_lasso_selects_a_rotated_stroke_where_it_is_drawn() {
let mut s = session();
s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap();
s.run("select.objects", &json!({"index": 0})).unwrap();
s.run("arrange.rotation", &json!({"degrees": 90})).unwrap();
// A loop around its old horizontal path misses it; one around the vertical stroke takes it.
let r = s.run("draw.lasso", &json!({"page": 0, "points": [[90, 95], [210, 95], [210, 105], [90, 105]]})).unwrap();
assert_eq!(r["count"], 0);
let r = s.run("draw.lasso", &json!({"page": 0, "points": [[140, 40], [160, 40], [160, 160], [140, 160]]})).unwrap();
assert_eq!(r["count"], 1);
}
/// `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]
@ -421,4 +891,127 @@ mod tests {
s.run("edit.redo", &json!({})).unwrap();
assert!(ink(&s).is_empty());
}
/// A closed rectangle drawn as page points, `per` points to a side.
fn rect_stroke(x: f32, y: f32, w: f32, h: f32) -> Vec<[f32; 2]> {
let c = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
let mut pts = Vec::new();
for i in 0..4 {
let (a, b) = (c[i], c[(i + 1) % 4]);
for k in 0..10 {
let t = k as f32 / 10.0;
pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
}
}
pts.push([x, y]);
pts
}
#[test]
fn the_lasso_selects_the_ink_inside_and_moves_recolours_and_deletes_it() {
let mut s = session();
s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [140, 110]]})).unwrap();
s.run("draw.stroke", &json!({"page": 0, "points": [[300, 300], [340, 310]]})).unwrap();
// Without points it picks up the lasso; a loop around the first stroke selects only it.
s.run("draw.lasso", &json!({})).unwrap();
assert_eq!(s.view.draw.mode, super::DrawMode::Lasso);
let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [200, 80], [200, 150], [80, 150]]})).unwrap();
assert_eq!(r["count"], 1);
let before = s.layout().object(&super::lasso_selection(&s).unwrap().items[0], 0).unwrap().rect;
// Moved, recoloured: each one undo step, and the selection stays.
let steps = s.undo_depth();
s.run("draw.lassoMove", &json!({"dx": 20, "dy": -10})).unwrap();
s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap();
assert_eq!(s.undo_depth(), steps + 2);
let sel = super::lasso_selection(&s).unwrap().items[0].clone();
let after = s.layout().object(&sel, 0).unwrap().rect;
assert!((after.x - before.x - 20.0).abs() < 0.01 && (after.y - before.y + 10.0).abs() < 0.01, "{before:?} -> {after:?}");
let objs = ink(&s);
let blue = objs.iter().filter(|o| matches!(o, InlineObject::Shape { stroke: Some(c), .. } if c.hex() == "0070C0")).count();
assert_eq!(blue, 1);
// Deleted: only the selected stroke goes, and the selection with it.
s.run("draw.lassoDelete", &json!({})).unwrap();
assert_eq!(ink(&s).len(), 1);
assert!(super::lasso_selection(&s).is_none());
assert!(s.run("draw.lassoMove", &json!({"dx": 1, "dy": 1})).is_err(), "nothing selected");
// Hostile loops are refused or select nothing; they never panic.
assert!(s.run("draw.lasso", &json!({"page": 0, "points": [[1, 1], [2, 2]]})).is_err());
let huge: Vec<[f64; 2]> = (0..20_000).map(|i| [(i % 600) as f64, (i / 40) as f64]).collect();
let _ = s.run("draw.lasso", &json!({"page": 0, "points": huge}));
}
#[test]
fn ink_to_shape_turns_shape_like_strokes_into_shapes_and_leaves_scribbles() {
let mut s = session();
assert_eq!(s.run("draw.inkToShape", &json!({})).unwrap()["value"], true);
let steps = s.undo_depth();
let r = s.run("draw.stroke", &json!({"page": 0, "points": rect_stroke(100.0, 100.0, 120.0, 60.0), "color": "C00000", "width": 2})).unwrap();
assert_eq!(r["shape"], "rectangle");
assert_eq!(s.undo_depth(), steps + 1, "drawn and converted in one undo step");
let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap();
let obj = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)).cloned().unwrap();
let InlineObject::Shape { kind, w, h, stroke, stroke_width, freeform, .. } = obj else { panic!("{obj:?}") };
assert_eq!((kind, stroke, stroke_width, freeform.is_none()), (ShapeKind::Rectangle, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, true));
assert!((w - 120.0).abs() < 2.0 && (h - 60.0).abs() < 2.0, "{w} x {h}");
let rect = s.layout().object(&at, 0).unwrap().rect;
assert!((rect.x - 100.0).abs() < 2.0 && (rect.y - 100.0).abs() < 2.0, "where it was drawn: {rect:?}");
// A scribble stays ink.
let r = s.run("draw.stroke", &json!({"page": 0, "points": [[300, 300], [320, 340], [340, 300], [360, 340], [380, 300]]})).unwrap();
assert!(r["shape"].is_null());
assert_eq!(ink(&s).len(), 1);
// Off again; a drawn ellipse stays ink until the lasso selects it and Ink to Shape converts it.
s.run("draw.inkToShape", &json!({"value": false})).unwrap();
let ring: Vec<[f32; 2]> =
(0..=48).map(|i| i as f32 / 48.0 * std::f32::consts::TAU).map(|a| [200.0 + 50.0 * a.cos(), 500.0 + 30.0 * a.sin()]).collect();
s.run("draw.stroke", &json!({"page": 0, "points": ring})).unwrap();
assert_eq!(ink(&s).len(), 2);
s.run("draw.lasso", &json!({"page": 0, "points": [[130, 450], [270, 450], [270, 550], [130, 550]]})).unwrap();
let r = s.run("draw.inkToShape", &json!({})).unwrap();
assert_eq!(r["converted"][0]["shape"], "ellipse");
assert_eq!(ink(&s).len(), 1);
assert!(!s.view.draw.ink_to_shape, "converting the selection doesn't turn the mode on");
}
#[test]
fn ink_replay_plays_strokes_in_the_order_they_were_drawn() {
let mut s = session();
// Drawn low on the page first, then higher: document order would be the other way round.
s.run("draw.stroke", &json!({"page": 0, "points": [[100, 400], [150, 400]]})).unwrap();
s.run("draw.stroke", &json!({"page": 0, "points": [[100, 90], [150, 90]]})).unwrap();
let r = s.run("draw.replay", &json!({})).unwrap();
assert!(s.view.draw.replay);
let orders: Vec<u64> = r["strokes"].as_array().unwrap().iter().map(|k| k["order"].as_u64().unwrap()).collect();
assert_eq!(orders, vec![1, 2]);
let ys: Vec<f32> = super::strokes_in_drawing_order(&mut s).iter().map(|(_, k)| k.rect.y).collect();
assert!(ys[0] > ys[1], "the lower stroke was drawn first: {ys:?}");
assert_eq!(s.run("draw.replay", &json!({})).unwrap()["replaying"], false);
// Nothing to replay without ink.
assert!(session().run("draw.replay", &json!({})).is_err());
}
#[test]
fn add_pen_validates_and_saved_galleries_drop_junk() {
let mut s = session();
let r = s.run("draw.addPen", &json!({"kind": "highlighter", "color": "00B050", "width": 1e9})).unwrap();
assert_eq!(r["pen"], json!({"kind": "highlighter", "color": "00B050", "width": super::MAX_WIDTH}));
assert_eq!(s.view.draw.mode, super::DrawMode::Pen(InkTool::Highlighter), "the new pen is picked up");
for bad in [json!({"kind": "crayon"}), json!({"color": "blue"}), json!(5), json!(null)] {
assert!(s.run("draw.addPen", &bad).is_err(), "{bad}");
}
for _ in 0..super::MAX_PENS + 3 {
let _ = s.run("draw.addPen", &json!({}));
}
assert_eq!(s.view.draw.pens.len(), super::MAX_PENS);
s.run("draw.removePen", &json!({"index": 0})).unwrap();
assert!(s.run("draw.removePen", &json!({"index": 999})).is_err());
// A saved gallery keeps its pens and drops the junk around them.
let saved =
json!({"pens": [{"kind": "pencil", "color": "7030A0", "width": 3}, "junk", {"kind": "crayon"}, {"width": "x"}], "inkToShape": true});
let d: super::DrawState = serde_json::from_value(saved).unwrap();
assert_eq!(d.pens.len(), 2);
assert_eq!((d.pens[0].kind, d.pens[0].color.hex(), d.pens[0].width), (InkTool::Pencil, "7030A0".to_string(), 3.0));
assert!(d.ink_to_shape);
let back: super::DrawState = serde_json::from_value(serde_json::to_value(&d).unwrap()).unwrap();
assert_eq!(back.pens, d.pens);
}
}

View File

@ -0,0 +1,325 @@
//! Draw › Ink to Shape: a small geometric recogniser that tells whether a hand-drawn stroke is a
//! straight line, a rectangle (or square), an ellipse (or circle), a triangle or another polygon
//! of up to eight corners. Our own fit: an open stroke is a line when it stays close to the chord
//! between its ends; a closed one is an ellipse when its points sit on the ellipse inscribed in
//! its bounds (low RMS of the normalised radius), else a polygon from its corners (the closed
//! path simplified with Ramer–Douglas–Peucker, near-straight and crowded corners merged) when
//! that polygon stays close to every point. Anything else (scribbles, curves, letters) stays ink.
use wordcraft_doc::freeform::MAX_POINTS;
/// A shape recognised in a stroke, in the stroke's own coordinates.
#[derive(Clone, Debug, PartialEq)]
pub enum InkShape {
/// A straight line from `a` to `b`.
Line { a: [f32; 2], b: [f32; 2] },
/// An upright rectangle: top-left and size (equal sides when drawn nearly square).
Rectangle { x: f32, y: f32, w: f32, h: f32 },
/// An upright ellipse in this box (a circle when drawn nearly round).
Ellipse { x: f32, y: f32, w: f32, h: f32 },
/// A closed polygon through its 3–8 corners (a triangle has three).
Polygon(Vec<[f32; 2]>),
}
impl InkShape {
/// What it is, for messages and command results.
pub fn name(&self) -> &'static str {
match self {
InkShape::Line { .. } => "line",
InkShape::Rectangle { w, h, .. } if w == h => "square",
InkShape::Rectangle { .. } => "rectangle",
InkShape::Ellipse { w, h, .. } if w == h => "circle",
InkShape::Ellipse { .. } => "ellipse",
InkShape::Polygon(c) if c.len() == 3 => "triangle",
InkShape::Polygon(_) => "polygon",
}
}
/// Its bounds: left, top, width, height.
pub fn bounds(&self) -> [f32; 4] {
match self {
InkShape::Rectangle { x, y, w, h } | InkShape::Ellipse { x, y, w, h } => [*x, *y, *w, *h],
InkShape::Line { a, b } => bbox(&[*a, *b]),
InkShape::Polygon(c) => bbox(c),
}
}
}
/// Smallest stroke (its longer side, points) that is turned into a shape: a tap or a tick stays ink.
const MIN_SIZE: f32 = 6.0;
/// Most corners a polygon may have.
pub const MAX_CORNERS: usize = 8;
/// The shape `pts` draws, if it is one.
pub fn recognise(pts: &[[f32; 2]]) -> Option<InkShape> {
// Finite points only, without repeats.
let mut p: Vec<[f32; 2]> = Vec::with_capacity(pts.len().min(MAX_POINTS));
for q in pts.iter().take(MAX_POINTS).filter(|[x, y]| x.is_finite() && y.is_finite() && x.abs() < 1e6 && y.abs() < 1e6) {
if p.last() != Some(q) {
p.push(*q);
}
}
let (first, last) = (*p.first()?, *p.last()?);
if p.len() < 2 {
return None;
}
let [x0, y0, w, h] = bbox(&p);
let size = w.max(h);
let diag = w.hypot(h);
if size < MIN_SIZE {
return None;
}
let length: f32 = p.windows(2).map(|s| s.first().zip(s.get(1)).map_or(0.0, |(a, b)| dist(*a, *b))).sum();
let gap = dist(first, last);
// A line: the stroke barely leaves the chord between its ends and doesn't double back.
if gap > 0.85 * length && gap >= MIN_SIZE {
let off = p.iter().map(|q| seg_distance(first, last, *q)).fold(0.0, f32::max);
if off <= (0.06 * gap).max(1.5) {
return Some(InkShape::Line { a: first, b: last });
}
}
// Other shapes are closed: the stroke ends near where it began and goes around.
if gap > 0.2 * size || length < 2.0 * size {
return None;
}
// Ellipse: the points sit on the ellipse inscribed in their bounds.
let ellipse_rms = ellipse_rms(&p, x0, y0, w, h);
if w > 0.2 * size && h > 0.2 * size && ellipse_rms < 0.06 {
let (x, y, w, h) = snap_square(x0, y0, w, h);
return Some(InkShape::Ellipse { x, y, w, h });
}
// Polygon: the corners of the closed path, if they describe it.
let corners = corners(&p, diag);
let fits = corners.len() >= 3 && p.iter().all(|q| polygon_distance(&corners, *q) <= 0.08 * diag);
if fits && corners.len() == 4 && upright(&corners) {
let [x, y, w, h] = bbox(&corners);
let (x, y, w, h) = snap_square(x, y, w, h);
return Some(InkShape::Rectangle { x, y, w, h });
}
if fits && corners.len() <= MAX_CORNERS {
return Some(InkShape::Polygon(corners));
}
// A wobbly round stroke is still an ellipse.
(w > 0.2 * size && h > 0.2 * size && ellipse_rms < 0.1).then(|| {
let (x, y, w, h) = snap_square(x0, y0, w, h);
InkShape::Ellipse { x, y, w, h }
})
}
/// Left, top, width and height of `pts` (zero for none).
fn bbox(pts: &[[f32; 2]]) -> [f32; 4] {
let Some(f) = pts.first() else { return [0.0; 4] };
let (mut x0, mut y0, mut x1, mut y1) = (f[0], f[1], f[0], f[1]);
for [x, y] in pts {
(x0, y0, x1, y1) = (x0.min(*x), y0.min(*y), x1.max(*x), y1.max(*y));
}
[x0, y0, x1 - x0, y1 - y0]
}
fn dist(a: [f32; 2], b: [f32; 2]) -> f32 {
(b[0] - a[0]).hypot(b[1] - a[1])
}
/// 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 };
dist([a[0] + t * dx, a[1] + t * dy], p)
}
/// Distance from `p` to the closed polygon through `c`.
fn polygon_distance(c: &[[f32; 2]], p: [f32; 2]) -> f32 {
let n = c.len();
(0..n).filter_map(|i| Some(seg_distance(*c.get(i)?, *c.get((i + 1) % n)?, p))).fold(f32::INFINITY, f32::min)
}
/// How far the points stray from the ellipse inscribed in the `w` × `h` box at (`x0`, `y0`): the
/// RMS of each point's normalised radius minus one (0 on the ellipse).
fn ellipse_rms(p: &[[f32; 2]], x0: f32, y0: f32, w: f32, h: f32) -> f32 {
let (rx, ry) = (w / 2.0, h / 2.0);
if rx <= 0.0 || ry <= 0.0 || p.is_empty() {
return f32::INFINITY;
}
let (cx, cy) = (x0 + rx, y0 + ry);
let sum: f32 = p.iter().map(|[x, y]| (((x - cx) / rx).hypot((y - cy) / ry) - 1.0).powi(2)).sum();
(sum / p.len() as f32).sqrt()
}
/// A box drawn nearly square made square (same centre).
fn snap_square(x: f32, y: f32, w: f32, h: f32) -> (f32, f32, f32, f32) {
if (w - h).abs() <= 0.12 * w.max(h) {
let s = (w + h) / 2.0;
(x + (w - s) / 2.0, y + (h - s) / 2.0, s, s)
} else {
(x, y, w, h)
}
}
/// The corners of the closed path through `p`: simplified with Ramer–Douglas–Peucker (split
/// at the point farthest from the start), then corners that barely turn or crowd a neighbour
/// merged away.
fn corners(p: &[[f32; 2]], diag: f32) -> Vec<[f32; 2]> {
let eps = 0.05 * diag;
let Some(&start) = p.first() else { return Vec::new() };
let far = p.iter().enumerate().fold((0, 0.0), |best, (i, q)| if dist(start, *q) > best.1 { (i, dist(start, *q)) } else { best }).0;
let (a, b) = p.split_at(far.min(p.len()));
let mut ring: Vec<[f32; 2]> = rdp(&[a, b.get(..1).unwrap_or(&[])].concat(), eps);
let mut back = rdp(&[b, &[start]].concat(), eps);
// The halves share the far point and the start.
ring.pop();
back.pop();
ring.extend(back);
// Merge until every corner turns sharply and none sits on top of the next.
while ring.len() > 3 {
let n = ring.len();
let turn = |i: usize| -> f32 {
let (Some(a), Some(b), Some(c)) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n)) else { return 0.0 };
turning(*a, *b, *c)
};
let (i, t) = (0..n).map(|i| (i, turn(i))).fold((0, f32::INFINITY), |m, x| if x.1 < m.1 { x } else { m });
let short = (0..n).find(|&j| ring.get(j).zip(ring.get((j + 1) % n)).is_some_and(|(a, b)| dist(*a, *b) < 0.1 * diag));
if t < 30.0 {
ring.remove(i);
} else if let Some(j) = short {
// Of the two crowded corners keep the sharper.
let k = if turn(j) < turn((j + 1) % n) { j } else { (j + 1) % n };
ring.remove(k);
} else {
break;
}
}
ring
}
/// How far the path turns at `b` coming from `a` and going on to `c`, degrees (0 = straight on).
fn turning(a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> f32 {
let (u, v) = ([b[0] - a[0], b[1] - a[1]], [c[0] - b[0], c[1] - b[1]]);
let (lu, lv) = (u[0].hypot(u[1]), v[0].hypot(v[1]));
if lu <= 0.0 || lv <= 0.0 {
return 0.0;
}
((u[0] * v[0] + u[1] * v[1]) / (lu * lv)).clamp(-1.0, 1.0).acos().to_degrees()
}
/// Ramer–Douglas–Peucker simplification of an open path (iterative: no deep recursion on long
/// strokes); keeps both ends.
fn rdp(p: &[[f32; 2]], eps: f32) -> Vec<[f32; 2]> {
let n = p.len();
if n < 3 {
return p.to_vec();
}
let mut keep = vec![false; n];
if let Some(f) = keep.first_mut() {
*f = true;
}
if let Some(l) = keep.last_mut() {
*l = true;
}
let mut stack = vec![(0usize, n - 1)];
while let Some((a, b)) = stack.pop() {
let (Some(&pa), Some(&pb)) = (p.get(a), p.get(b)) else { continue };
let far = (a + 1..b).filter_map(|i| Some((i, seg_distance(pa, pb, *p.get(i)?)))).fold((a, 0.0), |m, x| if x.1 > m.1 { x } else { m });
if far.1 > eps
&& let Some(k) = keep.get_mut(far.0)
{
*k = true;
stack.push((a, far.0));
stack.push((far.0, b));
}
}
p.iter().zip(keep).filter(|(_, k)| *k).map(|(q, _)| *q).collect()
}
/// A quadrilateral whose sides run (nearly) across and down the page, alternately.
fn upright(c: &[[f32; 2]]) -> bool {
let n = c.len();
let dirs: Vec<Option<bool>> = (0..n)
.map(|i| {
let (a, b) = (c.get(i)?, c.get((i + 1) % n)?);
let deg = (b[1] - a[1]).atan2(b[0] - a[0]).to_degrees().rem_euclid(180.0);
// true: across, false: down; None when it's slanted.
if deg <= 15.0 || deg >= 165.0 {
Some(true)
} else if (75.0..=105.0).contains(&deg) {
Some(false)
} else {
None
}
})
.collect();
dirs.iter().all(Option::is_some) && (0..n).all(|i| dirs.get(i) != dirs.get((i + 1) % n))
}
#[cfg(test)]
mod tests {
use super::*;
/// Points along a closed polygon, `per` to a side, with a little deterministic hand wobble.
fn around(corners: &[[f32; 2]], per: usize) -> Vec<[f32; 2]> {
let mut out = Vec::new();
for (i, a) in corners.iter().enumerate() {
let b = corners[(i + 1) % corners.len()];
for k in 0..per {
let t = k as f32 / per as f32;
let wob = (out.len() as f32 * 1.7).sin() * 0.6;
out.push([a[0] + (b[0] - a[0]) * t + wob, a[1] + (b[1] - a[1]) * t - wob]);
}
}
out.push(corners[0]);
out
}
#[test]
fn rectangles_ellipses_lines_and_triangles_are_recognised() {
// A rectangle, started mid-side as people often do.
let rect = around(&[[50.0, 10.0], [110.0, 10.0], [110.0, 70.0 + 0.0], [10.0, 70.0], [10.0, 10.0]], 12);
let Some(InkShape::Rectangle { x, y, w, h }) = recognise(&rect) else { panic!("{:?}", recognise(&rect)) };
assert!((x - 10.0).abs() < 2.0 && (y - 10.0).abs() < 2.0 && (w - 100.0).abs() < 3.0 && (h - 60.0).abs() < 3.0, "{x} {y} {w} {h}");
// Nearly square: a square.
assert_eq!(recognise(&around(&[[0.0, 0.0], [60.0, 0.0], [60.0, 57.0], [0.0, 57.0]], 10)).map(|s| s.name()), Some("square"));
// An ellipse and a circle.
let ring = |rx: f32, ry: f32| {
(0..=64).map(|i| (i as f32 / 64.0) * std::f32::consts::TAU).map(|a| [100.0 + rx * a.cos(), 80.0 + ry * a.sin()]).collect::<Vec<_>>()
};
let Some(InkShape::Ellipse { x, w, h, .. }) = recognise(&ring(40.0, 25.0)) else { panic!() };
assert!((x - 60.0).abs() < 0.5 && (w - 80.0).abs() < 0.5 && (h - 50.0).abs() < 0.5);
assert_eq!(recognise(&ring(30.0, 29.0)).map(|s| s.name()), Some("circle"));
// A slightly shaky line keeps its ends.
let line: Vec<[f32; 2]> = (0..=40).map(|i| i as f32).map(|t| [10.0 + t * 3.0, 20.0 + t + (t * 2.1).sin() * 0.8]).collect();
assert_eq!(recognise(&line), Some(InkShape::Line { a: line[0], b: line[40] }));
// A triangle and a pentagon.
let tri = around(&[[0.0, 80.0], [45.0, 0.0], [90.0, 80.0]], 15);
let Some(InkShape::Polygon(c)) = recognise(&tri) else { panic!("{:?}", recognise(&tri)) };
assert_eq!(c.len(), 3);
let penta: Vec<[f32; 2]> = (0..5)
.map(|i| (i as f32 / 5.0) * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2)
.map(|a| [60.0 + 50.0 * a.cos(), 60.0 + 50.0 * a.sin()])
.collect();
assert_eq!(recognise(&around(&penta, 12)).map(|s| s.name()), Some("polygon"));
}
#[test]
fn scribbles_ticks_and_hostile_points_stay_ink() {
// An open zigzag, a hook, a tap and a tiny tick.
let zigzag: Vec<[f32; 2]> = (0..20).map(|i| [i as f32 * 8.0, if i % 2 == 0 { 0.0 } else { 30.0 }]).collect();
assert_eq!(recognise(&zigzag), None);
assert_eq!(recognise(&[[0.0, 0.0], [50.0, 0.0], [50.0, 50.0]]), None);
assert_eq!(recognise(&[[5.0, 5.0]]), None);
assert_eq!(recognise(&[[0.0, 0.0], [2.0, 3.0]]), None);
// A closed scribble with many spikes: not a polygon of eight or fewer corners, not round.
let star: Vec<[f32; 2]> = (0..=24)
.map(|i| {
let a = i as f32 / 24.0 * std::f32::consts::TAU;
let r = if i % 2 == 0 { 60.0 } else { 20.0 };
[80.0 + r * a.cos(), 80.0 + r * a.sin()]
})
.collect();
assert_eq!(recognise(&star), None);
// NaN, infinities and a flood of points never panic.
let mut junk = vec![[f32::NAN, 1.0], [f32::INFINITY, 2.0], [3.0, f32::NEG_INFINITY]];
junk.extend(std::iter::repeat_n([1.0, 1.0], MAX_POINTS * 3));
assert_eq!(recognise(&junk), None);
assert_eq!(recognise(&[]), None);
}
}

View File

@ -12,6 +12,7 @@ pub mod fields;
pub mod file;
pub mod fmt_revisions;
pub mod format;
pub mod ink_shape;
pub mod insert;
pub mod inspector;
pub mod lists;

View File

@ -50,6 +50,8 @@ pub struct CanvasState {
pub(crate) ink: Option<crate::ink::InkDrag>,
/// The eraser is down: whether it has erased anything in this drag yet.
pub(crate) erasing: Option<bool>,
/// The lasso being drawn or dragged, and Ink Replay (Draw tab).
pub(crate) ink_ui: crate::ink::InkUi,
/// 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.
@ -111,6 +113,7 @@ impl Default for CanvasState {
obj_drag: None,
ink: None,
erasing: None,
ink_ui: Default::default(),
context_menu_open: false,
mini_anchor: None,
pasted: false,
@ -266,7 +269,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, v.hide_ink).hash(&mut h);
(v.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).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);
@ -350,6 +353,7 @@ fn drawing_grid_step(spacing: f32, scale: f32) -> f32 {
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
crate::ink::replay_tick(app, ui.ctx());
let layout = app.session.layout();
// View › Switch Modes: dark paper, inverted document colours, a white caret.
let dark_page = app.session.view.dark_mode;
@ -460,7 +464,8 @@ 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;
// Ink Replay draws the ink itself, stroke by stroke, over pages without it.
opts.display.hide_ink = app.session.view.hide_ink || app.session.view.draw.replay;
opts.display.revisions = app.session.prefs.markup.clone();
opts.dark = dark_page;
opts.dark_paper = dark_paper;
@ -675,6 +680,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
(resp, rects)
});
app.canvas.scroll_offset = out.state.offset;
crate::ink::replay_bar(app, ui, out.inner_rect);
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
let (resp, rects) = out.inner;
// A click on the page leaves the selected comment balloon.

View File

@ -1469,3 +1469,16 @@
Continuous numbering Numeración continua
Apply Aplicar
Convert Notes Convertir notas
Lasso Select Selección con lazo
Move Selected Ink Mover la tinta seleccionada
Delete Selected Ink Eliminar la tinta seleccionada
Recolor Selected Ink Cambiar el color de la tinta seleccionada
Add Pen Agregar pluma
Remove Pen Quitar pluma
Ink to Shape Tinta a forma
Convert to Shapes Convertir en formas
Ink Replay Reproducir tinta
Add\nPen Agregar\npluma
Rewind Rebobinar
Forward Avanzar
Stroke {n} of {total} Trazo {n} de {total}

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1463,3 +1463,16 @@
Continuous numbering Pidev nummerdus
Apply Rakenda
Convert Notes Teisenda märkused
Lasso Select Lassoga valimine
Move Selected Ink Teisalda valitud tint
Delete Selected Ink Kustuta valitud tint
Recolor Selected Ink Muuda valitud tindi värvi
Add Pen Lisa pliiats
Remove Pen Eemalda pliiats
Ink to Shape Käsikiri kujundiks
Convert to Shapes Teisenda kujunditeks
Ink Replay Esita käsikiri
Add\nPen Lisa\npliiats
Rewind Keri algusesse
Forward Edasi
Stroke {n} of {total} Joon {n}/{total}

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -1453,3 +1453,16 @@
Continuous numbering 通し番号
Apply 適用
Convert Notes 注の変換
Lasso Select なげなわ選択
Move Selected Ink 選択したインクを移動
Delete Selected Ink 選択したインクを削除
Recolor Selected Ink 選択したインクの色を変更
Add Pen ペンの追加
Remove Pen ペンの削除
Ink to Shape インクを図形に変換
Convert to Shapes 図形に変換
Ink Replay インクの再生
Add\nPen ペンの\n追加
Rewind 巻き戻し
Forward 早送り
Stroke {n} of {total} ストローク {n}/{total}

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -1465,3 +1465,16 @@
Continuous numbering Numeração contínua
Apply Aplicar
Convert Notes Converter notas
Lasso Select Seleção com laço
Move Selected Ink Mover a tinta selecionada
Delete Selected Ink Excluir a tinta selecionada
Recolor Selected Ink Mudar a cor da tinta selecionada
Add Pen Adicionar caneta
Remove Pen Remover caneta
Ink to Shape Tinta para forma
Convert to Shapes Converter em formas
Ink Replay Reproduzir tinta
Add\nPen Adicionar\ncaneta
Rewind Retroceder
Forward Avançar
Stroke {n} of {total} Traço {n} de {total}

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1803,6 +1803,19 @@ preview Title Пример
Right-to-Left Section Раздел справа налево
Table direction: Направление таблицы:
Section direction: Направление раздела:
Lasso Select Лассо
Move Selected Ink Переместить выделенные штрихи
Delete Selected Ink Удалить выделенные штрихи
Recolor Selected Ink Перекрасить выделенные штрихи
Add Pen Добавить перо
Remove Pen Удалить перо
Ink to Shape Рукописный ввод в фигуры
Convert to Shapes Преобразовать в фигуры
Ink Replay Воспроизведение рукописного ввода
Add\nPen Добавить\nперо
Rewind Назад
Forward Вперёд
Stroke {n} of {total} Штрих {n} из {total}
Drawing Canvas Полотно для рисования
New Drawing Canvas Новое полотно для рисования
Fit По размеру

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1463,3 +1463,16 @@
Continuous numbering Neprekidna numeracija
Apply Primeni
Convert Notes Pretvori napomene
Lasso Select Izbor lasom
Move Selected Ink Premesti izabrani rukopis
Delete Selected Ink Obriši izabrani rukopis
Recolor Selected Ink Promeni boju izabranog rukopisa
Add Pen Dodaj pero
Remove Pen Ukloni pero
Ink to Shape Rukopis u oblik
Convert to Shapes Pretvori u oblike
Ink Replay Rukopis repriza
Add\nPen Dodaj\npero
Rewind Na početak
Forward Napred
Stroke {n} of {total} Potez {n} od {total}

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -1467,3 +1467,16 @@
Continuous numbering Непрекидна нумерација
Apply Примени
Convert Notes Претвори напомене
Lasso Select Избор ласом
Move Selected Ink Премести изабрани рукопис
Delete Selected Ink Обриши изабрани рукопис
Recolor Selected Ink Промени боју изабраног рукописа
Add Pen Додај перо
Remove Pen Уклони перо
Ink to Shape Рукопис у облик
Convert to Shapes Претвори у облике
Ink Replay Рукопис реприза
Add\nPen Додај\nперо
Rewind На почетак
Forward Напред
Stroke {n} of {total} Потез {n} од {total}

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -1421,6 +1421,19 @@
Right-to-Left Section Sağdan Sola Bölüm
Table direction: Tablo yönü:
Section direction: Bölüm yönü:
Lasso Select Kement Seçimi
Move Selected Ink Seçili Mürekkebi Taşı
Delete Selected Ink Seçili Mürekkebi Sil
Recolor Selected Ink Seçili Mürekkebi Yeniden Renklendir
Add Pen Kalem Ekle
Remove Pen Kalemi Kaldır
Ink to Shape Mürekkepten Şekle
Convert to Shapes Şekillere Dönüştür
Ink Replay Mürekkep Yeniden Oynatma
Add\nPen Kalem\nEkle
Rewind Geri Sar
Forward İleri
Stroke {n} of {total} Vuruş {n} / {total}
Drawing Canvas Çizim Tuvali
New Drawing Canvas Yeni Çizim Tuvali
Fit Sığdır

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -1465,3 +1465,16 @@
Continuous numbering Наскрізна нумерація
Apply Застосувати
Convert Notes Перетворити примітки
Lasso Select Виділення ласо
Move Selected Ink Перемістити виділений рукопис
Delete Selected Ink Видалити виділений рукопис
Recolor Selected Ink Змінити колір виділеного рукопису
Add Pen Додати перо
Remove Pen Вилучити перо
Ink to Shape Рукопис у фігуру
Convert to Shapes Перетворити на фігури
Ink Replay Відтворити рукопис
Add\nPen Додати\nперо
Rewind На початок
Forward Вперед
Stroke {n} of {total} Штрих {n} з {total}

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -1465,3 +1465,16 @@
Continuous numbering 连续编号
Apply 应用
Convert Notes 转换注释
Lasso Select 套索选择
Move Selected Ink 移动所选墨迹
Delete Selected Ink 删除所选墨迹
Recolor Selected Ink 更改所选墨迹颜色
Add Pen 添加笔
Remove Pen 删除笔
Ink to Shape 墨迹转形状
Convert to Shapes 转换为形状
Ink Replay 墨迹重播
Add\nPen 添加笔
Rewind 倒回
Forward 前进
Stroke {n} of {total} 第 {n} 笔,共 {total} 笔

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -1454,3 +1454,16 @@
Continuous numbering 連續編號
Apply 套用
Convert Notes 轉換附註
Lasso Select 套索選取
Move Selected Ink 移動選取的筆跡
Delete Selected Ink 刪除選取的筆跡
Recolor Selected Ink 變更選取筆跡的色彩
Add Pen 新增畫筆
Remove Pen 移除畫筆
Ink to Shape 筆跡轉圖形
Convert to Shapes 轉換為圖案
Ink Replay 筆跡重播
Add\nPen 新增畫筆
Rewind 倒轉
Forward 快轉
Stroke {n} of {total} 第 {n} 筆,共 {total} 筆

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -498,6 +498,27 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
pen.circle(10.0, 10.0, 6.5, c);
pen.fill(&[(8.5, 7.0), (8.5, 13.0), (13.0, 10.0)], a);
}
// Add Pen: a pen with a plus.
"addPen" => {
pen.line(&[(3.0, 17.0), (11.0, 9.0), (13.0, 11.0), (5.0, 19.0), (2.5, 19.5), (3.0, 17.0)]);
pen.fill(&[(11.0, 9.0), (12.5, 7.5), (14.5, 9.5), (13.0, 11.0)], a);
pen.line_c(&[(15.0, 2.0), (15.0, 8.0)], a);
pen.line_c(&[(12.0, 5.0), (18.0, 5.0)], a);
}
// Ink Replay's controls.
"replayRewind" => {
pen.frect(4.0, 5.0, 5.5, 15.0, c);
pen.fill(&[(16.0, 5.0), (16.0, 15.0), (6.5, 10.0)], c);
}
"replayPlay" => pen.fill(&[(6.0, 4.0), (6.0, 16.0), (16.0, 10.0)], a),
"replayPause" => {
pen.frect(5.5, 4.5, 8.5, 15.5, a);
pen.frect(11.5, 4.5, 14.5, 15.5, a);
}
"replayForward" => {
pen.fill(&[(4.0, 5.0), (4.0, 15.0), (13.5, 10.0)], c);
pen.frect(14.5, 5.0, 16.0, 15.0, c);
}
// Design
"themes" => {
pen.frect(3.0, 3.0, 10.0, 10.0, a);

View File

@ -1,9 +1,14 @@
//! 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.
//! Drawing on the page with the Draw tab's pens, eraser and lasso, and Ink Replay. 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). With the lasso, a drag draws a loop whose release
//! selects the ink inside (`draw.lasso`); dragging the selection moves it (`draw.lassoMove`),
//! Delete deletes it and Esc lets it go. Select (or Esc) goes back to ordinary selection.
//!
//! Ink Replay (`draw.replay`) hides the ink on the pages and draws the strokes again here, one
//! after another in the order they were drawn, with a bar of controls at the bottom.
use egui::{Color32, Painter, Pos2, Rect, Stroke, Ui};
use egui::{Align2, Color32, Key, Painter, Pos2, Rect, Stroke, Ui, pos2, vec2};
use serde_json::json;
use wordcraft_doc::freeform::{InkTool, MAX_POINTS};
use wordcraft_engine::cmd::draw::DrawMode;
@ -19,13 +24,55 @@ pub struct InkDrag {
pts: Vec<[f32; 2]>,
}
/// The lasso in use: a loop being drawn, or the selection being dragged (page points).
pub enum LassoDrag {
Loop { page: usize, pts: Vec<[f32; 2]> },
Move { page: usize, from: [f32; 2], to: [f32; 2] },
}
/// Ink Replay in progress: the strokes in drawing order and how far it has got.
pub struct Replay {
strokes: Vec<ReplayStroke>,
/// Seconds into the replay.
t: f32,
playing: bool,
}
struct ReplayStroke {
page: usize,
paths: Vec<Vec<[f32; 2]>>,
color: Color32,
width: f32,
/// When it starts being drawn and how long it takes, seconds.
start: f32,
dur: f32,
}
/// The Draw tab's pointer state beyond the pens (kept on the canvas).
#[derive(Default)]
pub struct InkUi {
pub(crate) lasso: Option<LassoDrag>,
pub(crate) replay: Option<Replay>,
}
/// Smallest move that adds a point to a live stroke, screen points.
const MIN_STEP_PX: f32 = 1.5;
/// How fast replayed ink is drawn, page points per second, and the pause between strokes.
const REPLAY_SPEED: f32 = 400.0;
const REPLAY_GAP: f32 = 0.12;
/// How close to the lasso selection's frame a press still grabs it, page points.
const GRAB_SLOP: f32 = 4.0;
/// Handle the pointer while a pen or the eraser is in use; `true` when it did (no text
/// selection or object dragging then).
/// Handle the pointer while a pen, the eraser or the lasso 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::Lasso {
app.canvas.ink_ui.lasso = None;
if app.session.view.draw.lasso.is_some() {
let _ = app.run("draw.lasso", json!({"clear": true}));
}
}
if mode == DrawMode::Select {
app.canvas.ink = None;
app.canvas.erasing = None;
@ -73,27 +120,112 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect]
app.canvas.erasing = None;
}
}
DrawMode::Lasso => lasso_pointer(app, (starts, down, released, at), rects, layout, scale),
DrawMode::Select => {}
}
true
}
fn lasso_pointer(app: &mut WordApp, (starts, down, released, at): (bool, bool, bool, Option<Pos2>), rects: &[Rect], layout: &DocLayout, scale: f32) {
if starts && let Some((page, x, y)) = at.and_then(|p| page_at(rects, layout, scale, p)) {
// A press on the selection drags it; anywhere else starts a new loop.
let grab = wordcraft_engine::cmd::draw::lasso_bounds(&app.session, layout).is_some_and(|(bp, r)| {
bp == page && x >= r.x - GRAB_SLOP && x <= r.right() + GRAB_SLOP && y >= r.y - GRAB_SLOP && y <= r.bottom() + GRAB_SLOP
});
app.canvas.ink_ui.lasso =
Some(if grab { LassoDrag::Move { page, from: [x, y], to: [x, y] } } else { LassoDrag::Loop { page, pts: vec![[x, y]] } });
} else if down
&& let Some(p) = at
&& let Some(d) = app.canvas.ink_ui.lasso.as_mut()
{
match d {
LassoDrag::Loop { page, pts } => {
if let Some((x, y)) = on_page(rects, layout, scale, *page, p) {
let far = pts.last().is_none_or(|[lx, ly]| ((x - lx).powi(2) + (y - ly).powi(2)).sqrt() * scale >= MIN_STEP_PX * 2.0);
if far && pts.len() < MAX_POINTS {
pts.push([x, y]);
}
}
}
LassoDrag::Move { page, to, .. } => {
if let Some((x, y)) = on_page(rects, layout, scale, *page, p) {
*to = [x, y];
}
}
}
}
if released && let Some(d) = app.canvas.ink_ui.lasso.take() {
match d {
LassoDrag::Loop { page, pts } if pts.len() >= 3 => {
let _ = app.run("draw.lasso", json!({"page": page, "points": pts}));
}
// A click without a loop lets the selection go.
LassoDrag::Loop { .. } => {
let _ = app.run("draw.lasso", json!({"clear": true}));
}
LassoDrag::Move { from, to, .. } => {
let (dx, dy) = (to[0] - from[0], to[1] - from[1]);
if dx.abs() + dy.abs() > 0.25 {
let _ = app.run("draw.lassoMove", json!({"dx": dx, "dy": dy}));
}
}
}
}
}
/// Keys the lasso and Ink Replay take: Delete deletes the lasso selection, Esc lets it go or
/// stops the replay. `true` when the key was used.
pub fn key(app: &mut WordApp, key: Key) -> bool {
if key == Key::Escape && app.session.view.draw.replay {
stop_replay(app);
return true;
}
let selected = wordcraft_engine::cmd::draw::lasso_selection(&app.session).is_some();
match key {
Key::Delete | Key::Backspace if selected => {
let _ = app.run("draw.lassoDelete", json!({}));
true
}
Key::Escape if selected => {
let _ = app.run("draw.lasso", json!({"clear": true}));
true
}
_ => false,
}
}
/// 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))
let s = page_scale(rects, layout, scale, page)?;
Some(((p.x - r.min.x) / s, (p.y - r.min.y) / s))
}
/// The live stroke, drawn over the page until it is committed.
/// Screen points per page point on `page`.
fn page_scale(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize) -> Option<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_some(s)
}
/// Page points to screen points on `page`.
fn to_screen(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize) -> Option<impl Fn([f32; 2]) -> Pos2> {
let r = *rects.get(page)?;
let s = page_scale(rects, layout, scale, page)?;
Some(move |[x, y]: [f32; 2]| pos2(r.min.x + x * s, r.min.y + y * s))
}
/// The live stroke, the lasso and its selection, and the replayed ink, drawn over the pages.
pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) {
paint_replay(app, painter, rects, layout, scale);
paint_lasso(app, painter, rects, layout, scale);
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 Some(map) = to_screen(rects, layout, scale, d.page) else { return };
let s = page_scale(rects, layout, scale, d.page).unwrap_or(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<Pos2> = d.pts.iter().map(|[x, y]| egui::pos2(r.min.x + x * s, r.min.y + y * s)).collect();
let pts: Vec<Pos2> = d.pts.iter().map(|p| map(*p)).collect();
let width = (set.width * s).max(1.0);
if let [only] = pts.as_slice() {
painter.circle_filled(*only, width / 2.0, color);
@ -102,10 +234,197 @@ pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayou
}
}
/// The pointer over the page while a pen or the eraser is in use.
fn paint_lasso(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) {
let t = crate::theme::Tokens::get(painter.ctx());
let dash = |pts: &[Pos2]| egui::Shape::dashed_line(pts, Stroke::new(1.0, t.accent), 4.0, 3.0);
if let Some(LassoDrag::Loop { page, pts }) = &app.canvas.ink_ui.lasso
&& let Some(map) = to_screen(rects, layout, scale, *page)
{
let mut loop_pts: Vec<Pos2> = pts.iter().map(|p| map(*p)).collect();
if let Some(first) = loop_pts.first().copied() {
loop_pts.push(first);
}
painter.extend(dash(&loop_pts));
}
let Some((page, r)) = wordcraft_engine::cmd::draw::lasso_bounds(&app.session, layout) else { return };
let (dx, dy) = match &app.canvas.ink_ui.lasso {
Some(LassoDrag::Move { from, to, .. }) => (to[0] - from[0], to[1] - from[1]),
_ => (0.0, 0.0),
};
let Some(map) = to_screen(rects, layout, scale, page) else { return };
let (a, b) = (map([r.x + dx, r.y + dy]), map([r.right() + dx, r.bottom() + dy]));
let sr = Rect::from_min_max(a, b).expand(3.0);
painter.extend(dash(&[sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()]));
}
/// The pointer over the page while a pen, the eraser or the lasso is in use.
pub fn cursor(app: &WordApp) -> Option<egui::CursorIcon> {
match app.session.view.draw.mode {
DrawMode::Select => None,
DrawMode::Eraser | DrawMode::Pen(_) => Some(egui::CursorIcon::Crosshair),
DrawMode::Lasso if matches!(app.canvas.ink_ui.lasso, Some(LassoDrag::Move { .. })) => Some(egui::CursorIcon::Grabbing),
DrawMode::Eraser | DrawMode::Pen(_) | DrawMode::Lasso => Some(egui::CursorIcon::Crosshair),
}
}
// --- Ink Replay ----------------------------------------------------------------------------
/// Start, advance or end Ink Replay with the session's `draw.replay` switch; call once a frame
/// before the pages are drawn.
pub fn replay_tick(app: &mut WordApp, ctx: &egui::Context) {
if !app.session.view.draw.replay {
app.canvas.ink_ui.replay = None;
return;
}
if app.canvas.ink_ui.replay.is_none() {
let mut start = 0.0;
let strokes: Vec<ReplayStroke> = wordcraft_engine::cmd::draw::strokes_in_drawing_order(&mut app.session)
.into_iter()
.map(|(page, k)| {
let paths = k.paths();
let len: f32 = paths.iter().map(|p| path_len(p)).sum();
let dur = (len / REPLAY_SPEED).clamp(0.15, 2.5);
let alpha = (k.freeform.alpha.clamp(0.0, 1.0) * 255.0).round() as u8;
let c = k.color.unwrap_or_default();
let width = if k.width.is_finite() { k.width.clamp(0.25, 72.0) } else { 1.0 };
let st = ReplayStroke { page, paths, color: Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha), width, start, dur };
start += dur + REPLAY_GAP;
st
})
.collect();
if strokes.is_empty() {
stop_replay(app);
return;
}
app.canvas.ink_ui.replay = Some(Replay { strokes, t: 0.0, playing: true });
}
let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1);
if let Some(r) = app.canvas.ink_ui.replay.as_mut()
&& r.playing
{
r.t = (r.t + dt).min(r.end());
if r.t >= r.end() {
r.playing = false;
}
ctx.request_repaint();
}
}
impl Replay {
fn end(&self) -> f32 {
self.strokes.last().map_or(0.0, |s| s.start + s.dur)
}
/// Strokes started so far.
fn started(&self) -> usize {
self.strokes.iter().filter(|s| s.start <= self.t).count()
}
}
fn path_len(p: &[[f32; 2]]) -> f32 {
p.windows(2).filter_map(|w| Some((w.first()?, w.get(1)?))).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum()
}
/// The replayed ink so far: finished strokes whole, the current one up to where it has got.
fn paint_replay(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) {
let Some(r) = &app.canvas.ink_ui.replay else { return };
for k in r.strokes.iter().filter(|k| k.start <= r.t) {
let Some(map) = to_screen(rects, layout, scale, k.page) else { continue };
let s = page_scale(rects, layout, scale, k.page).unwrap_or(scale);
let frac = if k.dur > 0.0 { ((r.t - k.start) / k.dur).clamp(0.0, 1.0) } else { 1.0 };
let total: f32 = k.paths.iter().map(|p| path_len(p)).sum();
let mut left = total * frac;
let width = (k.width * s).max(1.0);
for path in &k.paths {
let mut pts: Vec<Pos2> = Vec::new();
for w in path.windows(2) {
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue };
if pts.is_empty() {
pts.push(map(*a));
}
let seg = (b[0] - a[0]).hypot(b[1] - a[1]);
if seg <= left {
pts.push(map(*b));
left -= seg;
} else {
let f = if seg > 0.0 { left / seg } else { 0.0 };
pts.push(map([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]));
left = 0.0;
break;
}
}
match (pts.as_slice(), path.as_slice()) {
(_, [only]) => {
painter.circle_filled(map(*only), width / 2.0, k.color);
}
([], _) => {}
_ => {
painter.add(egui::Shape::line(pts, Stroke::new(width, k.color)));
}
}
if left <= 0.0 {
break;
}
}
}
}
fn stop_replay(app: &mut WordApp) {
app.canvas.ink_ui.replay = None;
let _ = app.run("draw.replay", json!({"value": false}));
}
/// Ink Replay's controls at the bottom of the document area: Rewind, Play/Pause, Forward (to
/// the end of the stroke being drawn), how far it has got, and Close.
pub fn replay_bar(app: &mut WordApp, ui: &Ui, area: Rect) {
let Some(r) = app.canvas.ink_ui.replay.as_ref() else { return };
let (playing, n, total) = (r.playing, r.started(), r.strokes.len());
let mut action = None;
egui::Area::new(egui::Id::new("ink_replay_bar"))
.order(egui::Order::Foreground)
.pivot(Align2::CENTER_BOTTOM)
.fixed_pos(pos2(area.center().x, area.max.y - 18.0))
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(6.0, 0.0);
if crate::widgets::icon_button(ui, "replayRewind", tl!("Rewind")).clicked() {
action = Some("rewind");
}
let (icon, tip) = if playing { ("replayPause", tl!("Pause")) } else { ("replayPlay", tl!("Play")) };
if crate::widgets::icon_button(ui, icon, tip).clicked() {
action = Some("play");
}
if crate::widgets::icon_button(ui, "replayForward", tl!("Forward")).clicked() {
action = Some("forward");
}
let label = crate::i18n::fmt(tl!("Stroke {n} of {total}"), &[("n", &n.to_string()), ("total", &total.to_string())]);
ui.label(label);
if crate::widgets::icon_button(ui, "close", tl!("Close")).clicked() {
action = Some("close");
}
});
});
});
let Some(a) = action else { return };
if a == "close" {
stop_replay(app);
return;
}
let Some(r) = app.canvas.ink_ui.replay.as_mut() else { return };
match a {
"rewind" => r.t = 0.0,
"play" if r.playing => r.playing = false,
"play" => {
// Play again from the start once it has finished.
if r.t >= r.end() {
r.t = 0.0;
}
r.playing = true;
}
_ => {
// Forward: finish the stroke being drawn, or the next one between strokes.
let t = r.t;
r.t = r.strokes.iter().map(|s| s.start + s.dur).find(|e| *e > t + 1e-3).unwrap_or(r.end());
}
}
ui.ctx().request_repaint();
}

View File

@ -258,6 +258,10 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
app.session.painter = None;
continue;
}
// The lasso selection and Ink Replay take Delete and Escape first.
if crate::ink::key(app, key) {
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;

View File

@ -207,6 +207,7 @@ fn controls(tab: &str) -> Vec<Control> {
c("Pen", "draw.pen"),
c("Pencil", "draw.pencil"),
c("Highlighter", "draw.highlighter"),
m("Add Pen", "draw.addPen"),
c("Ink to Shape", "draw.inkToShape"),
c("Ink to Math", "draw.inkToMath"),
c("Drawing Canvas", "insert.canvas"),

View File

@ -135,6 +135,10 @@ pub struct UiState {
/// Custom ribbon tabs and groups, hidden tabs and the Quick Access Toolbar (Customize Ribbon,
/// #379); the session owns them while running.
pub ribbon: wordcraft_engine::RibbonLayout,
/// Draw › Add Pen: the pens added to the pen gallery; the session owns them while running.
/// Entries that aren't pens are dropped on reading.
#[serde(deserialize_with = "wordcraft_engine::cmd::draw::lenient_pens")]
pub ink_pens: Vec<wordcraft_engine::cmd::draw::CustomPen>,
}
impl Default for UiState {
@ -162,6 +166,7 @@ impl Default for UiState {
dark_page: false,
keyboard: wordcraft_engine::KeyMap::default(),
ribbon: wordcraft_engine::RibbonLayout::default(),
ink_pens: Vec::new(),
}
}
}
@ -305,6 +310,7 @@ impl WordApp {
ui.dark_page = self.session.view.dark_mode;
ui.keyboard = self.session.keymap.clone();
ui.ribbon = self.session.ribbon.clone();
ui.ink_pens = self.session.view.draw.pens.clone();
ui
}
@ -332,6 +338,7 @@ impl WordApp {
// So are ribbon and toolbar commands.
self.session.ribbon = std::mem::take(&mut self.ui.ribbon);
self.session.ribbon.retain_known(&reg);
self.session.view.draw.pens = std::mem::take(&mut self.ui.ink_pens);
}
/// Run a command the user asked for (ribbon, shortcut, Backstage, file drop). New, Open,

View File

@ -717,29 +717,46 @@ 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;
use wordcraft_engine::cmd::draw::{DrawMode, lasso_selection};
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",
};
let set = app.session.view.draw.settings(tool);
// The gallery pen in use, if any: changing the colour or thickness changes it too.
let custom =
app.session.view.draw.pens.iter().position(|p| mode == DrawMode::Pen(p.kind) && p.color == set.color && (p.width - set.width).abs() < 0.01);
let lasso_on = lasso_selection(&app.session).is_some();
group(ui, "Drawing Tools", None, app, |ui, app| {
big_toggle(ui, app, "select", "Select", "draw.select", mode == DrawMode::Select);
big(ui, app, "lasso", "Lasso", "draw.lasso", json!({}), false);
big_toggle(ui, app, "lasso", "Lasso", "draw.lasso", mode == DrawMode::Lasso);
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);
big_toggle(ui, app, "pen", "Pen", "draw.pen", mode == DrawMode::Pen(InkTool::Pen) && custom.is_none());
big_toggle(ui, app, "pencil", "Pencil", "draw.pencil", mode == DrawMode::Pen(InkTool::Pencil) && custom.is_none());
big_toggle(ui, app, "highlight", "Highlighter", "draw.highlighter", mode == DrawMode::Pen(InkTool::Highlighter) && custom.is_none());
// Pens added with Add Pen; right-click one to remove it.
let pens = app.session.view.draw.pens.clone();
for (i, pen) in pens.iter().enumerate() {
gallery_pen(ui, app, i, *pen, custom == Some(i));
}
menu_button(ui, app, "addPen", Some("Add\nPen"), "Add Pen", true, |ui, app| {
for (label, kind) in [("Pen", InkTool::Pen), ("Pencil", InkTool::Pencil), ("Highlighter", InkTool::Highlighter)] {
// A new pen starts as that kind's current colour and thickness.
let cur = app.session.view.draw.settings(kind);
mi(ui, app, label, "draw.addPen", json!({"kind": kind.name(), "color": cur.color.hex(), "width": cur.width}));
}
});
stack(ui, |ui| {
let sw = Some(crate::theme::c32(set.color));
split(ui, app, "fontcolor", "Color", pen_id(tool), json!({}), false, sw, |ui, app| {
split(ui, app, "fontcolor", "Color", pen_command(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}));
// With ink lasso-selected the colour goes to the selection, else to the pen.
if lasso_on {
let _ = app.run("draw.lassoColor", json!({"color": hex}));
} else {
let _ = app.run(pen_command(tool), json!({"color": hex}));
update_gallery_pen(app, custom, tool);
}
ui.close();
}
});
@ -747,20 +764,80 @@ fn draw(app: &mut WordApp, ui: &mut Ui) {
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}));
mi_check(ui, app, &label, (set.width - w).abs() < 0.01, pen_command(tool), json!({"width": w}));
}
update_gallery_pen(app, custom, tool);
});
});
});
group(ui, "Convert", None, app, |ui, app| {
big(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", json!({}), false);
let on = app.session.view.draw.ink_to_shape;
big_toggle(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", on);
big(ui, app, "inkToMath", "Ink to\nMath", "draw.inkToMath", json!({}), false);
});
group(ui, "Insert", None, app, |ui, app| {
big(ui, app, "canvas", "Drawing\nCanvas", "insert.canvas", json!({}), false);
});
group(ui, "Replay", None, app, |ui, app| {
big(ui, app, "replay", "Ink\nReplay", "draw.replay", json!({}), false);
let on = app.session.view.draw.replay;
big_toggle(ui, app, "replay", "Ink\nReplay", "draw.replay", on);
});
}
/// The command that picks up `tool`.
fn pen_command(tool: wordcraft_doc::freeform::InkTool) -> &'static str {
use wordcraft_doc::freeform::InkTool;
match tool {
InkTool::Pen => "draw.pen",
InkTool::Pencil => "draw.pencil",
InkTool::Highlighter => "draw.highlighter",
}
}
/// After the colour or thickness menu changed `tool`'s settings, the gallery pen that was in use
/// (`custom`) takes them too.
fn update_gallery_pen(app: &mut WordApp, custom: Option<usize>, tool: wordcraft_doc::freeform::InkTool) {
let set = app.session.view.draw.settings(tool);
if let Some(p) = custom.and_then(|i| app.session.view.draw.pens.get_mut(i))
&& p.kind == tool
{
p.color = set.color;
p.width = set.width;
}
}
/// A pen of the gallery: its kind's icon over a bar of its colour and thickness; a click picks it
/// up, right-click offers Remove Pen.
fn gallery_pen(ui: &mut Ui, app: &mut WordApp, i: usize, pen: wordcraft_engine::cmd::draw::CustomPen, active: bool) {
use wordcraft_doc::freeform::InkTool;
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(34.0, CONTENT_H), Sense::click());
crate::widgets::bg(ui, r, &resp, active, &t);
let icon = match pen.kind {
InkTool::Pen => "pen",
InkTool::Pencil => "pencil",
InkTool::Highlighter => "highlight",
};
let color = crate::theme::c32(pen.color);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(28.0, 28.0)), icon, t.icon, color);
let bar = (pen.width * 0.6).clamp(1.5, 8.0);
let y = r.min.y + 46.0;
ui.painter().line_segment([pos2(r.min.x + 6.0, y), pos2(r.max.x - 6.0, y)], Stroke::new(bar, color));
let kind = tl!(match pen.kind {
InkTool::Pen => "Pen",
InkTool::Pencil => "Pencil",
InkTool::Highlighter => "Highlighter",
});
let size = crate::i18n::fmt(tl!("{n} pt"), &[("n", &pen.width.to_string())]);
let resp = resp.on_hover_text(format!("{kind}: {size}, #{}", pen.color.hex()));
if resp.clicked() {
let _ = app.run(pen_command(pen.kind), json!({"color": pen.color.hex(), "width": pen.width}));
}
resp.context_menu(|ui| {
if ui.button(tl!("Remove Pen")).clicked() {
let _ = app.run("draw.removePen", json!({"index": i}));
ui.close();
}
});
}

View File

@ -89,7 +89,7 @@ pub fn enabled(app: &WordApp, id: &str) -> bool {
}
}
fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) {
pub(crate) fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) {
let fill = if resp.is_pointer_button_down_on() {
t.pressed
} else if checked {

View File

@ -22,7 +22,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), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
| 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) |
| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); missing: Lasso Select, Add Pen, Ink to Shape, Ink to Math, Ink Replay, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 5/11) | Pen and tablet users | 8–14 | [hardware-parity.md](hardware-parity.md) |
| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); missing: Ink to Math, arrows in Ink to Shape, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 9/11) | Pen and tablet users | 4–8 | [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**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) |
@ -79,6 +79,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
| 2026-10-11 | trivial | All 12 bibliography styles landed (#383); removed from References depth |
| 2026-10-11 | trivial | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h |
| 2026-10-11 | trivial | #12 narrowed: Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395) |
| 2026-10-10 | trivial | Draw Table and Eraser landed (#303); removed from the feature-gap list |
| 2026-10-10 | trivial | Tabs and Borders and Shading dialogs landed (#320); removed from the dialog-depth list |
| 2026-10-10 | trivial | Columns, Symbol and Field dialogs landed (#321); removed from the dialog-depth list |

View File

@ -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.
**429 of 451 catalog features have commands (95.1%).**
**433 of 451 catalog features have commands (96.0%).**
| Tab | Live | Total |
|---|---|---|
@ -10,7 +10,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
| Quick Access | 4 | 4 |
| Home | 101 | 102 |
| Insert | 45 | 53 |
| Draw | 6 | 11 |
| Draw | 10 | 11 |
| Design | 10 | 10 |
| Layout | 21 | 21 |
| References | 24 | 24 |
@ -45,11 +45,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
### Draw
- Tools › Lasso Select (`draw.lasso`)
- Pens › Add Pen (`draw.addPen`)
- Convert › Ink to Shape (`draw.inkToShape`)
- Convert › Ink to Math (`draw.inkToMath`)
- Replay › Ink Replay (`draw.replay`)
### Review

View File

@ -1,6 +1,6 @@
# WordCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Draw: Lasso Select, Add Pen, Ink to Shape and Ink Replay landed, #395; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is
[`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are
@ -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), ink pens and eraser (#307), contour wrap (#354) done; lasso and ink conversion, grouping missing | 35–55 h |
| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332), ink pens and eraser (#307), contour wrap (#354), lasso, Add Pen, Ink to Shape and Ink Replay (#395) done; Ink to Math, grouping 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) | — |
@ -81,3 +81,4 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
| 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) |
| 2026-10-11 | trivial | M11: lasso, Add Pen, Ink to Shape and Ink Replay landed (#395) |

View File

@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **429 / 451 (95.1%)** | **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 | **433 / 451 (96.0%)** | **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 (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. |
| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), text-box text direction (90°, 270°, stacked), Align Text and linked text boxes (#369), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, text in a freely rotated text box, group (#267 open), shape effects, WordArt, connectors, online pictures, screenshot. |
| Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. |
| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; lasso, ink to shape/math and replay missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). |
| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; Lasso Select (move, recolour, delete), Add Pen gallery, Ink to Shape (line, rectangle, ellipse, triangle, polygon) and Ink Replay (#395); Ink to Math, pressure and Word's own InkML 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. |
@ -263,6 +263,7 @@ can be used without committing Word output.
| 2026-10-11 | trivial | References: all 12 of Word's bibliography styles (eight more: Harvard, ISO 690 ×2, Turabian, GB/T 7714, GOST ×2, SIST02) (#383); no percentage change |
| 2026-10-11 | trivial | Mail merge fixes (#427–#430): SKIPIF drops matching records, NEXT advances later fields, IF previews, header/footer fields merge; Mailings row updated, no percentage change |
| 2026-10-11 | trivial | Formatting revisions (#41): Review row updated; no percentage change |
| 2026-10-11 | trivial | #395: Lasso Select, Add Pen, Ink to Shape and Ink Replay; catalog 423/448 → 427/448 (95.3%) |
| 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 |

View File

@ -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). 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) |
| 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); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape, Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math | 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 | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 |
@ -58,3 +58,4 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| 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) |
| 2026-10-11 | trivial | Draw: Lasso, Add Pen gallery, Ink to Shape and Ink Replay (#395) |