Ink to Shape: two-stroke arrows and closed-triangle heads (gap 12) (#1778)

* ink-arrows-2: WIP

* ink-arrows-2: Ink to Shape makes two-stroke arrows and closed-triangle heads

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

* ink-arrows-2: review fixes — recognise each stroke once, drawing-order pairing, arrow rect

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-18 04:24:15 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov
parent 211d6c0c06
commit 1f0aeb4626
5 changed files with 624 additions and 52 deletions
+2
View File
@@ -172,6 +172,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word |
| 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) |
| 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 |
| 2026-10-15 | Draw: Ink to Shape makes two-stroke arrows and arrows with a closed triangle head. A line plus a separate head stroke (an open V or a small closed triangle, drawn after the line or before it) whose apex sits at one of the line's ends becomes one straight arrow connector, and the head stroke goes, in one undo step; this works while drawing with Ink to Shape on and on lasso-selected strokes with Convert to Shapes. A shaft with a small triangle drawn round its tip in the same stroke is an arrow too; a triangle without a shaft stays a triangle. Our own design (Word for Mac has no Ink to Shape). |
| 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box |
| 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon |
| 2026-10-13 | The status bar flags a proofing language whose Hunspell dictionary isn't installed (Polish, Portuguese): "Polish (no dictionary)", with a tooltip naming the dictionary and where it is looked up (#1167) |
@@ -464,6 +465,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table |
| 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) |
| 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged |
| 2026-10-15 | trivial | Progress log: Ink to Shape two-stroke and closed-head arrows |
| 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) |
| 2026-10-14 | trivial | Progress log: file names that aren't valid Unicode (#1440) |
| 2026-10-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too |
+300 -13
View File
@@ -22,7 +22,7 @@ use wordcraft_doc::{CharProps, Pos, StoryRef};
use wordcraft_geom::Rect;
use wordcraft_layout::{DocLayout, Placed};
use super::ink_shape::{InkShape, recognise};
use super::ink_shape::{ArrowParts, InkShape, arrow_parts, pair_parts, recognise};
use super::pos_json;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
@@ -461,9 +461,26 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult {
extra: Default::default(),
};
let at = place(s, &layout, obj, page, (fx, fy), (first[0], first[1]))?;
// 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}))
// Ink to Shape: a stroke that looks like a shape becomes it, and a line and a head drawn as two
// strokes become one arrow (in the same undo step).
let (at, shape, rect) = if s.view.draw.ink_to_shape && tool != InkTool::Highlighter {
match pair_with_neighbour(s, page, &at)? {
Some(arrow) => {
// The arrow is the line drawn earlier (or just now): its frame, not the stroke's.
s.touch();
let r = s.layout().object(&arrow, page).map(|o| o.rect);
let rect = r.map_or([fx, fy, fw, fh], |r| [r.x, r.y, r.w, r.h]);
(arrow, Some("arrow"), rect)
}
None => {
let shape = convert_at(s, &at)?;
(at, shape, [fx, fy, fw, fh])
}
}
} else {
(at, None, [fx, fy, fw, fh])
};
Ok(json!({"pos": pos_json(&at), "page": page, "rect": rect, "shape": shape}))
}
/// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph
@@ -676,28 +693,141 @@ fn lasso_color(s: &mut Session, v: &Value) -> CmdResult {
// --- Ink to Shape --------------------------------------------------------------------------
/// Turn the lasso-selected strokes that look like shapes into those shapes (one undo step).
/// Turn the lasso-selected strokes that look like shapes into those shapes, and a selected line
/// and arrowhead drawn as two strokes into one arrow (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 {
let layout = s.layout();
let pieces = arrow_pieces(s, &layout, sel.page, |pos, _| sel.items.contains(pos));
let (mut used, mut heads, mut converted): (Vec<Pos>, Vec<Pos>, Vec<(Pos, &'static str)>) = (Vec::new(), Vec::new(), Vec::new());
// Each piece was recognised once; pairing them is cheap geometry, even for a crowded page.
for line in pieces.iter().filter(|k| k.parts.line.is_some()) {
for head in pieces.iter().filter(|k| !k.parts.heads.is_empty()) {
if line.pos == head.pos || used.contains(&line.pos) || used.contains(&head.pos) || !near(line.rect, head.rect) {
continue;
}
if let Some(arrow) = pair_parts(&line.parts, &head.parts) {
set_shape(s, &line.pos, &local_to(&arrow, line.rect))?;
used.extend([line.pos.clone(), head.pos.clone()]);
heads.push(head.pos.clone());
converted.push((line.pos.clone(), "arrow"));
}
}
}
for pos in sel.items.iter().filter(|p| !used.contains(p)) {
if let Some(name) = convert_at(s, pos)? {
converted.push(json!({"pos": pos_json(pos), "shape": name}));
converted.push((pos.clone(), name));
}
}
if converted.is_empty() {
return Err(CmdError::Failed("none of the selected strokes looks like a shape".into()));
}
keep_lasso(s, sel);
// The heads went into their arrows; last first, so the earlier ones keep their place.
heads.sort();
for pos in heads.iter().rev() {
delete_object(s, pos)?;
}
let items = sel.items.iter().filter(|p| !heads.contains(p)).map(|p| after_deleting(p, &heads)).collect();
let converted: Vec<Value> = converted.iter().map(|(p, name)| json!({"pos": pos_json(&after_deleting(p, &heads)), "shape": name})).collect();
keep_lasso(s, LassoSelection { items, ..sel });
Ok(json!({"count": converted.len(), "converted": converted}))
}
/// Where `pos` is once the objects at `deleted` (none of them `pos`) are gone.
fn after_deleting(pos: &Pos, deleted: &[Pos]) -> Pos {
let before = deleted.iter().filter(|d| d.story == pos.story && d.path == pos.path && d.off < pos.off).count();
Pos { off: pos.off.saturating_sub(before.saturating_mul(OBJ.len_utf8())), ..pos.clone() }
}
/// A stroke (or an Ink to Shape line or triangle) on a page that may be one half of a two-stroke
/// arrow: where it is, when it was drawn (ink's drawing order) and what it can be in an arrow.
struct ArrowPiece {
pos: Pos,
rect: Rect,
order: Option<u32>,
parts: ArrowParts,
}
/// Whether two objects laid out at `a` and `b` are close enough to make an arrow (a cheap test
/// before recognising them).
fn near(a: Rect, b: Rect) -> bool {
let reach = 0.2 * a.w.max(a.h).max(b.w).max(b.h);
reach.is_finite() && a.expand(reach).intersects(&b.expand(reach))
}
/// The pieces of two-stroke arrows on `page` that `keep` (given the position and laid-out rect)
/// accepts: unturned, unflipped pen and pencil strokes, and the open two-point lines and closed
/// triangles Ink to Shape makes. `keep` runs first, so only kept pieces are recognised.
fn arrow_pieces(s: &Session, layout: &DocLayout, page: usize, keep: impl Fn(&Pos, &Rect) -> bool) -> Vec<ArrowPiece> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
let mut out = 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 };
if !spin.is_identity() || !keep(&pos, rect) {
continue;
}
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
let Some(fp) = f.paths.first().filter(|_| f.paths.len() == 1) else { continue };
let pen = matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && !s.view.hide_ink;
let shape = f.ink.is_none() && *kind == ShapeKind::Freeform && matches!((fp.pts.len(), fp.closed), (2, false) | (3, true));
if !(pen || shape) || float.rot != 0.0 || float.flip_h || float.flip_v {
continue;
}
let Some((mut pts, closed)) = f.placed(rect.x, rect.y, rect.w, rect.h).into_iter().next() else { continue };
if closed && let Some(&first) = pts.first() {
pts.push(first);
}
out.push(ArrowPiece { pos, rect: *rect, order: f.order, parts: arrow_parts(&pts) });
}
out
}
/// Ink to Shape while drawing: when the stroke just drawn at `new` and another on the page are a
/// line and an arrowhead (either drawn first), make the line an arrow and drop the head. Returns
/// where the arrow is.
fn pair_with_neighbour(s: &mut Session, page: usize, new: &Pos) -> Result<Option<Pos>, CmdError> {
s.touch(); // lay out the new stroke where it was just placed
let layout = s.layout();
let Some(mine) = layout.object(new, page).map(|o| o.rect) else { return Ok(None) };
// Only the new stroke and its neighbours are recognised, not the whole page.
let mut pieces = arrow_pieces(s, &layout, page, |pos, rect| pos == new || near(mine, *rect));
let Some(at) = pieces.iter().position(|k| k.pos == *new) else { return Ok(None) };
let me = pieces.swap_remove(at);
// The most recently drawn first (ink records its drawing order; shapes and ink from files
// don't, and come after, latest on the page first), so the winner doesn't depend on layout.
pieces.sort_by(|a, b| b.order.cmp(&a.order).then_with(|| b.pos.cmp(&a.pos)));
for other in &pieces {
for (line, head) in [(other, &me), (&me, other)] {
if let Some(arrow) = pair_parts(&line.parts, &head.parts) {
set_shape(s, &line.pos, &local_to(&arrow, line.rect))?;
delete_object(s, &head.pos)?;
return Ok(Some(after_deleting(&line.pos, std::slice::from_ref(&head.pos))));
}
}
}
Ok(None)
}
/// `shape` (in page points) in the frame of the object laid out at `rect`.
fn local_to(shape: &InkShape, rect: Rect) -> InkShape {
let m = |[x, y]: [f32; 2]| [x - rect.x, y - rect.y];
match shape {
InkShape::Arrow { a, b, head_at_end, head_at_start } => {
InkShape::Arrow { a: m(*a), b: m(*b), head_at_end: *head_at_end, head_at_start: *head_at_start }
}
InkShape::Line { a, b } => InkShape::Line { a: m(*a), b: m(*b) },
InkShape::Rectangle { x, y, w, h } => InkShape::Rectangle { x: x - rect.x, y: y - rect.y, w: *w, h: *h },
InkShape::Ellipse { x, y, w, h } => InkShape::Ellipse { x: x - rect.x, y: y - rect.y, w: *w, h: *h },
InkShape::Polygon(c) => InkShape::Polygon(c.iter().copied().map(m).collect()),
}
}
/// 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, extra, .. }) = para.object_at_mut(pos.off) else { return Ok(None) };
let Some(InlineObject::Shape { w, h, float, freeform, .. }) = s.doc.para_at(pos).and_then(|p| p.object_at(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);
};
@@ -707,13 +837,24 @@ fn convert_at(s: &mut Session, pos: &Pos) -> Result<Option<&'static str>, CmdErr
// 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) };
set_shape(s, pos, &found)?;
Ok(Some(found.name()))
}
/// Make the drawing at `pos` the shape `found` (given in the drawing's own frame), keeping its
/// colour and thickness, where it is.
fn set_shape(s: &mut Session, pos: &Pos, found: &InkShape) -> Result<(), CmdError> {
let para = s.doc.para_mut(pos.story, &pos.path)?;
let Some(InlineObject::Shape { kind, w, h, fill, float, freeform, extra, .. }) = para.object_at_mut(pos.off) else {
return Err(CmdError::Failed("no drawing there".into()));
};
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 {
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)),
@@ -735,7 +876,7 @@ fn convert_at(s: &mut Session, pos: &Pos) -> Result<Option<&'static str>, CmdErr
float.x += bx;
float.y += by;
para.touch();
Ok(Some(found.name()))
Ok(())
}
// --- Ink Replay ----------------------------------------------------------------------------
@@ -1017,6 +1158,152 @@ mod tests {
assert!(extra.arrow_end && !extra.arrow_start && float.flip_h, "{extra:?} {float:?}");
}
/// Every drawing in the body, in document order.
fn drawings(s: &Session) -> Vec<InlineObject> {
s.doc
.body
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.filter(|o| matches!(o, InlineObject::Shape { .. }))
.cloned()
.collect()
}
/// A straight stroke from (100, 300) to (300, 300).
fn shaft_stroke() -> Vec<[f32; 2]> {
(0..=40).map(|i| [100.0 + i as f32 * 5.0, 300.0]).collect()
}
#[test]
fn ink_to_shape_turns_a_line_and_a_head_drawn_after_it_into_one_arrow_in_one_undo_step() {
let mut s = session();
s.run("draw.inkToShape", &json!({"value": true})).unwrap();
let r = s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke(), "color": "C00000", "width": 2})).unwrap();
assert_eq!(r["shape"], "line");
let steps = s.undo_depth();
// An open V at the right-hand end, drawn as a second stroke.
let head = [[274.0, 285.0], [287.0, 292.5], [300.0, 300.0], [287.0, 307.5], [274.0, 315.0]];
let r = s.run("draw.stroke", &json!({"page": 0, "points": head})).unwrap();
assert_eq!(r["shape"], "arrow");
assert_eq!(s.undo_depth(), steps + 1, "paired in one undo step");
assert!(ink(&s).is_empty(), "the head stroke is gone");
let all = drawings(&s);
let [InlineObject::Shape { kind, w, stroke, stroke_width, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") };
assert_eq!((*kind, *stroke, *stroke_width), (ShapeKind::StraightConnector, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0));
assert!(extra.arrow_end && !extra.arrow_start && !float.flip_h && !float.flip_v, "{extra:?} {float:?}");
assert!((w - 200.0).abs() < 1.0, "{w}");
let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap();
assert!(matches!(s.doc.para_at(&at).and_then(|p| p.object_at(at.off)), Some(InlineObject::Shape { kind: ShapeKind::StraightConnector, .. })));
let rect = s.layout().object(&at, 0).unwrap().rect;
assert!((rect.x - 100.0).abs() < 1.0 && (rect.y - 300.0).abs() < 1.0, "where the line was: {rect:?}");
let got: Vec<f32> = r["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect();
assert!(
got.len() == 4 && [rect.x, rect.y, rect.w, rect.h].iter().zip(&got).all(|(a, b)| (a - b).abs() < 0.5),
"the arrow's rect, not the deleted head's: {got:?} vs {rect:?}"
);
// It survives a DOCX save and open.
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let again: Vec<_> = back.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect();
assert!(
again
.iter()
.any(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, .. } if extra.arrow_end && !extra.arrow_start)),
"{again:?}"
);
// Undo brings back the line, without the head.
s.run("edit.undo", &json!({})).unwrap();
let all = drawings(&s);
let [InlineObject::Shape { kind: ShapeKind::Freeform, extra, .. }] = all.as_slice() else { panic!("the line: {all:?}") };
assert!(!extra.arrow_end);
assert!(ink(&s).is_empty());
}
#[test]
fn ink_to_shape_pairs_a_closed_triangle_head_at_the_start_and_a_head_drawn_first() {
let mut s = session();
s.run("draw.inkToShape", &json!({"value": true})).unwrap();
s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap();
// A closed triangle at the left end, where the line began: the arrow points left.
let tri = [[100.0, 300.0], [113.0, 292.5], [126.0, 285.0], [126.0, 300.0], [126.0, 315.0], [113.0, 307.5], [100.0, 300.0]];
let r = s.run("draw.stroke", &json!({"page": 0, "points": tri})).unwrap();
assert_eq!(r["shape"], "arrow");
let all = drawings(&s);
let [InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") };
assert!(extra.arrow_end && float.flip_h && !float.flip_v, "tip at the left: {extra:?} {float:?}");
// Head first (it stays ink on its own), then the shaft that ends at its apex.
let mut s = session();
s.run("draw.inkToShape", &json!({"value": true})).unwrap();
let r = s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap();
assert!(r["shape"].is_null());
let r = s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap();
assert_eq!(r["shape"], "arrow");
assert!(ink(&s).is_empty());
let all = drawings(&s);
let [InlineObject::Shape { kind: ShapeKind::StraightConnector, extra, float, .. }] = all.as_slice() else { panic!("one arrow: {all:?}") };
assert!(extra.arrow_end && !float.flip_h, "{extra:?} {float:?}");
// A V far from the line's ends stays ink, and the line stays a line.
let mut s = session();
s.run("draw.inkToShape", &json!({"value": true})).unwrap();
s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap();
let r = s.run("draw.stroke", &json!({"page": 0, "points": [[174.0, 385.0], [200.0, 400.0], [174.0, 415.0]]})).unwrap();
assert!(r["shape"].is_null());
assert_eq!(ink(&s).len(), 1);
assert!(drawings(&s).iter().all(|o| !matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. })));
}
#[test]
fn converting_a_lasso_selection_pairs_a_line_and_a_head_stroke_into_an_arrow() {
let mut s = session();
s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke(), "color": "0070C0"})).unwrap();
s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap();
s.run("draw.stroke", &json!({"page": 0, "points": rect_stroke(100.0, 400.0, 120.0, 60.0)})).unwrap();
assert_eq!(ink(&s).len(), 3);
let steps = s.undo_depth();
s.run("draw.lasso", &json!({"page": 0, "points": [[80, 260], [340, 260], [340, 480], [80, 480]]})).unwrap();
let r = s.run("draw.inkToShape", &json!({})).unwrap();
let names: Vec<&str> = r["converted"].as_array().unwrap().iter().filter_map(|c| c["shape"].as_str()).collect();
assert!(names.contains(&"arrow") && names.contains(&"rectangle"), "{names:?}");
assert!(ink(&s).is_empty(), "the head stroke went into the arrow");
let all = drawings(&s);
assert_eq!(all.len(), 2, "{all:?}");
let arrow = all.iter().find(|o| matches!(o, InlineObject::Shape { kind: ShapeKind::StraightConnector, .. }));
let Some(InlineObject::Shape { extra, stroke, .. }) = arrow else { panic!("{all:?}") };
assert!(extra.arrow_end && *stroke == Some(wordcraft_doc::Rgb(0, 0x70, 0xC0)));
// The selection still holds what's left: deleting it removes both shapes.
s.run("draw.lassoDelete", &json!({})).unwrap();
assert!(drawings(&s).is_empty());
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(drawings(&s).len(), 2);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(ink(&s).len(), 3, "one undo step brings all three strokes back");
assert_eq!(s.undo_depth(), steps);
}
#[test]
fn converting_a_crowded_lasso_selection_recognises_each_stroke_a_bounded_number_of_times() {
let mut s = session();
s.run("draw.stroke", &json!({"page": 0, "points": shaft_stroke()})).unwrap();
s.run("draw.stroke", &json!({"page": 0, "points": [[274.0, 285.0], [300.0, 300.0], [274.0, 315.0]]})).unwrap();
// A stack of 40 lines close together: every pair is near, none makes an arrow.
let n = 40;
for i in 0..n {
let y = 420.0 + i as f32 * 2.0;
let pts: Vec<[f32; 2]> = (0..=40).map(|k| [100.0 + k as f32 * 5.0, y]).collect();
s.run("draw.stroke", &json!({"page": 0, "points": pts})).unwrap();
}
s.run("draw.lasso", &json!({"page": 0, "points": [[80, 260], [340, 260], [340, 560], [80, 560]]})).unwrap();
super::super::ink_shape::RECOGNISED.with(|c| c.set(0));
let r = s.run("draw.inkToShape", &json!({})).unwrap();
let calls = super::super::ink_shape::RECOGNISED.with(|c| c.get());
let strokes = n + 2;
assert!(calls <= 2 * strokes, "{calls} recognitions for {strokes} strokes");
let names: Vec<&str> = r["converted"].as_array().unwrap().iter().filter_map(|c| c["shape"].as_str()).collect();
assert_eq!(names.iter().filter(|n| **n == "arrow").count(), 1, "{names:?}");
assert_eq!(names.iter().filter(|n| **n == "line").count(), n, "{names:?}");
assert!(ink(&s).is_empty());
}
#[test]
fn ink_replay_plays_strokes_in_the_order_they_were_drawn() {
let mut s = session();
+317 -36
View File
@@ -4,8 +4,10 @@
//! 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. An arrow is a line-like shaft plus a head of two barbs
//! drawn in the same stroke (shaft first or head first). Anything else (scribbles, curves, letters) stays ink.
//! that polygon stays close to every point. An arrow is a line-like shaft plus a head drawn in the
//! same stroke (shaft first or head first): two barbs, or a small closed triangle round the tip.
//! [`pair_arrow`] makes one arrow of two strokes: a line, and a head (an open V or a small closed
//! triangle) whose apex sits at one of its ends. Anything else (scribbles, curves, letters) stays ink.
use wordcraft_doc::freeform::MAX_POINTS;
@@ -55,15 +57,17 @@ const MIN_SIZE: f32 = 6.0;
/// Most corners a polygon may have.
pub const MAX_CORNERS: usize = 8;
#[cfg(test)]
thread_local! {
/// How many times [`recognise`] ran on this thread (tests check the work stays bounded).
pub static RECOGNISED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// 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);
}
}
#[cfg(test)]
RECOGNISED.with(|c| c.set(c.get().saturating_add(1)));
let p = clean(pts);
let (first, last) = (*p.first()?, *p.last()?);
if p.len() < 2 {
return None;
@@ -83,8 +87,10 @@ pub fn recognise(pts: &[[f32; 2]]) -> Option<InkShape> {
return Some(InkShape::Line { a: first, b: last });
}
}
// An arrow: a shaft and a head with both barbs, drawn in one stroke (either end first).
if let Some(arrow) = arrow(&p).or_else(|| arrow(&p.iter().rev().copied().collect::<Vec<_>>())) {
// An arrow: a shaft and a head (both barbs, or a closed triangle), drawn in one stroke
// (either end first).
let rev: Vec<[f32; 2]> = p.iter().rev().copied().collect();
if let Some(arrow) = arrow(&p).or_else(|| arrow(&rev)).or_else(|| closed_arrow(&p)).or_else(|| closed_arrow(&rev)) {
return Some(arrow);
}
// Other shapes are closed: the stroke ends near where it began and goes around.
@@ -126,27 +132,7 @@ const BARB_ANGLE: (f32, f32) = (15.0, 75.0);
/// similar angles and lengths. (A stroke drawn head first is tried reversed by the caller.)
fn arrow(p: &[[f32; 2]]) -> Option<InkShape> {
let tail = *p.first()?;
// The tip: start at the first point about as far from the start as any (a return that
// overshoots it by a point or two must not move it), then follow the stroke on while it still
// moves away from the start (densely sampled strokes have shaft points inside that window).
// The first barb heads back, so the walk stops at the real tip and never reaches an overshoot;
// jitter of under half a point doesn't stop it early.
let dmax = p.iter().map(|q| dist(tail, *q)).fold(0.0, f32::max);
let mut ti = p.iter().position(|q| dist(tail, *q) >= dmax - (0.03 * dmax).max(2.0))?;
let mut best = dist(tail, *p.get(ti)?);
for (i, q) in p.iter().enumerate().skip(ti + 1) {
let d = dist(tail, *q);
if d >= best {
(ti, best) = (i, d);
} else if d < best - 0.5 {
break;
}
}
let tip = *p.get(ti)?;
let shaft = dist(tail, tip);
if shaft < MIN_SIZE || p.get(..=ti)?.iter().any(|q| seg_distance(tail, tip, *q) > (0.06 * shaft).max(1.5)) {
return None;
}
let (ti, tip, shaft) = shaft_tip(p)?;
// The head: the stroke after the tip leaves it twice, once for each barb.
let head = p.get(ti..)?;
let reach = head.iter().map(|q| dist(tip, *q)).fold(0.0, f32::max);
@@ -172,20 +158,166 @@ fn arrow(p: &[[f32; 2]]) -> Option<InkShape> {
}
let &[(l1, b1), (l2, b2)] = barbs.as_slice() else { return None };
// The pen returns to the tip between the barbs.
if gap > 0.35 * l1.min(l2) {
if gap > 0.35 * l1.min(l2) || shaft <= 0.0 {
return None;
}
// Each barb as a vector from the tip, against the direction back along the shaft.
head_fits(tail, tip, tip, b1, b2).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false })
}
/// An arrow with a closed triangle head, drawn shaft first: the shaft runs from the start to the
/// tip like [`arrow`]'s, then the pen goes round a small triangle (tip, one barb end, the other)
/// and ends back at the tip. (Drawn head first, the caller tries it reversed.)
fn closed_arrow(p: &[[f32; 2]]) -> Option<InkShape> {
let tail = *p.first()?;
let (ti, tip, _) = shaft_tip(p)?;
let head = p.get(ti..)?;
// The corners: the point farthest from the tip, then the one farthest from that side.
let b1 = head.iter().copied().fold(tip, |m, q| if dist(tip, q) > dist(tip, m) { q } else { m });
let b2 = head.iter().copied().fold(tip, |m, q| if seg_distance(tip, b1, q) > seg_distance(tip, b1, m) { q } else { m });
let (l1, l2) = (dist(tip, b1), dist(tip, b2));
if l1.min(l2) <= 0.0 || dist(*head.last()?, tip) > 0.35 * l1.min(l2) {
return None;
}
// Every point of the head is on the triangle.
let tri = [tip, b1, b2];
if head.iter().any(|q| polygon_distance(&tri, *q) > (0.15 * l1).max(1.5)) {
return None;
}
head_fits(tail, tip, tip, b1, b2).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false })
}
/// Whether a head whose barbs run from `apex` to `b1` and `b2` fits a shaft from `tail` to `tip`:
/// barbs 5–50 % of the shaft, each 15–75° off the way back along it, on opposite sides of it,
/// and of similar length and angle.
fn head_fits(tail: [f32; 2], tip: [f32; 2], apex: [f32; 2], b1: [f32; 2], b2: [f32; 2]) -> bool {
let shaft = dist(tail, tip);
let (l1, l2) = (dist(apex, b1), dist(apex, b2));
if !(shaft > 0.0 && l1 > 0.0 && l2 > 0.0) {
return false;
}
// Each barb as a vector from the apex, against the direction back along the shaft.
let back = [tail[0] - tip[0], tail[1] - tip[1]];
let angle_side = |e: [f32; 2], len: f32| {
let u = [e[0] - tip[0], e[1] - tip[1]];
let u = [e[0] - apex[0], e[1] - apex[1]];
let angle = ((back[0] * u[0] + back[1] * u[1]) / (shaft * len)).clamp(-1.0, 1.0).acos().to_degrees();
(angle, back[0] * u[1] - back[1] * u[0])
};
let ((a1, s1), (a2, s2)) = (angle_side(b1, l1), angle_side(b2, l2));
let ok = |l: f32, a: f32| (BARB_LEN.0 * shaft..=BARB_LEN.1 * shaft).contains(&l) && (BARB_ANGLE.0..=BARB_ANGLE.1).contains(&a);
let similar = l1.max(l2) <= 2.5 * l1.min(l2) && (a1 - a2).abs() <= 35.0;
(ok(l1, a1) && ok(l2, a2) && s1 * s2 < 0.0 && similar).then_some(InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false })
ok(l1, a1) && ok(l2, a2) && s1 * s2 < 0.0 && similar
}
/// How far a two-stroke arrow's head apex may sit from the line's end, as a fraction of the line.
const PAIR_REACH: f32 = 0.12;
/// What a stroke can be in a two-stroke arrow, worked out once (one recognition): the ends of the
/// straight line it is, if it is one, and the ways it can be an arrowhead on its own.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ArrowParts {
pub line: Option<([f32; 2], [f32; 2])>,
pub heads: Vec<[[f32; 2]; 3]>,
}
/// `pts` as a possible half of a two-stroke arrow (see [`pair_parts`]).
pub fn arrow_parts(pts: &[[f32; 2]]) -> ArrowParts {
let p = clean(pts);
let found = recognise(&p);
let line = match found {
Some(InkShape::Line { a, b }) => Some((a, b)),
_ => None,
};
ArrowParts { line, heads: heads(&p, found.as_ref()) }
}
/// One arrow from two strokes: `line`, a straight stroke, and `head`, an open V (one barb end,
/// the apex, the other end) or a small closed triangle, whose apex is at one end of the line and
/// whose barbs point back along it (the single-stroke head limits). The arrow runs from the
/// line's other end to that end; `None` when they don't make one.
pub fn pair_arrow(line: &[[f32; 2]], head: &[[f32; 2]]) -> Option<InkShape> {
pair_parts(&arrow_parts(line), &arrow_parts(head))
}
/// [`pair_arrow`] on strokes already recognised by [`arrow_parts`]: cheap geometry only.
pub fn pair_parts(line: &ArrowParts, head: &ArrowParts) -> Option<InkShape> {
let (a, b) = line.line?;
let mut best: Option<(f32, InkShape)> = None;
for &[apex, b1, b2] in &head.heads {
for (tail, tip) in [(a, b), (b, a)] {
let off = dist(apex, tip);
let near = off <= (PAIR_REACH * dist(tail, tip)).max(3.0);
if near && best.as_ref().is_none_or(|(d, _)| off < *d) && head_fits(tail, tip, apex, b1, b2) {
best = Some((off, InkShape::Arrow { a: tail, b: tip, head_at_end: true, head_at_start: false }));
}
}
}
best.map(|(_, arrow)| arrow)
}
/// The ways the cleaned points `p` (which recognise as `found`) can be an arrowhead on their own,
/// as `[apex, barb end, barb end]`: each corner of a closed triangle, or the corner of an open V
/// whose two arms are straight.
fn heads(p: &[[f32; 2]], found: Option<&InkShape>) -> Vec<[[f32; 2]; 3]> {
if let Some(InkShape::Polygon(c)) = found
&& let &[x, y, z] = c.as_slice()
{
return vec![[x, y, z], [y, z, x], [z, x, y]];
}
let (Some(&f), Some(&l)) = (p.first(), p.last()) else { return Vec::new() };
// The apex: the point farthest from the chord between the ends.
let Some((ai, apex)) = p.iter().copied().enumerate().fold(None, |m: Option<(usize, [f32; 2])>, (i, q)| match m {
Some((_, b)) if seg_distance(f, l, b) >= seg_distance(f, l, q) => m,
_ => Some((i, q)),
}) else {
return Vec::new();
};
let straight = |a: [f32; 2], b: [f32; 2], arm: Option<&[[f32; 2]]>| {
let len = dist(a, b);
len >= 1.0 && arm.is_some_and(|arm| arm.iter().all(|q| seg_distance(a, b, *q) <= (0.12 * len).max(1.5)))
};
if ai == 0 || ai + 1 >= p.len() || !straight(f, apex, p.get(..=ai)) || !straight(apex, l, p.get(ai..)) {
return Vec::new();
}
vec![[apex, f, l]]
}
/// Finite, not absurdly far points only (at most [`MAX_POINTS`]), without repeats.
fn clean(pts: &[[f32; 2]]) -> Vec<[f32; 2]> {
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);
}
}
p
}
/// A shaft drawn from the start of `p`: the index of its tip, the tip and the shaft's length;
/// `None` when it is too short or not straight.
fn shaft_tip(p: &[[f32; 2]]) -> Option<(usize, [f32; 2], f32)> {
let tail = *p.first()?;
// The tip: start at the first point about as far from the start as any (a return that
// overshoots it by a point or two must not move it), then follow the stroke on while it still
// moves away from the start (densely sampled strokes have shaft points inside that window).
// The first barb heads back, so the walk stops at the real tip and never reaches an overshoot;
// jitter of under half a point doesn't stop it early.
let dmax = p.iter().map(|q| dist(tail, *q)).fold(0.0, f32::max);
let mut ti = p.iter().position(|q| dist(tail, *q) >= dmax - (0.03 * dmax).max(2.0))?;
let mut best = dist(tail, *p.get(ti)?);
for (i, q) in p.iter().enumerate().skip(ti + 1) {
let d = dist(tail, *q);
if d >= best {
(ti, best) = (i, d);
} else if d < best - 0.5 {
break;
}
}
let tip = *p.get(ti)?;
let shaft = dist(tail, tip);
if shaft < MIN_SIZE || p.get(..=ti)?.iter().any(|q| seg_distance(tail, tip, *q) > (0.06 * shaft).max(1.5)) {
return None;
}
Some((ti, tip, shaft))
}
/// Left, top, width and height of `pts` (zero for none).
@@ -686,4 +818,153 @@ mod tests {
let b = tip_of(&stroke);
assert!(b.is_some_and(|b| dist(b, tip) < 1.0), "overshoot head first: {b:?}");
}
/// A shaft from `tail` to `tip`, then a closed triangle head drawn round the tip in the same
/// stroke (tip, one barb end, the other, back to the tip); `n` shaft points, `m` per side.
fn closed_arrow_n(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32, n: usize, m: usize) -> Vec<[f32; 2]> {
let [b1, b2] = barb_ends(tail, tip, barb, spread_deg);
let mut pts = line(tail, tip, n);
pts.extend(line(tip, b1, m));
pts.extend(line(b1, b2, m));
pts.extend(line(b2, tip, m));
pts.push(tip);
pts
}
/// The ends of a head's two barbs at `tip`, `barb` long, `spread_deg` either side of the shaft.
fn barb_ends(tail: [f32; 2], tip: [f32; 2], barb: f32, spread_deg: f32) -> [[f32; 2]; 2] {
let back = (tail[1] - tip[1]).atan2(tail[0] - tip[0]);
let end = |sign: f32| {
let a = back + sign * spread_deg.to_radians();
[tip[0] + barb * a.cos(), tip[1] + barb * a.sin()]
};
[end(1.0), end(-1.0)]
}
#[test]
fn arrows_with_a_closed_triangle_head_in_one_stroke_are_recognised_both_ways() {
for (tail, tip) in
[([20.0, 100.0], [180.0, 100.0]), ([180.0, 100.0], [20.0, 100.0]), ([100.0, 180.0], [100.0, 20.0]), ([150.0, 120.0], [30.0, 30.0])]
{
for stroke in [closed_arrow_n(tail, tip, 25.0, 30.0, 24, 5), closed_arrow_n(tail, tip, 20.0, 25.0, dist(tail, tip).round() as usize, 15)]
{
let Some(InkShape::Arrow { a, b, head_at_end, head_at_start }) = recognise(&stroke) else {
panic!("{tail:?}->{tip:?}: {:?}", recognise(&stroke))
};
assert!(head_at_end && !head_at_start);
assert!(dist(a, tail) < 2.0 && dist(b, tip) < 2.0, "{a:?} {b:?} for {tail:?} {tip:?}");
// The loop round the head first, then the shaft: the same arrow.
let mut rev = stroke;
rev.reverse();
let Some(InkShape::Arrow { a, b, .. }) = recognise(&rev) else { panic!("head first {tail:?}->{tip:?}: {:?}", recognise(&rev)) };
assert!(dist(a, tail) < 2.0 && dist(b, tip) < 2.0, "head first {a:?} {b:?}");
}
}
// The head size limits hold: 5 to 50 % of a 160 pt shaft, 15 to 75 degrees.
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
let is_arrow = |l: f32, deg: f32| matches!(recognise(&closed_arrow_n(tail, tip, l, deg, 24, 5)), Some(InkShape::Arrow { .. }));
assert!(is_arrow(12.0, 30.0) && is_arrow(70.0, 30.0) && is_arrow(25.0, 20.0) && is_arrow(25.0, 70.0));
assert!(!is_arrow(6.0, 30.0) && !is_arrow(90.0, 30.0) && !is_arrow(25.0, 8.0) && !is_arrow(25.0, 85.0));
}
#[test]
fn triangles_without_a_shaft_stay_triangles() {
// Closed, and closed with the pen running on past the start along the first side.
let tri = around(&[[0.0, 80.0], [45.0, 0.0], [90.0, 80.0]], 15);
assert_eq!(recognise(&tri).map(|s| s.name()), Some("triangle"));
let mut over = tri.clone();
over.extend(line([0.0, 80.0], [45.0, 0.0], 15).into_iter().skip(1).take(8));
assert!(!matches!(recognise(&over), Some(InkShape::Arrow { .. })), "{:?}", recognise(&over));
// A long thin triangle is not a shaft with a head.
let thin = around(&[[0.0, 0.0], [160.0, 10.0], [0.0, 20.0]], 20);
assert!(!matches!(recognise(&thin), Some(InkShape::Arrow { .. })), "{:?}", recognise(&thin));
// A shaft with a triangle as big as the shaft is not an arrow.
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
assert!(!matches!(recognise(&closed_arrow_n(tail, tip, 150.0, 30.0, 24, 5)), Some(InkShape::Arrow { .. })));
// Hostile points around a closed head never panic.
let mut junk = closed_arrow_n(tail, tip, 25.0, 30.0, 24, 5);
junk.insert(30, [f32::NAN, f32::INFINITY]);
assert!(matches!(recognise(&junk), Some(InkShape::Arrow { .. })));
}
/// A head drawn on its own: an open V (one barb end, the apex, the other end) or a closed
/// triangle (apex, barb end, barb end, apex), `m` points per side.
fn v_head(apex: [f32; 2], [b1, b2]: [[f32; 2]; 2], m: usize) -> Vec<[f32; 2]> {
let mut pts = line(b1, apex, m);
pts.extend(line(apex, b2, m));
pts.push(b2);
pts
}
fn triangle_head(apex: [f32; 2], [b1, b2]: [[f32; 2]; 2], m: usize) -> Vec<[f32; 2]> {
let mut pts = line(apex, b1, m);
pts.extend(line(b1, b2, m));
pts.extend(line(b2, apex, m));
pts.push(apex);
pts
}
#[test]
fn a_head_stroke_at_a_line_end_pairs_into_an_arrow() {
for (tail, tip) in [([20.0, 100.0], [180.0, 100.0]), ([180.0, 160.0], [40.0, 30.0]), ([100.0, 20.0], [100.0, 180.0])] {
let shaft = line(tail, tip, 40).into_iter().chain([tip]).collect::<Vec<_>>();
let ends = barb_ends(tail, tip, 25.0, 30.0);
for (what, head) in [("V", v_head(tip, ends, 8)), ("triangle", triangle_head(tip, ends, 8)), ("bare V", vec![ends[0], tip, ends[1]])] {
let Some(InkShape::Arrow { a, b, head_at_end, head_at_start }) = pair_arrow(&shaft, &head) else {
panic!("{what} {tail:?}->{tip:?}: {:?}", pair_arrow(&shaft, &head))
};
assert!(head_at_end && !head_at_start);
assert!(dist(a, tail) < 1.0 && dist(b, tip) < 1.0, "{what}: {a:?} {b:?}");
// Drawn the other way round, the head is still found at its end.
let mut rev = head.clone();
rev.reverse();
assert!(matches!(pair_arrow(&shaft, &rev), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "{what} reversed");
let mut back = shaft.clone();
back.reverse();
assert!(matches!(pair_arrow(&back, &head), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "{what}, shaft drawn tip first");
}
// A triangle given as its corners (an Ink to Shape triangle), apex anywhere in the ring.
let [b1, b2] = barb_ends(tail, tip, 25.0, 30.0);
assert!(matches!(pair_arrow(&shaft, &[b1, b2, tip, b1]), Some(InkShape::Arrow { b, .. }) if dist(b, tip) < 1.0), "corners");
// An apex a few points short of the end still pairs.
let d = [(tip[0] - tail[0]) / dist(tail, tip), (tip[1] - tail[1]) / dist(tail, tip)];
let near = [tip[0] - 6.0 * d[0] + 3.0 * d[1], tip[1] - 6.0 * d[1] - 3.0 * d[0]];
assert!(pair_arrow(&shaft, &v_head(near, barb_ends(tail, near, 25.0, 30.0), 8)).is_some(), "near the end");
}
}
#[test]
fn heads_away_from_the_end_the_wrong_way_or_the_wrong_size_dont_pair() {
let (tail, tip) = ([20.0, 100.0], [180.0, 100.0]);
let shaft = line(tail, tip, 40).into_iter().chain([tip]).collect::<Vec<_>>();
let head_at = |apex: [f32; 2], barb: f32, deg: f32| v_head(apex, barb_ends(tail, apex, barb, deg), 8);
// Far from either end: mid-shaft, and 40 pt past the tip.
assert_eq!(pair_arrow(&shaft, &head_at([100.0, 100.0], 25.0, 30.0)), None);
assert_eq!(pair_arrow(&shaft, &head_at([220.0, 100.0], 25.0, 30.0)), None);
assert_eq!(pair_arrow(&shaft, &head_at([180.0, 140.0], 25.0, 30.0)), None);
// Pointing back at the shaft (barbs forward), too big, too small, too narrow, too wide.
let forward = v_head(tip, barb_ends([340.0, 100.0], tip, 25.0, 30.0), 8);
assert_eq!(pair_arrow(&shaft, &forward), None);
for (barb, deg) in [(100.0, 30.0), (5.0, 30.0), (25.0, 5.0), (25.0, 85.0)] {
assert_eq!(pair_arrow(&shaft, &head_at(tip, barb, deg)), None, "barb {barb} at {deg} degrees");
}
// Lopsided: both barbs on one side.
assert_eq!(pair_arrow(&shaft, &[[155.0, 87.0], tip, [158.0, 80.0]]), None);
// The "line" must be a line, the head a head.
let ends = barb_ends(tail, tip, 25.0, 30.0);
let zig: Vec<[f32; 2]> = (0..9).map(|i| [20.0 + i as f32 * 20.0, if i % 2 == 0 { 100.0 } else { 120.0 }]).collect();
assert_eq!(pair_arrow(&zig, &v_head(tip, ends, 8)), None);
assert_eq!(pair_arrow(&shaft, &[[150.0, 80.0], [170.0, 120.0], [175.0, 80.0], [180.0, 120.0]]), None, "a scribble");
assert_eq!(pair_arrow(&shaft, &around(&[[150.0, 60.0], [190.0, 60.0], [190.0, 140.0], [150.0, 140.0]], 8)), None, "a box");
// Hostile input never panics.
let mut junk = v_head(tip, ends, 8);
junk.insert(2, [f32::NAN, 1.0]);
junk.push([f32::INFINITY, 0.0]);
assert!(pair_arrow(&shaft, &junk).is_some());
assert_eq!(pair_arrow(&[], &[]), None);
assert_eq!(pair_arrow(&shaft, &[[180.0, 100.0]]), None);
let huge: Vec<[f32; 2]> = (0..MAX_POINTS * 2).map(|i| [(i % 977) as f32, ((i * 31) % 541) as f32]).collect();
let _ = pair_arrow(&huge, &huge);
let _ = pair_arrow(&shaft, &huge);
}
}
+2 -1
View File
@@ -35,7 +35,7 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin
| 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian only**; no dictionaries, grammar or hyphenation for the other ~42 languages Word proofs (text in them is left unmarked) | | `crates/proof` `ProofLang::PROOFED`: English, Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) and Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16), a language per run; Bulgarian has hyphenation only (#826); open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech and Indonesian | 20–40 | [localization-parity.md](localization-parity.md) |
| 10 | **Objects**: 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 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
| 11 | **Interface languages**: 3 of the 12 key languages missing (hi, ar, id) and pt-PT (French landed, #729; Vietnamese, #1434; Korean, #1216; 21 interface languages in `crates/ui-egui/src/i18n`); open PRs for Arabic (#737, #1042, #1032), Indonesian (#572) and pt-PT (#1028); no mirrored RTL interface yet; ~5% of strings still English in the 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); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); missing: Ink to Math, two-stroke arrows and arrows with a closed triangle head, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [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); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) |
| 13 | **Charts and SmartArt editing** (editing charts and SmartArt from Word, more chart types and formatting) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts (charts of those types show a placeholder with their title and type, #998), axis or series formatting; Insert › SmartArt landed (#497) for graphics WordCraft makes | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) |
| 14 | **`.doc` can't be written; 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**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189) | | #790 stores sources in `customXml` and reads them back; `crates/zotero` | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) |
@@ -111,6 +111,7 @@ The Language dialog reads effective character formatting, including document def
| 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. |
| 2026-10-14 | trivial | Field report: grouped `flowChartOr` omitted its crossing spokes; the preset now draws them; wider DOCX corpus work remains open |
| 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table |
| 2026-10-15 | trivial | #12: Ink to Shape pairs a line with a separately drawn V or closed-triangle head, and recognises one-stroke arrows with a closed triangle head |
| 2026-10-13 | trivial | Gap #3: Behind Text pictures and shapes drag over text in the body and headers, selected or not; a click without movement still edits the text |
| 2026-10-13 | trivial | Row 1: the watermark is saved to DOCX headers (#1290) |
| 2026-10-13 | trivial | Row 1: Word's text watermark in DOCX headers is drawn (#1290) |
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware parity with Microsoft Word
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** trivial (Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** trivial (Pen / stylus row: two-stroke and closed-head arrows; previously trivial: Pen / stylus row: ink, eraser and Ink to Shape incl. arrows landed; previously trivial: a window too big for its screen shrinks to fit when it opens, #1215; previously trivial: 32-bit Windows starts on OpenGL; previously major: first version) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 and Word for Windows
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -14,7 +14,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
|---|---|---|---|---|---|---|---|
| Printers: native print dialog, printer choice, copies, duplex, page ranges, tray, scaling | ✅ (`WordPDE.plugin` print-dialog extension on Mac) | 🟡 Direct CUPS printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | 🟡 Direct spooler printing; printer, copies, collation, duplex, ranges and grayscale | ✅ browser print dialog (#209) | 35% | 5–8 |
| GPU rendering, HiDPI/Retina, pixel-aligned text | ✅ | ✅ wgpu + vello_cpu rasteriser, whole-pixel text (#110, #165) | ✅ DirectX 12 default (#50), OpenGL when DirectX 12 has no GPU driver (#316) or on 32-bit; startup crash on Intel UHD (#170) | ✅ (soft text on KDE at some zooms, #140) | ✅ | 80% | 3–5 (bugs) |
| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | 🟡 ink, stroke eraser, Ink to Shape incl. arrows (#307, #395); pressure and Ink to Math missing | 🟡 same | 🟡 same | 🟡 same | 50% | (Draw tab, 15–25) |
| Pen / stylus: ink, pressure, eraser, ink to shape/math | ✅ (Draw tab, `InkRender.bundle`) | 🟡 ink, stroke eraser, Ink to Shape incl. one- and two-stroke arrows with open or closed heads (#307, #395); pressure and Ink to Math missing | 🟡 same | 🟡 same | 🟡 same | 50% | (Draw tab, 15–25) |
| Multiple monitors, per-monitor DPI, remembered window position, a window that fits its screen | ✅ | ✅ (#38, #76, #1215) | ✅ (winit DPI fix pending, #155) | ✅ | — | 85% | 1 |
| Microphone: Dictate | ✅ (cloud speech service) | ❌ | ❌ | ❌ | ❌ | 0% | 8–15 + owner (speech model choice) |
| Speakers: Read Aloud | ✅ | ✅ system speech, speed, sentence skip (#190) | ✅ | 🟡 | 🟡 | 75% | 1–2 |
@@ -25,6 +25,7 @@ pens, microphones (Dictate) and speakers (Read Aloud), and displays.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | trivial | Pen / stylus row: Ink to Shape also makes two-stroke arrows and arrows with a closed triangle head |
| 2026-10-14 | trivial | Pen / stylus row corrected: ink, eraser and Ink to Shape (with arrows) work; pressure and Ink to Math missing |
| 2026-10-13 | trivial | A window too big for its screen shrinks to fit when it opens (#1215): eframe sized the first window to the whole monitor, frame and taskbar not counted, so on a laptop screen its bottom, with the end of the scroll bar, was past the screen edge. A window saved that size is fitted on its next launch too |
| 2026-10-12 | trivial | 32-bit Windows starts on OpenGL: DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |