Draw tab: draw with pen, pencil and highlighter, erase ink, and hide ink (#315)

* Draw Table and Eraser: draw tables and split or merge cells with a pen (#303)

Insert › Tables › Draw Table and Table Layout › Draw › Draw Table / Eraser.
table.draw takes a pen stroke in page points: a rectangle outside a table
inserts a one-cell table of that size at the paragraph under its start; a
straight vertical or horizontal line inside a table splits the cells it
crosses (new grid column or row; untouched cells span it). table.eraser
merges the two cells beside the border nearest a point. Each stroke is one
undo step. In the app the buttons toggle pen and eraser modes with a live
preview, a crosshair with the tool's icon, and Esc to stop; canvas clicks
don't move the caret while a tool is on. Splitting through part of a
vertically merged cell and erasing beside one are refused with a clear
error. Catalog 384/430 -> 387/430.

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

* Draw tab: pen, pencil and highlighter ink, stroke eraser and Hide Ink (#307)

Ink strokes are floating freeform shapes: a new Freeform geometry (paths in
shape-local coordinates, opacity, and the pen that drew it) on
InlineObject::Shape with ShapeKind::Freeform. draw.stroke inserts one
anchored to the paragraph under the first point (one undo step; points
capped per stroke, strokes capped per page, NaN dropped); draw.erase /
draw.eraser with a point deletes the stroke under it; draw.pen, draw.pencil,
draw.highlighter, draw.eraser and draw.select set the Draw tab's tool in the
session view state; review.hideInk toggles showing ink on screen.

The canvas draws a live stroke while a pen is dragged and commits it on
release; the eraser deletes strokes it passes over (one undo step per drag);
Esc or Select goes back to selecting. Draw tab buttons show the tool in use,
with colour and thickness menus; Review gets an Ink group with Hide Ink.
Ink renders with round caps and joins on screen and in PDF; highlighters are
translucent and behind the text. DOCX writes freeforms as wps:wsp with
a:custGeom (moveTo/lnTo) in a wp:anchor and reads them back, along with
simple custom-geometry freeforms from other files.

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

* #315 review fixes: eraser undo test, sanitized tap dots, custGeom path cap, ink docs

- draw.eraser with a point erases through draw.erase: test that it is one
  undo step (undo/redo) and marks the document dirty.
- Render and PDF: a single-point stroke's dot is drawn at the sanitized
  point, not the raw first one.
- DOCX custGeom reading stops once MAX_PATHS paths (subpaths included) are
  collected, across a:path elements.
- docs/file-format-parity.md: ink is saved as custom geometry, not InkML, so
  Word shows it as a freeform.
- widgets test: theme::apply takes an Appearance (#317).

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

* fmt

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

* Draw tab: pen, pencil and highlighter ink, stroke eraser and Hide Ink (#307)

Ink strokes are floating freeform shapes: a new Freeform geometry (paths in
shape-local coordinates, opacity, and the pen that drew it) on
InlineObject::Shape with ShapeKind::Freeform. draw.stroke inserts one
anchored to the paragraph under the first point (one undo step; points
capped per stroke, strokes capped per page, NaN dropped); draw.erase /
draw.eraser with a point deletes the stroke under it; draw.pen, draw.pencil,
draw.highlighter, draw.eraser and draw.select set the Draw tab's tool in the
session view state; review.hideInk toggles showing ink on screen.

The canvas draws a live stroke while a pen is dragged and commits it on
release; the eraser deletes strokes it passes over (one undo step per drag);
Esc or Select goes back to selecting. Draw tab buttons show the tool in use,
with colour and thickness menus; Review gets an Ink group with Hide Ink.
Ink renders with round caps and joins on screen and in PDF; highlighters are
translucent and behind the text. DOCX writes freeforms as wps:wsp with
a:custGeom (moveTo/lnTo) in a wp:anchor and reads them back, along with
simple custom-geometry freeforms from other files.

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

---------

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 00:50:37 +02:00 committed by GitHub
parent 0e2e4cde80
commit e71e2d1c52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1317 additions and 66 deletions

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (Draw Table and Eraser landed, #303: catalog 396/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
agent control. This page is the summary; the assessment is
@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **396 / 434 (91.2%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| Ribbon/menu catalog coverage | **402 / 434 (92.6%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
@ -130,6 +130,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-10 | Draw tab: pen, pencil and highlighter ink, stroke eraser, Select, Review › Hide Ink; ink saved to DOCX as DrawingML freeforms (#307); catalog 399/431 (92.6%) |
| 2026-10-10 | Draw Table and Eraser (#303): a pen draws one-cell tables and splits cells along drawn lines; the eraser merges the cells beside a border. Catalog 393/431 (91.2%) |
| 2026-10-10 | DOCX charts, SmartArt and OLE objects survive open and save with their parts (#319) |
| 2026-10-10 | Full re-measure against Word for Mac 16.113.4 and progress docs to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, format/layout/UI/hardware/localization parity; `docs/parity.md` → `docs/parity-checklist.md`). Landed the same day (~80 PRs): equations with OMML and an Equation tab (#191); Word 97-2003 `.doc` import (#145); right-to-left and Persian text (#207); Zotero integration (#189); Read Aloud player (#190); text boxes and floating objects editable (#46) and placed like Word (#136); floating tables (#137); track-changes fixes (#125, #244); typing parity with Word (#204); hidden text (#173); save prompts and AutoSave rules (#151); LaTeX import/export (#11); keytips and mini-toolbar (#43); Spanish, Ukrainian and Brazilian Portuguese interfaces; system theme (#249); Linux file dialogs no longer freeze (#246); Chinese UI font on Windows (#248); every installed font weight (#239); cell text direction (#245); Paste Special (#235), Advanced Find (#234), Column Selection (#237), Style Inspector (#236), custom table styles (#256), View gridlines (#243), zoom buttons (#232), inertial touchpad scrolling (#252), File Info fields kept (#268), ¶ in mixed-direction paragraphs (#278), Serbian interface (#250), table Height/Width boxes, AutoFit Contents and Table Properties (#44), AutoSave switch greyed out with the reason where it can't save (.md/.txt/.html, unsaved, browser) (#196, #176), OpenGL fallback on Windows PCs without a DirectX 12 driver (#316), Columns, Symbol and Field dialogs (#321), Tabs and Borders and Shading dialogs (#320); v0.4.0 released |
@ -146,6 +147,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | Progress log: Draw tab ink (#307) |
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) |
| 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) |
| 2026-10-10 | trivial | Progress log: free rotation and flips (#332) |

View File

@ -572,6 +572,7 @@ mod tests {
stroke_width: 0.75,
float: Default::default(),
story,
freeform: None,
effects: Default::default(),
}
}

201
crates/doc/src/freeform.rs Normal file
View File

@ -0,0 +1,201 @@
//! Freeform shapes: the geometry of a shape drawn point by point (DrawingML `a:custGeom`), such as
//! an ink stroke drawn with a pen, pencil or highlighter (Draw tab).
use serde::{Deserialize, Serialize};
/// Most points one path keeps (hostile files and commands): a long pen stroke has a few hundred.
pub const MAX_POINTS: usize = 5_000;
/// Most paths one freeform keeps.
pub const MAX_PATHS: usize = 256;
/// Most ink strokes on one page: drawing more is refused.
pub const MAX_INK_PER_PAGE: usize = 2_000;
/// Largest coordinate kept, in the path's own coordinate space.
const MAX_COORD: f32 = 1.0e6;
/// The pen an ink stroke was drawn with.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum InkTool {
#[default]
Pen,
Pencil,
Highlighter,
}
impl InkTool {
pub fn parse(s: &str) -> Option<InkTool> {
match s.to_ascii_lowercase().as_str() {
"pen" => Some(InkTool::Pen),
"pencil" => Some(InkTool::Pencil),
"highlighter" => Some(InkTool::Highlighter),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
InkTool::Pen => "pen",
InkTool::Pencil => "pencil",
InkTool::Highlighter => "highlighter",
}
}
/// The colour a new stroke gets (black pen, grey pencil, yellow highlighter).
pub fn default_color(self) -> crate::props::Rgb {
match self {
InkTool::Pen => crate::props::Rgb(0, 0, 0),
InkTool::Pencil => crate::props::Rgb(0x59, 0x59, 0x59),
InkTool::Highlighter => crate::props::Rgb(0xFF, 0xFF, 0x00),
}
}
/// The width a new stroke gets, points.
pub fn default_width(self) -> f32 {
match self {
InkTool::Pen => 1.5,
InkTool::Pencil => 1.0,
InkTool::Highlighter => 12.0,
}
}
/// How opaque its strokes are: a highlighter's let the text show through, a pencil's are a
/// little lighter than a pen's.
pub fn alpha(self) -> f32 {
match self {
InkTool::Pen => 1.0,
InkTool::Pencil => 0.85,
InkTool::Highlighter => 0.5,
}
}
}
/// One path of a freeform: points joined by straight lines, closed back to the first or not.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct FreePath {
pub pts: Vec<[f32; 2]>,
pub closed: bool,
}
/// A freeform shape's geometry: paths in a `w` × `h` coordinate space that is stretched over the
/// shape's frame (DrawingML's `a:path` `w` and `h`). Drawn with the shape's outline colour and
/// width (and its fill, for closed paths that aren't ink).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Freeform {
pub w: f32,
pub h: f32,
pub paths: Vec<FreePath>,
/// Opacity of the outline, 0–1.
pub alpha: f32,
/// Set when this is ink: drawn with a pen (Draw tab), hidden by Review › Hide Ink and removed
/// by the eraser.
#[serde(skip_serializing_if = "Option::is_none")]
pub ink: Option<InkTool>,
}
impl Default for Freeform {
fn default() -> Self {
Freeform { w: 0.0, h: 0.0, paths: Vec::new(), alpha: 1.0, ink: None }
}
}
impl Freeform {
/// An ink stroke through `pts` (shape-local points, already in the `w` × `h` frame).
pub fn ink(tool: InkTool, w: f32, h: f32, pts: Vec<[f32; 2]>) -> Freeform {
Freeform { w, h, paths: vec![FreePath { pts, closed: false }], alpha: tool.alpha(), ink: Some(tool) }.sanitized()
}
/// The same geometry with non-finite points dropped, coordinates clamped, the counts capped
/// and the opacity in 0–1: safe to draw whatever file or command it came from.
pub fn sanitized(mut self) -> Freeform {
let fin = |v: f32| if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 };
self.w = fin(self.w).max(0.0);
self.h = fin(self.h).max(0.0);
self.alpha = if self.alpha.is_finite() { self.alpha.clamp(0.0, 1.0) } else { 1.0 };
self.paths.truncate(MAX_PATHS);
for p in &mut self.paths {
p.pts.retain(|[x, y]| x.is_finite() && y.is_finite());
p.pts.truncate(MAX_POINTS);
for pt in &mut p.pts {
*pt = pt.map(fin);
}
}
self.paths.retain(|p| !p.pts.is_empty());
self
}
pub fn is_ink(&self) -> bool {
self.ink.is_some()
}
/// The paths mapped onto a frame at (`x`, `y`) of size `fw` × `fh` (page points). A zero
/// coordinate space takes the frame's own size.
pub fn placed(&self, x: f32, y: f32, fw: f32, fh: f32) -> Vec<(Vec<[f32; 2]>, bool)> {
let sx = if self.w > 0.0 { fw / self.w } else { 1.0 };
let sy = if self.h > 0.0 { fh / self.h } else { 1.0 };
let (sx, sy) = (if sx.is_finite() { sx } else { 1.0 }, if sy.is_finite() { sy } else { 1.0 });
self.paths
.iter()
.take(MAX_PATHS)
.map(|p| (p.pts.iter().take(MAX_POINTS).map(|[px, py]| [x + px * sx, y + py * sy]).collect(), p.closed))
.collect()
}
/// Distance from (`px`, `py`) to the nearest path of this freeform placed on a frame at
/// (`x`, `y`), `fw` × `fh` (page points); `None` when it has no points.
pub fn distance(&self, x: f32, y: f32, fw: f32, fh: f32, px: f32, py: f32) -> Option<f32> {
let mut best: Option<f32> = None;
for (pts, closed) in self.placed(x, y, fw, fh) {
let n = pts.len();
let segs = if closed && n > 2 { n } else { n.saturating_sub(1) };
let mut consider = |a: [f32; 2], b: [f32; 2]| {
let d = seg_distance(a, b, [px, py]);
if d.is_finite() && best.is_none_or(|bd| d < bd) {
best = Some(d);
}
};
if n == 1
&& let Some(a) = pts.first()
{
consider(*a, *a);
}
for i in 0..segs {
if let (Some(a), Some(b)) = (pts.get(i), pts.get((i + 1) % n)) {
consider(*a, *b);
}
}
}
best
}
}
/// Distance from `p` to the segment `a`–`b`.
fn seg_distance(a: [f32; 2], b: [f32; 2], p: [f32; 2]) -> f32 {
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let len2 = dx * dx + dy * dy;
let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 };
let (cx, cy) = (a[0] + t * dx, a[1] + t * dy);
((p[0] - cx).powi(2) + (p[1] - cy).powi(2)).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hostile_geometry_is_sanitized() {
let mut pts = vec![[f32::NAN, 1.0], [2.0, f32::INFINITY], [1.0, 2.0], [1e30, -1e30]];
pts.extend(std::iter::repeat_n([3.0, 3.0], MAX_POINTS * 2));
let f = Freeform { w: f32::NAN, h: -4.0, paths: vec![FreePath { pts, closed: false }], alpha: 7.0, ink: None }.sanitized();
assert_eq!((f.w, f.h, f.alpha), (0.0, 0.0, 1.0));
let p = &f.paths[0].pts;
assert_eq!(p.len(), MAX_POINTS);
assert_eq!(p[0], [1.0, 2.0]);
assert_eq!(p[1], [MAX_COORD, -MAX_COORD]);
}
#[test]
fn distance_to_a_stroke_scales_with_its_frame() {
let f = Freeform::ink(InkTool::Pen, 10.0, 10.0, vec![[0.0, 0.0], [10.0, 0.0]]);
// Frame twice the size at (100, 100): the stroke runs from (100,100) to (120,100).
assert_eq!(f.distance(100.0, 100.0, 20.0, 20.0, 110.0, 103.0), Some(3.0));
assert_eq!(Freeform::default().distance(0.0, 0.0, 1.0, 1.0, 0.0, 0.0), None);
}
}

View File

@ -15,6 +15,7 @@ pub mod edit;
pub mod effects;
pub mod encoding;
pub mod fields;
pub mod freeform;
pub mod graphic;
pub mod math;
pub mod math_edit;

View File

@ -207,6 +207,8 @@ pub enum ShapeKind {
Heart,
/// A text box (rectangle with a text story).
TextBox,
/// A shape drawn point by point, such as ink (its geometry is the shape's `freeform`).
Freeform,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
@ -262,6 +264,9 @@ pub enum InlineObject {
/// Text box content: `Document::parts` id.
#[serde(default)]
story: Option<u32>,
/// A freeform's geometry (`ShapeKind::Freeform`): its paths, or an ink stroke.
#[serde(default, skip_serializing_if = "Option::is_none")]
freeform: Option<std::sync::Arc<crate::freeform::Freeform>>,
/// Shadow, glow and soft edges.
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
effects: crate::effects::ShapeEffects,
@ -455,6 +460,13 @@ impl InlineObject {
_ => false,
}
}
/// The geometry of an ink stroke (a freeform shape drawn with a pen), if this is one.
pub fn ink(&self) -> Option<&crate::freeform::Freeform> {
match self {
InlineObject::Shape { freeform: Some(f), .. } if f.is_ink() => Some(f),
_ => None,
}
}
/// The text this object contributes to plain-text extraction.
pub fn plain_text(&self) -> &str {
match self {

View File

@ -88,6 +88,7 @@ fn text_box_anchor_finds_the_owning_shape() {
stroke_width: 0.75,
float: Default::default(),
story,
freeform: None,
effects: Default::default(),
};
// A plain shape before it doesn't count.
@ -109,6 +110,7 @@ fn word_count_including_notes_counts_used_boxes_and_notes_only() {
stroke_width: 0.75,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
};
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
@ -148,6 +150,7 @@ fn counted_stories_survive_self_nested_boxes() {
stroke_width: 0.0,
float: Default::default(),
story: Some(id),
freeform: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape.clone(), &CharProps::default()).unwrap();
@ -166,6 +169,7 @@ fn prune_text_boxes_drops_only_unshown_ones() {
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
};
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
@ -199,6 +203,7 @@ fn notes_in_text_box_follow_nested_boxes_once() {
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
};
let note = |id| InlineObject::NoteRef { kind: para::NoteKind::Footnote, id, custom: String::new() };

View File

@ -0,0 +1,111 @@
//! Freeform shapes (DrawingML custom geometry, `a:custGeom`) and ink strokes drawn with them.
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool, MAX_PATHS, MAX_POINTS};
use crate::units::int;
use crate::xml::El;
/// Straight pieces a Bézier curve is flattened into.
const CURVE_STEPS: usize = 8;
/// Largest path coordinate kept, in EMUs.
const MAX_EMU: f64 = 1.0e12;
/// The paths of the custom geometry in `sppr` (`wps:spPr`), mapped onto a `w` × `h` shape
/// (points): each `a:path` in its own `w` × `h` space (EMUs of the shape's size when it gives
/// none). Lines and Bézier curves (flattened) are kept; arcs are drawn straight to their end.
/// `None` when there is no `a:custGeom` or it has no points.
pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
let list = sppr.child("a:custGeom")?.child("a:pathLst")?;
let mut paths = Vec::new();
for p in list.children("a:path").take(MAX_PATHS) {
// Subpaths count too: stop once the shape has as many paths as it may keep.
if paths.len() >= MAX_PATHS {
break;
}
let dim = |n: &str, shape: f32| p.attr(n).and_then(int).filter(|v| *v > 0).map_or(shape as f64 * 12_700.0, |v| v as f64);
let (pw, ph) = (dim("w", w), dim("h", h));
let scale = |[x, y]: [f64; 2]| -> [f32; 2] {
let sx = if pw > 0.0 { w as f64 / pw } else { 1.0 / 12_700.0 };
let sy = if ph > 0.0 { h as f64 / ph } else { 1.0 / 12_700.0 };
[(x * sx) as f32, (y * sy) as f32]
};
let pt = |e: &El| -> Option<[f64; 2]> {
let c = |n: &str| e.attr(n).and_then(int).map(|v| (v as f64).clamp(-MAX_EMU, MAX_EMU));
Some([c("x")?, c("y")?])
};
// Each subpath (from an `a:moveTo`) becomes a path of its own.
let mut cur: Vec<[f64; 2]> = Vec::new();
let mut done: Vec<(Vec<[f64; 2]>, bool)> = Vec::new();
let fill_none = p.attr("fill") == Some("none");
for seg in p.els() {
if cur.len() >= MAX_POINTS || done.len() >= MAX_PATHS {
break;
}
let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).collect();
match seg.local() {
"moveTo" => {
if !cur.is_empty() {
done.push((std::mem::take(&mut cur), false));
}
cur.extend(pts.first());
}
"lnTo" => cur.extend(pts.first()),
"cubicBezTo" | "quadBezTo" => {
let Some(&start) = cur.last() else {
cur.extend(pts.last());
continue;
};
let ctrl: Vec<[f64; 2]> = std::iter::once(start).chain(pts.iter().copied()).collect();
for i in 1..=CURVE_STEPS {
cur.push(bezier(&ctrl, i as f64 / CURVE_STEPS as f64));
}
}
"close" if !cur.is_empty() => done.push((std::mem::take(&mut cur), true)),
_ => {}
}
}
if !cur.is_empty() {
done.push((cur, false));
}
for (pts, closed) in done.into_iter().take(MAX_PATHS.saturating_sub(paths.len())) {
paths.push(FreePath { pts: pts.into_iter().take(MAX_POINTS).map(scale).collect(), closed: closed && !fill_none });
}
}
let f = Freeform { w, h, paths, alpha: 1.0, ink: None }.sanitized();
(!f.paths.is_empty()).then_some(f)
}
/// The pen of an ink stroke written by WordCraft, from its drawing's name (`Ink Pen 3`,
/// `Ink Highlighter 4`).
pub fn ink_tool(name: &str) -> Option<InkTool> {
let mut words = name.split_whitespace();
if words.next() != Some("Ink") {
return None;
}
words.next().and_then(InkTool::parse)
}
/// The outline opacity of `ln` (`a:ln`): its colour's `a:alpha`, 0–1.
pub fn line_alpha(ln: Option<&El>) -> f32 {
ln.and_then(|l| l.child("a:solidFill"))
.and_then(|f| f.els().next())
.and_then(|c| c.child("a:alpha"))
.and_then(|a| a.attr("val"))
.and_then(int)
.map_or(1.0, |v| (v.clamp(0, 100_000) as f32) / 100_000.0)
}
/// The point at `t` on the Bézier curve with control points `c` (de Casteljau).
fn bezier(c: &[[f64; 2]], t: f64) -> [f64; 2] {
let mut p: Vec<[f64; 2]> = c.to_vec();
while p.len() > 1 {
p = p
.windows(2)
.filter_map(|w| match w {
[a, b] => Some([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]),
_ => None,
})
.collect();
}
p.first().copied().unwrap_or([0.0, 0.0])
}

View File

@ -4,6 +4,7 @@ mod chart;
mod diagram;
mod drawing_color;
mod embed;
mod freeform;
mod math;
mod props;
mod story;

View File

@ -683,7 +683,19 @@ impl Reader<'_> {
} else if gd.find("a:blip").is_some() {
self.read_pic(gd, rels, w, h, alt, float)?
} else {
self.read_wsp(sc, gd.find("wps:wsp")?, rels, w, h, float)
let wsp = gd.find("wps:wsp")?;
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
// A freeform: its custom geometry, and whether it's ink WordCraft wrote.
if let InlineObject::Shape { kind, freeform, .. } = &mut obj
&& let Some(sppr) = wsp.child("wps:spPr")
&& let Some(mut f) = super::freeform::cust_geom(sppr, w, h)
{
f.alpha = super::freeform::line_alpha(sppr.child("a:ln"));
f.ink = c.child("wp:docPr").and_then(|p| p.attr("name")).and_then(super::freeform::ink_tool);
*kind = ShapeKind::Freeform;
*freeform = Some(Arc::new(f));
}
obj
};
// Word's effect extent also covers a rotated object's overhang; the model keeps that
// apart (it follows from the angle), so take it back out.
@ -855,7 +867,7 @@ impl Reader<'_> {
Some(t) if sc.story_depth < MAX_STORY_DEPTH => Some(self.read_textbox(sc, t, rels)),
_ => None,
};
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, effects }
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, freeform: None, effects }
}
fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 {
@ -894,6 +906,7 @@ impl Reader<'_> {
stroke_width: 0.75,
float,
story,
freeform: None,
effects: Default::default(),
});
}

View File

@ -424,13 +424,14 @@ impl Writer<'_> {
w.close("w:r");
self.rev_close(w, props);
}
InlineObject::Shape { w: sw, h: sh, float, effects, .. } => {
InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, .. } => {
self.rev_open(w, props);
w.open("w:r", &[]);
rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let name = format!("Shape {docpr}");
// Ink is told apart by its name (and its pen), which reading looks for.
let name = shape_name(*kind, freeform.as_deref(), &docpr);
// The effect extent leaves room for the shadow and glow.
let mut float = *float;
for (e, fx) in float.effect.iter_mut().zip(effects.extent()) {
@ -611,10 +612,11 @@ impl Writer<'_> {
/// A shape's or text box's `wps:wsp`, at `off` in its group (or 0, 0). In a group it carries
/// its own `wps:cNvPr` with drawing id `id`.
fn wsp(&mut self, w: &mut W, o: &InlineObject, id: Option<&str>, off: (f32, f32), rels: &mut PartRels, depth: usize) {
let InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, story, effects, float } = o else { return };
let InlineObject::Shape { kind, w: sw, h: sh, fill, stroke, stroke_width, story, effects, float, freeform } = o else { return };
let geom = freeform.as_deref().filter(|_| *kind == ShapeKind::Freeform);
w.open("wps:wsp", &[]);
if let Some(id) = id {
let name = format!("Shape {id}");
let name = shape_name(*kind, freeform.as_deref(), id);
w.empty("wps:cNvPr", &[("id", id), ("name", &name)]);
}
if *kind == ShapeKind::TextBox {
@ -625,7 +627,7 @@ impl Writer<'_> {
w.open("wps:spPr", &[]);
xfrm(w, off, *sw, *sh, float.spin());
let prst = match kind {
ShapeKind::Rectangle | ShapeKind::TextBox => "rect",
ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => "rect",
ShapeKind::RoundedRectangle => "roundRect",
ShapeKind::Ellipse => "ellipse",
ShapeKind::Triangle => "triangle",
@ -635,10 +637,17 @@ impl Writer<'_> {
ShapeKind::Star => "star5",
ShapeKind::Heart => "heart",
};
w.open("a:prstGeom", &[("prst", prst)]);
w.empty("a:avLst", &[]);
w.close("a:prstGeom");
match fill {
match geom {
Some(f) => cust_geom(w, f, *sw, *sh),
None => {
w.open("a:prstGeom", &[("prst", prst)]);
w.empty("a:avLst", &[]);
w.close("a:prstGeom");
}
}
let alpha = geom.map_or(1.0, |f| f.alpha);
let ink = geom.is_some_and(|f| f.is_ink());
match fill.filter(|_| !ink) {
Some(c) => {
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
@ -648,10 +657,21 @@ impl Writer<'_> {
}
match stroke {
Some(c) => {
w.open("a:ln", &[("w", &emu(stroke_width.clamp(0.0, 100.0)))]);
let width = emu(stroke_width.clamp(0.0, 100.0));
let ln: &[(&str, &str)] = if ink { &[("w", width.as_str()), ("cap", "rnd")] } else { &[("w", width.as_str())] };
w.open("a:ln", ln);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
if alpha < 1.0 {
w.open("a:srgbClr", &[("val", &c.hex())]);
w.empty("a:alpha", &[("val", &n((alpha.clamp(0.0, 1.0) * 100_000.0).round() as i64))]);
w.close("a:srgbClr");
} else {
w.empty("a:srgbClr", &[("val", &c.hex())]);
}
w.close("a:solidFill");
if ink {
w.empty("a:round", &[]);
}
w.close("a:ln");
}
None => {
@ -882,6 +902,61 @@ impl Writer<'_> {
}
}
/// A shape's `cNvPr`/`docPr` name: ink is "Ink <pen> <id>", which reading looks for.
fn shape_name(kind: ShapeKind, freeform: Option<&wordcraft_doc::freeform::Freeform>, id: &str) -> String {
match freeform.filter(|_| kind == ShapeKind::Freeform).and_then(|f| f.ink) {
Some(tool) => format!("Ink {} {id}", ink_name(tool)),
None => format!("Shape {id}"),
}
}
fn ink_name(tool: wordcraft_doc::freeform::InkTool) -> &'static str {
match tool {
wordcraft_doc::freeform::InkTool::Pen => "Pen",
wordcraft_doc::freeform::InkTool::Pencil => "Pencil",
wordcraft_doc::freeform::InkTool::Highlighter => "Highlighter",
}
}
/// A freeform's geometry as DrawingML custom geometry (`a:custGeom`): each path in EMUs of its
/// own coordinate space (the shape's size when it has none), from `a:moveTo` along `a:lnTo`.
fn cust_geom(w: &mut W, f: &wordcraft_doc::freeform::Freeform, sw: f32, sh: f32) {
let (pw, ph) = (if f.w > 0.0 { f.w } else { sw.max(0.0) }, if f.h > 0.0 { f.h } else { sh.max(0.0) });
w.open("a:custGeom", &[]);
w.empty("a:avLst", &[]);
w.empty("a:gdLst", &[]);
w.empty("a:ahLst", &[]);
w.empty("a:cxnLst", &[]);
w.empty("a:rect", &[("l", "0"), ("t", "0"), ("r", "r"), ("b", "b")]);
w.open("a:pathLst", &[]);
let pt = |w: &mut W, tag: &str, [x, y]: [f32; 2]| {
w.open(tag, &[]);
w.empty("a:pt", &[("x", &emu(x)), ("y", &emu(y))]);
w.close(tag);
};
for p in f.paths.iter().take(wordcraft_doc::freeform::MAX_PATHS) {
let mut attrs = vec![("w", emu(pw)), ("h", emu(ph))];
if !p.closed || f.is_ink() {
attrs.push(("fill", "none".to_string()));
}
let attrs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.open("a:path", &attrs);
for (i, xy) in p.pts.iter().take(wordcraft_doc::freeform::MAX_POINTS).enumerate() {
pt(w, if i == 0 { "a:moveTo" } else { "a:lnTo" }, *xy);
}
// A tap: a zero-length line, so it shows as a dot.
if let (1, Some(xy)) = (p.pts.len(), p.pts.first()) {
pt(w, "a:lnTo", *xy);
}
if p.closed {
w.empty("a:close", &[]);
}
w.close("a:path");
}
w.close("a:pathLst");
w.close("a:custGeom");
}
/// A shape's `a:effectLst` (ECMA-376 Part 1 §20.1.8.26), children in schema order.
fn effect_list(w: &mut W, effects: &ShapeEffects) {
let fx = effects.sanitized();

View File

@ -689,6 +689,7 @@ fn toc_field_skips_nested_stories() {
stroke_width: 0.0,
float: Float::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
}
};
@ -811,6 +812,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
stroke_width: 1.0,
float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() },
story: Some(story),
freeform: None,
effects: Default::default(),
};
let star = InlineObject::Shape {
@ -822,6 +824,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
stroke_width: 0.0,
float: Float::default(),
story: None,
freeform: None,
effects: Default::default(),
};
let eq = InlineObject::Equation { linear: "x=(-b±√(b^2-4ac))/2a".into(), display: false, math: Default::default() };
@ -880,6 +883,7 @@ fn groups_round_trip() {
stroke_width: 0.0,
float: Float::default(),
story: None,
freeform: None,
// A member's shape effects (#275) come back too.
effects: wordcraft_doc::effects::ShapeEffects {
shadow: Some(wordcraft_doc::effects::Shadow {
@ -903,6 +907,7 @@ fn groups_round_trip() {
stroke_width: 0.75,
float: Float::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
};
let float = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 20.0, y: 10.0, dist: 9.0, ..Default::default() };
@ -1138,6 +1143,7 @@ fn self_showing_text_box_saves_bounded() {
stroke_width: 0.0,
float: Float::default(),
story: Some(id),
freeform: None,
effects: Default::default(),
};
for _ in 0..30 {
@ -1220,6 +1226,54 @@ fn list_level_overrides_round_trip() {
assert_eq!(c.next_label(&back.numbering, restart, 1).unwrap().0, "1.01");
}
/// Ink strokes and freeform shapes are written as DrawingML custom geometry (`a:custGeom`,
/// `a:moveTo`/`a:lnTo`) in floating drawings and read back with their points, pen and opacity.
#[test]
fn ink_and_freeforms_round_trip_as_custom_geometry() {
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool};
let shape = |fill, stroke_width, wrap, f: Freeform| InlineObject::Shape {
kind: ShapeKind::Freeform,
w: f.w,
h: f.h,
fill,
stroke: Some(Rgb(0xC0, 0, 0)),
stroke_width,
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 30.0, y: 12.0, ..Default::default() },
story: None,
freeform: Some(Arc::new(f)),
effects: Default::default(),
};
let pen = shape(None, 2.0, Wrap::InFrontOfText, Freeform::ink(InkTool::Pen, 60.0, 20.0, vec![[1.0, 1.0], [30.0, 19.0], [59.0, 4.0]]));
let marker = shape(None, 12.0, Wrap::BehindText, Freeform::ink(InkTool::Highlighter, 80.0, 12.0, vec![[6.0, 6.0], [74.0, 6.0]]));
let triangle = Freeform {
w: 40.0,
h: 40.0,
paths: vec![FreePath { pts: vec![[0.0, 40.0], [20.0, 0.0], [40.0, 40.0]], closed: true }],
..Default::default()
};
let tri = shape(Some(Rgb(0, 0x80, 0)), 1.0, Wrap::Square, triangle);
let mut p = Paragraph::with_text("Inked", CharProps::default());
for o in [pen.clone(), marker, tri.clone()] {
p.insert_object(0, o, &CharProps::default()).unwrap();
}
let d = doc_with(vec![p]);
let bytes = wordcraft_docx::write(&d).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert_eq!(xml.matches("<a:custGeom>").count(), 3, "{xml}");
assert!(xml.contains(r#"<a:path w="762000" h="254000" fill="none"><a:moveTo><a:pt x="12700" y="12700"/></a:moveTo><a:lnTo>"#), "{xml}");
assert!(xml.contains(r#"<a:alpha val="50000"/>"#) && xml.contains(r#"cap="rnd""#), "{xml}");
let r = rt(&d);
let objs = &paras(&r)[0].objects;
assert_eq!(objs.len(), 3);
// Written in reverse (each inserted at the start): the triangle, the highlighter, the pen.
assert_eq!(objs[0], tri, "a filled closed freeform comes back as it was");
let InlineObject::Shape { freeform: Some(m), float, stroke_width, .. } = &objs[1] else { panic!("{:?}", objs[1]) };
assert_eq!((m.ink, m.alpha, float.wrap, *stroke_width), (Some(InkTool::Highlighter), 0.5, Wrap::BehindText, 12.0));
assert_eq!(objs[2], pen, "the pen stroke comes back point for point");
}
/// Shape effects (#275): `a:effectLst` with an outer shadow, a glow and soft edges round-trips,
/// and the effect extent leaves room for them.
#[test]
@ -1239,6 +1293,7 @@ fn shape_effects_round_trip() {
stroke_width: 0.0,
float: Float::default(),
story: None,
freeform: None,
effects,
};
let mut p = Paragraph::with_text("x", CharProps::default());
@ -1313,6 +1368,7 @@ fn rotation_and_flips_round_trip() {
stroke_width: 0.0,
float,
story: None,
freeform: None,
effects: Default::default(),
};
let square = Float { wrap: Wrap::Square, ..Default::default() };
@ -1367,6 +1423,7 @@ fn rotated_group_and_shadow_rotation_round_trip() {
stroke_width: 0.0,
float: Float { rot: 20.0, ..Default::default() },
story: None,
freeform: None,
effects: ShapeEffects { shadow: Some(Shadow { rot_with_shape, ..Default::default() }), ..Default::default() },
};
let group = InlineObject::Group {

View File

@ -0,0 +1,393 @@
//! Draw tab and Review › Ink: pens, the eraser and ink strokes.
//!
//! An ink stroke is a floating freeform shape ([`Freeform`] with its pen set) anchored to the
//! paragraph under where it starts, in front of the text (a highlighter's behind it). The pen
//! commands only choose what dragging on the page does (session view state); `draw.stroke` and
//! `draw.erase` change the document.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use wordcraft_doc::freeform::{Freeform, InkTool, MAX_INK_PER_PAGE, MAX_POINTS};
use wordcraft_doc::para::{Anchor, Float, InlineObject, OBJ, ShapeKind, Wrap};
use wordcraft_doc::props::Rgb;
use wordcraft_doc::{CharProps, Pos, StoryRef};
use wordcraft_layout::{DocLayout, Placed};
use super::pos_json;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// What dragging on the page does (Draw tab).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum DrawMode {
/// Ordinary selection of text and drawings.
#[default]
Select,
/// Dragging over ink strokes deletes them.
Eraser,
/// Dragging draws ink with this pen.
Pen(InkTool),
}
impl DrawMode {
/// The pen it draws with, if it's one.
pub fn pen(self) -> Option<InkTool> {
match self {
DrawMode::Pen(t) => Some(t),
_ => None,
}
}
}
/// A pen's colour and thickness.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PenSettings {
pub color: Rgb,
/// Points.
pub width: f32,
}
impl PenSettings {
fn of(tool: InkTool) -> PenSettings {
PenSettings { color: tool.default_color(), width: tool.default_width() }
}
}
/// The Draw tab's state: the tool in use and each pen's settings.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct DrawState {
pub mode: DrawMode,
pub pen: PenSettings,
pub pencil: PenSettings,
pub highlighter: PenSettings,
}
impl Default for DrawState {
fn default() -> Self {
DrawState {
mode: DrawMode::Select,
pen: PenSettings::of(InkTool::Pen),
pencil: PenSettings::of(InkTool::Pencil),
highlighter: PenSettings::of(InkTool::Highlighter),
}
}
}
impl DrawState {
pub fn settings(&self, tool: InkTool) -> PenSettings {
match tool {
InkTool::Pen => self.pen,
InkTool::Pencil => self.pencil,
InkTool::Highlighter => self.highlighter,
}
}
fn settings_mut(&mut self, tool: InkTool) -> &mut PenSettings {
match tool {
InkTool::Pen => &mut self.pen,
InkTool::Pencil => &mut self.pencil,
InkTool::Highlighter => &mut self.highlighter,
}
}
}
/// Thinnest and thickest ink, points.
const MIN_WIDTH: f32 = 0.25;
const MAX_WIDTH: f32 = 72.0;
/// How far from a stroke (beyond its half width) the eraser still takes it, points.
const ERASE_SLOP: f32 = 3.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("draw.select", "Select", "Draw › Tools", |s, _| {
s.view.draw.mode = DrawMode::Select;
state(s)
})
.pure(),
CommandSpec::new("draw.eraser", "Eraser", "Draw › Tools", |s, v| {
// With a point, erase there (as a script would); without, pick up the eraser.
if v.get("page").is_some() || v.get("x").is_some() || v.get("y").is_some() {
return s.run("draw.erase", v);
}
s.view.draw.mode = DrawMode::Eraser;
state(s)
})
.params(r#"{"page"?: n, "x"?: pt, "y"?: pt} (no point: use the eraser; a point: delete the ink stroke there)"#)
.pure(),
CommandSpec::new("draw.pen", "Pen", "Draw › Pens", |s, v| pen(s, v, InkTool::Pen)).params(PEN_PARAMS).pure(),
CommandSpec::new("draw.pencil", "Pencil", "Draw › Pens", |s, v| pen(s, v, InkTool::Pencil)).params(PEN_PARAMS).pure(),
CommandSpec::new("draw.highlighter", "Highlighter", "Draw › Pens", |s, v| pen(s, v, InkTool::Highlighter)).params(PEN_PARAMS).pure(),
CommandSpec::new("draw.stroke", "Draw Ink", "Draw › Pens", stroke).params(
r#"{"page": n, "points": [[x, y], …] (page points, at most 5000), "tool"?: "pen|pencil|highlighter", "color"?: "RRGGBB", "width"?: pt}"#,
),
CommandSpec::new("draw.erase", "Erase Ink", "Draw › Tools", erase).params(r#"{"page": n, "x": pt, "y": pt}"#),
CommandSpec::new("review.hideInk", "Hide Ink", "Review › Ink", |s, v| {
s.view.hide_ink = p::bool(v, "value").unwrap_or(!s.view.hide_ink);
Ok(json!({"value": s.view.hide_ink}))
})
.params(r#"{"value"?: bool}"#)
.pure(),
]
}
const PEN_PARAMS: &str = r#"{"color"?: "RRGGBB", "width"?: pt}"#;
fn state(s: &Session) -> CmdResult {
serde_json::to_value(&s.view.draw).map_err(|e| CmdError::Failed(e.to_string()))
}
/// Pick up a pen, optionally changing its colour and thickness.
fn pen(s: &mut Session, v: &Value, tool: InkTool) -> CmdResult {
let color = p::str(v, "color").map(|c| Rgb::parse(c).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))).transpose()?;
let set = s.view.draw.settings_mut(tool);
if let Some(c) = color {
set.color = c;
}
if let Some(w) = p::f32(v, "width") {
set.width = w.clamp(MIN_WIDTH, MAX_WIDTH);
}
s.view.draw.mode = DrawMode::Pen(tool);
state(s)
}
/// Ink strokes laid out on `page`: their position and geometry on the page.
fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<(Pos, wordcraft_geom::Rect, Arc<Freeform>, f32)> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
pg.items
.iter()
.filter_map(|it| match it {
Placed::Object { rect, story, path, off, .. } => {
let pos = Pos { story: *story, path: path.clone(), off: *off };
match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) {
Some(InlineObject::Shape { freeform: Some(f), stroke_width, .. }) if f.is_ink() => Some((pos, *rect, f.clone(), *stroke_width)),
_ => None,
}
}
_ => None,
})
.collect()
}
/// The ink stroke on `page` under (`x`, `y`), topmost first; none while ink is hidden.
pub fn ink_at(s: &mut Session, page: usize, x: f32, y: f32) -> Option<Pos> {
if s.view.hide_ink || !x.is_finite() || !y.is_finite() {
return None;
}
let layout = s.layout();
ink_on(s, &layout, page).into_iter().rev().find_map(|(pos, r, f, sw)| {
let reach = (if sw.is_finite() { sw.clamp(0.0, MAX_WIDTH) } else { 1.0 }) / 2.0 + ERASE_SLOP;
f.distance(r.x, r.y, r.w, r.h, x, y).filter(|d| *d <= reach).map(|_| pos)
})
}
fn page_param(s: &mut Session, v: &Value) -> Result<(usize, Arc<DocLayout>), CmdError> {
let page = p::u64(v, "page").ok_or_else(|| CmdError::Params("`page` (0-based) is required".into()))?;
let layout = s.layout();
let page = usize::try_from(page).ok().filter(|i| *i < layout.pages.len()).ok_or_else(|| CmdError::Params(format!("no page {page}")))?;
Ok((page, layout))
}
/// Delete the ink stroke under a point.
fn erase(s: &mut Session, v: &Value) -> CmdResult {
let (page, _) = page_param(s, v)?;
let (x, y) = (p::req_f32(v, "x")?, p::req_f32(v, "y")?);
let pos = ink_at(s, page, x, y).ok_or_else(|| CmdError::Failed("no ink stroke there".into()))?;
let len = OBJ.len_utf8();
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
for at in [&mut s.sel.anchor, &mut s.sel.focus] {
if at.story == pos.story && at.path == pos.path && at.off > pos.off {
at.off = at.off.saturating_sub(len).max(pos.off);
}
}
Ok(json!({"erased": pos_json(&pos)}))
}
/// Draw an ink stroke through page points.
fn stroke(s: &mut Session, v: &Value) -> CmdResult {
let (page, layout) = page_param(s, v)?;
let tool = match p::str(v, "tool") {
Some(t) => InkTool::parse(t).ok_or_else(|| CmdError::Params("`tool` is pen, pencil or highlighter".into()))?,
None => s.view.draw.mode.pen().unwrap_or(InkTool::Pen),
};
let set = s.view.draw.settings(tool);
let color = match p::str(v, "color") {
Some(c) => Rgb::parse(c).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))?,
None => set.color,
};
let width = p::f32(v, "width").unwrap_or(set.width).clamp(MIN_WIDTH, MAX_WIDTH);
let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?;
let (pw, ph) = (pg.w.max(1.0), pg.h.clamp(1.0, 1e5));
// Points on the page; NaN, infinities and malformed entries are dropped.
let pts: Vec<[f32; 2]> = v
.get("points")
.and_then(Value::as_array)
.ok_or_else(|| CmdError::Params("`points` ([[x, y], …]) is required".into()))?
.iter()
.take(MAX_POINTS)
.filter_map(|pt| {
let a = pt.as_array()?;
let (x, y) = (a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32);
(x.is_finite() && y.is_finite()).then(|| [x.clamp(0.0, pw), y.clamp(0.0, ph)])
})
.collect();
let first = *pts.first().ok_or_else(|| CmdError::Params("`points` has no usable point".into()))?;
if ink_on(s, &layout, page).len() >= MAX_INK_PER_PAGE {
return Err(CmdError::Failed(format!("page {page} already has {MAX_INK_PER_PAGE} ink strokes")));
}
// The frame: the points' bounds, grown by half the width so the stroke's ends fit.
let pad = width / 2.0;
let (mut x0, mut y0, mut x1, mut y1) = (first[0], first[1], first[0], first[1]);
for [x, y] in &pts {
(x0, y0, x1, y1) = (x0.min(*x), y0.min(*y), x1.max(*x), y1.max(*y));
}
let (fx, fy) = (x0 - pad, y0 - pad);
let (fw, fh) = ((x1 - x0 + width).max(1.0), (y1 - y0 + width).max(1.0));
let local: Vec<[f32; 2]> = pts.iter().map(|[x, y]| [x - fx, y - fy]).collect();
let wrap = if tool == InkTool::Highlighter { Wrap::BehindText } else { Wrap::InFrontOfText };
let obj = InlineObject::Shape {
kind: ShapeKind::Freeform,
w: fw,
h: fh,
fill: None,
stroke: Some(color),
stroke_width: width,
float: Float { wrap, ..Default::default() },
story: None,
freeform: Some(Arc::new(Freeform::ink(tool, fw, fh, local))),
effects: Default::default(),
};
let at = place(s, &layout, obj, page, (fx, fy), first[1])?;
Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh]}))
}
/// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph
/// under `anchor_y` (positioned from its column and top), or, where no paragraph starts on the
/// page, to the nearest line and positioned on the page. Returns where its character went.
fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x, y): (f32, f32), anchor_y: f32) -> Result<Pos, CmdError> {
let (at, on_page) = match super::objects::anchor_paragraph(layout, page, anchor_y) {
Some(path) => (Pos { story: StoryRef::Body, path, off: 0 }, false),
None => {
(super::objects::nearest_line(layout, page, anchor_y).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true)
}
};
s.doc.insert_object(&at, obj, &CharProps::default())?;
let len = OBJ.len_utf8();
for p in [&mut s.sel.anchor, &mut s.sel.focus] {
if p.story == at.story && p.path == at.path && p.off > at.off {
p.off += len;
}
}
let (rel, origin) = if on_page {
(Anchor::Page, wordcraft_geom::Point::new(0.0, 0.0))
} else {
s.touch(); // lay out the new object to find where its paragraph puts it
let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the stroke isn't laid out".into()))?.origin;
(Anchor::Paragraph, origin)
};
let para = s.doc.para_mut(at.story, &at.path)?;
if let Some(InlineObject::Shape { float, .. }) = para.object_at_mut(at.off) {
float.h_rel = if on_page { Anchor::Page } else { Anchor::Column };
float.v_rel = rel;
float.x = x - origin.x;
float.y = y - origin.y;
}
para.touch();
Ok(at)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use wordcraft_doc::StoryRef;
use wordcraft_doc::freeform::InkTool;
use wordcraft_doc::para::{InlineObject, Wrap};
use crate::Session;
fn session() -> Session {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "First paragraph of text.\nSecond paragraph of text."})).unwrap();
s
}
fn ink(s: &Session) -> Vec<InlineObject> {
s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).filter(|o| o.ink().is_some()).cloned().collect()
}
#[test]
fn a_stroke_inserts_floating_ink_through_its_points_in_one_undo_step() {
let mut s = session();
let text = s.doc.plain_text(StoryRef::Body);
let r = s
.run("draw.stroke", &json!({"page": 0, "points": [[100, 90], [150, 120], [f64::NAN, 3], [200, 95]], "color": "C00000", "width": 2}))
.unwrap();
let [x, y] = [r["rect"][0].as_f64().unwrap() as f32, r["rect"][1].as_f64().unwrap() as f32];
assert_eq!((x, y), (99.0, 89.0), "the points' bounds, grown by half the width");
let objs = ink(&s);
let [InlineObject::Shape { stroke, stroke_width, float, freeform: Some(f), .. }] = objs.as_slice() else { panic!("one stroke: {objs:?}") };
assert_eq!((*stroke, *stroke_width, float.wrap), (Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, Wrap::InFrontOfText));
assert_eq!(f.ink, Some(InkTool::Pen));
assert_eq!(f.paths[0].pts, vec![[1.0, 1.0], [51.0, 31.0], [101.0, 6.0]], "NaN dropped, shape-local");
// Laid out where it was drawn.
let at = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 };
let hit = s.layout().object(&at, 0).unwrap();
assert!((hit.rect.x - 99.0).abs() < 0.01 && (hit.rect.y - 89.0).abs() < 0.01, "{:?}", hit.rect);
s.run("edit.undo", &json!({})).unwrap();
assert!(ink(&s).is_empty());
assert_eq!(s.doc.plain_text(StoryRef::Body), text);
// A highlighter goes behind the text, translucent.
s.run("draw.highlighter", &json!({"color": "00FF00"})).unwrap();
s.run("draw.stroke", &json!({"page": 0, "points": [[80, 80]]})).unwrap();
let objs = ink(&s);
let [InlineObject::Shape { float, freeform: Some(f), stroke, .. }] = objs.as_slice() else { panic!() };
assert_eq!((float.wrap, f.ink, *stroke), (Wrap::BehindText, Some(InkTool::Highlighter), Some(wordcraft_doc::Rgb(0, 0xFF, 0))));
assert!(f.alpha < 1.0);
// Hostile input is refused, not drawn.
assert!(s.run("draw.stroke", &json!({"page": 99, "points": [[1, 1]]})).is_err());
assert!(s.run("draw.stroke", &json!({"page": 0, "points": [["x", 1], [1]]})).is_err());
assert!(s.run("draw.stroke", &json!({"page": 0, "points": [[1, 1]], "tool": "crayon"})).is_err());
}
#[test]
fn the_eraser_removes_the_stroke_under_the_point_and_hide_ink_hides_it() {
let mut s = session();
s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap();
assert!(s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 130})).is_err(), "nothing there");
assert_eq!(ink(&s).len(), 1);
// Review › Hide Ink: not drawn, not erasable, still in the document.
assert_eq!(s.run("review.hideInk", &json!({})).unwrap()["value"], true);
assert!(s.view.hide_ink);
assert!(s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).is_err());
s.run("review.hideInk", &json!({"value": false})).unwrap();
s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).unwrap();
assert!(ink(&s).is_empty());
// Without a point it picks up the eraser; Select puts it down.
s.run("draw.eraser", &json!({})).unwrap();
assert_eq!(s.view.draw.mode, super::DrawMode::Eraser);
s.run("draw.pencil", &json!({"width": 1e9})).unwrap();
assert_eq!((s.view.draw.mode, s.view.draw.pencil.width), (super::DrawMode::Pen(InkTool::Pencil), super::MAX_WIDTH));
s.run("draw.select", &json!({})).unwrap();
assert_eq!(s.view.draw.mode, super::DrawMode::Select);
}
/// `draw.eraser` is a view command, but with a point it erases through `draw.erase`, which
/// changes the document: that must be one undo step and mark the document changed.
#[test]
fn erasing_through_the_eraser_command_is_undoable_and_marks_the_document_dirty() {
let mut s = session();
s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap();
s.dirty = false;
let steps = s.undo_depth();
s.run("draw.eraser", &json!({"page": 0, "x": 150, "y": 101})).unwrap();
assert!(ink(&s).is_empty());
assert!(s.dirty, "erasing is an unsaved change");
assert_eq!(s.undo_depth(), steps + 1, "one undo step");
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(ink(&s).len(), 1, "undo brings the stroke back");
s.run("edit.redo", &json!({})).unwrap();
assert!(ink(&s).is_empty());
}
}

View File

@ -263,6 +263,7 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult {
stroke_width: 1.0,
float: Float { wrap: Wrap::Inline, ..Default::default() },
story: None,
freeform: None,
effects: Default::default(),
};
let end = s.doc.insert_object(&at, obj, &props)?;
@ -286,6 +287,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult {
stroke_width: 0.75,
float: Float::default(),
story: Some(id),
freeform: None,
effects: Default::default(),
};
s.doc.insert_object(&at, obj, &props)?;

View File

@ -4,6 +4,7 @@ pub mod caret;
pub mod citations;
pub mod column;
pub mod design;
pub mod draw;
pub mod edit;
pub mod equation;
pub mod file;
@ -58,6 +59,7 @@ pub fn registry() -> Registry {
v.extend(mailings::specs());
v.extend(citations::specs());
v.extend(objects::specs());
v.extend(draw::specs());
v.extend(tools::specs());
v.extend(speech::specs());
Registry::new(v)

View File

@ -604,7 +604,7 @@ fn unalign(s: &mut Session) -> Result<(), CmdError> {
/// The top-level body paragraph under `y` on `page`: the last one starting at or above it (else
/// the first starting on the page).
fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option<wordcraft_doc::Path> {
pub(crate) fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option<wordcraft_doc::Path> {
let starts: Vec<(&wordcraft_doc::Path, f32)> = layout
.pages
.get(page)?
@ -619,7 +619,7 @@ fn anchor_paragraph(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -
}
/// The start of the top-level body line on `page` nearest to `y`.
fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option<Pos> {
pub(crate) fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Option<Pos> {
let lines = wordcraft_layout::hit::page_lines(layout.pages.get(page)?, StoryRef::Body);
let best = lines.iter().filter(|l| l.path.depth() == 0).min_by(|a, b| {
let d = |l: &wordcraft_layout::hit::LineHit| if y < l.top { l.top - y } else { (y - l.bottom).max(0.0) };

View File

@ -77,6 +77,12 @@ pub struct ViewState {
pub track_changes_pane: bool,
/// Check spelling and grammar as you type.
pub proofing: bool,
/// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document).
#[serde(default)]
pub hide_ink: bool,
/// Draw tab: the tool dragging on the page uses, and each pen's colour and thickness.
#[serde(default)]
pub draw: crate::cmd::draw::DrawState,
}
fn on() -> bool {
@ -106,6 +112,8 @@ impl Default for ViewState {
show_markup: true,
track_changes_pane: false,
proofing: true,
hide_ink: false,
draw: Default::default(),
}
}
}

View File

@ -69,6 +69,14 @@ pub enum Draw {
/// Shadow, glow and soft edges (drawn with [`wordcraft_doc::effects::bands`]).
effects: wordcraft_doc::effects::ShapeEffects,
},
/// An ink stroke: a line through `pts` (page points) with round ends and joins, `width`
/// wide, in `color` at opacity `alpha`.
Ink {
pts: Vec<(f32, f32)>,
color: Rgb,
width: f32,
alpha: f32,
},
/// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked.
Path {
segs: Vec<PathSeg>,
@ -140,13 +148,23 @@ pub struct DisplayOptions {
pub markup: bool,
/// Show on-screen-only marks: equation placeholders and prompts (never in print or PDF).
pub placeholders: bool,
/// Review › Hide Ink: leave ink strokes out (they stay in the document).
pub hide_ink: bool,
/// How tracked changes and comments are marked when `markup` is on (Track Changes Options).
pub revisions: MarkupOptions,
}
impl Default for DisplayOptions {
fn default() -> Self {
DisplayOptions { marks: false, dim_header: true, dim_body: false, markup: true, placeholders: false, revisions: MarkupOptions::default() }
DisplayOptions {
marks: false,
dim_header: true,
dim_body: false,
markup: true,
placeholders: false,
hide_ink: false,
revisions: MarkupOptions::default(),
}
}
}
@ -383,19 +401,11 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu
Placed::Image { rect, media, crop, spin, .. } => {
spun(*spin, *rect, vec![Draw::Image { rect: *rect, media: media.clone(), crop: *crop, alpha }], out)
}
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, spin } => spun(
*spin,
*rect,
vec![Draw::Shape {
rect: *rect,
kind: *kind,
fill: *fill,
stroke: *stroke,
stroke_width: *stroke_width,
effects: effects_in(*effects, *spin),
}],
out,
),
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin } => {
let mut items = Vec::new();
shape_draws(*rect, *kind, *fill, *stroke, *stroke_width, effects_in(*effects, *spin), freeform.as_deref(), opts, &mut items);
spun(*spin, *rect, items, out)
}
Placed::Graphic { rect, graphic, spin, .. } => spun(*spin, *rect, graphic_draws(doc, graphic, *rect, alpha), out),
Placed::Cell { .. } | Placed::Object { .. } => {}
Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => {
@ -778,7 +788,7 @@ fn lines(
let spin = obj.and_then(InlineObject::frame).map(|(_, _, f)| f.spin()).unwrap_or_default();
match obj {
Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) => {
object_draws(o, rect, Spin::default(), alpha, out)
object_draws(o, rect, Spin::default(), alpha, opts, out)
}
Some(InlineObject::Graphic { graphic, alt, .. }) => {
let mut draws = Vec::new();
@ -1027,6 +1037,50 @@ fn page_segs(segs: &[PathSeg], map: impl Fn(f32, f32) -> (f32, f32)) -> Vec<Path
}
/// A path of clean segments (see [`page_segs`]) as a kurbo path, for the rasteriser and PDF.
/// The draws of a shape at `rect`: a preset outline, or a freeform's paths (an ink stroke left out
/// under Review › Hide Ink).
#[allow(clippy::too_many_arguments)]
fn shape_draws(
rect: Rect,
kind: ShapeKind,
fill: Option<Rgb>,
stroke: Option<Rgb>,
stroke_width: f32,
effects: wordcraft_doc::effects::ShapeEffects,
freeform: Option<&wordcraft_doc::freeform::Freeform>,
opts: &DisplayOptions,
out: &mut Vec<Draw>,
) {
let Some(f) = freeform.filter(|_| kind == ShapeKind::Freeform) else {
out.push(Draw::Shape { rect, kind, fill, stroke, stroke_width, effects });
return;
};
if f.is_ink() && opts.hide_ink {
return;
}
let width = if stroke_width.is_finite() { stroke_width.clamp(0.0, 200.0) } else { 0.75 };
for (pts, closed) in f.placed(rect.x, rect.y, rect.w, rect.h) {
if f.is_ink() || (!closed && fill.is_none()) {
if let Some(color) = stroke {
let mut pts: Vec<(f32, f32)> = pts.iter().map(|[x, y]| (*x, *y)).collect();
if closed && let Some(first) = pts.first().copied() {
pts.push(first);
}
out.push(Draw::Ink { pts, color, width: width.max(0.25), alpha: f.alpha });
}
continue;
}
let mut segs: Vec<PathSeg> = Vec::with_capacity(pts.len() + 1);
for (i, [x, y]) in pts.iter().enumerate() {
segs.push(if i == 0 { PathSeg::Move(*x, *y) } else { PathSeg::Line(*x, *y) });
}
if closed {
segs.push(PathSeg::Close);
}
out.push(Draw::Path { segs, fill: if closed { fill } else { None }, stroke, stroke_width: width });
}
}
pub fn seg_path(segs: &[PathSeg]) -> BezPath {
let mut p = BezPath::new();
for s in segs {
@ -1122,18 +1176,20 @@ fn spun(spin: Spin, rect: Rect, items: Vec<Draw>, out: &mut Vec<Draw>) {
/// A picture, shape or group drawn in `rect` (its unturned frame), turned by its own spin about
/// the rect's centre; a group's members are turned inside it. `outer` is the spin it is already
/// drawn inside (its group's). Nothing for anything else.
fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, out: &mut Vec<Draw>) {
fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &DisplayOptions, out: &mut Vec<Draw>) {
let own = o.frame().map(|(_, _, f)| f.spin()).unwrap_or_default();
match o {
InlineObject::Image { media, crop, .. } => spun(own, rect, vec![Draw::Image { rect, media: media.clone(), crop: *crop, alpha }], out),
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => {
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, .. } => {
let effects = effects_in(*effects, own.within(outer));
spun(own, rect, vec![Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects }], out)
let mut items = Vec::new();
shape_draws(rect, *kind, *fill, *stroke, *stroke_width, effects, freeform.as_deref(), opts, &mut items);
spun(own, rect, items, out)
}
InlineObject::Group { .. } => {
let mut members = Vec::new();
for ([x, y, w, h], c) in o.group_rects(rect.x, rect.y, rect.w, rect.h) {
object_draws(c, Rect::new(x, y, w, h), own.within(outer), alpha, &mut members);
object_draws(c, Rect::new(x, y, w, h), own.within(outer), alpha, opts, &mut members);
}
spun(own, rect, members, out)
}

View File

@ -99,6 +99,8 @@ pub enum Placed {
fill: Option<Rgb>,
stroke: Option<Rgb>,
stroke_width: f32,
/// A freeform's paths (ink strokes among them).
freeform: Option<Arc<wordcraft_doc::freeform::Freeform>>,
effects: wordcraft_doc::effects::ShapeEffects,
spin: Spin,
},
@ -1410,8 +1412,17 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR
InlineObject::Image { media, crop, .. } => {
vec![Placed::Image { rect, media: media.clone(), crop: *crop, story, path: Path(path.to_vec()), off, spin }]
}
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, .. } => {
vec![Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width, effects: *effects, spin }]
InlineObject::Shape { kind, fill, stroke, stroke_width, effects, freeform, .. } => {
vec![Placed::Shape {
rect,
kind: *kind,
fill: *fill,
stroke: *stroke,
stroke_width: *stroke_width,
effects: *effects,
freeform: freeform.clone(),
spin,
}]
}
InlineObject::Graphic { graphic, .. } => {
vec![Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off, spin }]

View File

@ -232,6 +232,7 @@ fn many_hidden_float_anchors_lay_out_in_linear_time() {
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
};
let obj = wordcraft_doc::para::OBJ.to_string();
@ -1048,6 +1049,7 @@ fn text_wraps_around_square_float() {
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
@ -1086,6 +1088,7 @@ fn deleted_float_leaves_no_wrap_area_without_markup() {
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
@ -1138,6 +1141,7 @@ fn hidden_float_leaves_no_wrap_area_when_hidden_text_is_not_shown() {
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
@ -1175,6 +1179,7 @@ fn line_numbers_borders_text_boxes() {
stroke_width: 1.0,
float: Default::default(),
story: Some(id),
freeform: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(2, 5), tb, &Default::default()).unwrap();
@ -1469,6 +1474,7 @@ fn text_box_at(d: &mut Document, pos: &Pos, text: &str, w: f32, h: f32, float: w
stroke_width: 0.75,
float,
story: Some(id),
freeform: None,
effects: Default::default(),
};
d.insert_object(pos, shape, &Default::default()).unwrap();
@ -1540,6 +1546,7 @@ fn presses_grab_pictures_anywhere_and_text_boxes_by_their_border() {
stroke_width: 1.0,
float: Default::default(),
story: None,
freeform: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
@ -1760,6 +1767,7 @@ fn box_fan_out(levels: usize, fan: usize) -> Document {
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
};
for (k, id) in ids.iter().enumerate() {
@ -1833,6 +1841,7 @@ fn picture(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject {
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
}
}
@ -1981,6 +1990,7 @@ fn rect_shape(w: f32, h: f32, float: wordcraft_doc::para::Float) -> InlineObject
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
}
}
@ -2150,6 +2160,7 @@ fn floating_header_text_box_leaves_the_body_at_the_top_margin() {
stroke_width: 0.75,
float,
story: Some(story),
freeform: None,
effects: Default::default(),
};
anchor.insert_object(0, tb, &Default::default()).unwrap();
@ -2759,6 +2770,7 @@ fn rotated_float_wraps_around_its_rotated_bounds() {
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
};
d.insert_object(&Pos::body(0, 0), shape, &Default::default()).unwrap();
@ -2802,6 +2814,7 @@ fn rotated_group_turns_its_members_and_their_shadows() {
stroke_width: 0.0,
float: Float { rot, ..Default::default() },
story: None,
freeform: None,
effects: ShapeEffects { shadow: Some(Shadow { angle: 0.0, rot_with_shape, ..Default::default() }), ..Default::default() },
};
let float =

View File

@ -389,7 +389,8 @@ fn shape_path(kind: ShapeKind, r: &Rect) -> Option<Path> {
pb.close();
};
match kind {
ShapeKind::Rectangle | ShapeKind::TextBox => poly(&mut pb, &[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]),
// A freeform is drawn from its own paths; without them, its frame.
ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => poly(&mut pb, &[(x0, y0), (x1, y0), (x1, y1), (x0, y1)]),
ShapeKind::RoundedRectangle => {
let rad = r.w.min(r.h) * 0.16;
let k = rad * (1.0 - K);
@ -755,6 +756,36 @@ impl Exporter<'_> {
s.set_fill(None);
s.set_stroke(None);
}
Draw::Ink { pts, color, width, alpha } => {
let mut pb = PathBuilder::new();
let mut used = 0usize;
let mut last = (0.0, 0.0);
for &(x, y) in pts.iter().filter(|(x, y)| ok(*x) && ok(*y)) {
if used > 0 {
pb.line_to(x, y);
} else {
pb.move_to(x, y);
}
used += 1;
last = (x, y);
}
// A tap (one usable point): a zero-length line, which round caps draw as a dot.
if used == 1 {
pb.line_to(last.0, last.1);
}
let Some(p) = pb.finish() else { return };
s.set_fill(None);
s.set_stroke(Some(Stroke {
paint: rgb::Color::new(color.0, color.1, color.2).into(),
width: if width.is_finite() { width.clamp(0.25, 200.0) } else { 1.0 },
opacity: norm(*alpha),
line_cap: LineCap::Round,
line_join: LineJoin::Round,
..Default::default()
}));
s.draw_path(&p);
s.set_stroke(None);
}
Draw::Shape { rect, kind, fill: f, stroke, stroke_width, effects } => {
let Some(p) = shape_path(*kind, rect) else { return };
let can_fill = *kind != ShapeKind::Line;

View File

@ -639,6 +639,17 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
ctx.stroke_path(&path);
}
}
Draw::Ink { pts, color: c, width, alpha } => {
let Some(path) = ink_path(pts) else { return };
if !path.bounding_box().inflate(*width as f64, *width as f64).overlaps(*visible) {
return;
}
ctx.set_transform(view);
ctx.set_paint(color(opts.ink(*c), *alpha));
let w = if width.is_finite() { width.clamp(0.25, 200.0) } else { 1.0 };
ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
Draw::Shape { rect, kind, fill, stroke, stroke_width, effects } => {
let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64);
let path = shape_path(*kind, r);
@ -714,6 +725,28 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
}
}
/// The line through an ink stroke's points (finite ones only); a single point is a dot.
fn ink_path(pts: &[(f32, f32)]) -> Option<BezPath> {
let mut p = BezPath::new();
let mut last = None;
for &(x, y) in pts.iter().filter(|(x, y)| x.is_finite() && y.is_finite() && x.abs() < 1e6 && y.abs() < 1e6) {
let pt = (x as f64, y as f64);
if last.is_none() {
p.move_to(pt);
} else {
p.line_to(pt);
}
last = Some(pt);
}
// Only sanitized points reach the path: a tap is drawn at the one usable point.
let only = last?;
if p.elements().len() == 1 {
// A tap: a zero-length line, which round caps draw as a dot.
p.line_to(only);
}
Some(p)
}
/// Outline of a basic shape in a rectangle.
pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath {
let (cx, cy) = (r.center().x, r.center().y);
@ -730,7 +763,8 @@ pub fn shape_path(kind: ShapeKind, r: kurbo::Rect) -> BezPath {
p
};
match kind {
ShapeKind::Rectangle | ShapeKind::TextBox => r.to_path(0.1),
// A freeform is drawn from its own paths; without them, its frame.
ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => r.to_path(0.1),
ShapeKind::RoundedRectangle => kurbo::RoundedRect::from_rect(r, r.width().min(r.height()) * 0.16).to_path(0.1),
ShapeKind::Ellipse => kurbo::Ellipse::from_rect(r).to_path(0.1),
ShapeKind::Triangle => poly(&[(cx, r.y0), (r.x1, r.y1), (r.x0, r.y1)]),
@ -842,6 +876,7 @@ mod tests {
stroke_width: 0.0,
float: Default::default(),
story: None,
freeform: None,
effects,
};
let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 };

View File

@ -46,6 +46,10 @@ pub struct CanvasState {
pub mini_anchor: Option<Rect>,
/// A picture, shape or text box being dragged by its frame.
pub(crate) obj_drag: Option<crate::objects::ObjectDrag>,
/// An ink stroke being drawn with a pen (Draw tab).
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>,
/// Wheel/touchpad scrolling (smooth notches, touchpad momentum).
pub(crate) wheel: crate::scroll::CanvasScroll,
/// The scroll offset and its maximum at the end of last frame.
@ -100,6 +104,8 @@ impl Default for CanvasState {
context_issue: None,
context_synonyms: None,
obj_drag: None,
ink: None,
erasing: None,
context_menu_open: false,
mini_anchor: None,
pasted: false,
@ -249,7 +255,7 @@ fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
scale_px.to_bits().hash(&mut h);
let v = &app.session.view;
(v.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER).hash(&mut h);
(v.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink).hash(&mut h);
app.session.prefs.markup.hash(&mut h);
dim_body.hash(&mut h);
format!("{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.watermark).hash(&mut h);
@ -264,8 +270,9 @@ fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 {
Placed::Fill { rect, color } => format!("{rect:?}{color:?}").hash(&mut h),
Placed::Rule { x0, y0, x1, y1, border } => format!("{x0}{y0}{x1}{y1}{border:?}").hash(&mut h),
Placed::Image { rect, media, spin, .. } => format!("{rect:?}{media}{spin:?}").hash(&mut h),
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, spin } => {
format!("{rect:?}{kind:?}{fill:?}{stroke:?}{stroke_width}{effects:?}{spin:?}").hash(&mut h)
Placed::Shape { rect, kind, fill, stroke, stroke_width, effects, freeform, spin } => {
format!("{rect:?}{kind:?}{fill:?}{stroke:?}{stroke_width}{effects:?}{spin:?}").hash(&mut h);
freeform.as_ref().map(|f| std::sync::Arc::as_ptr(f) as usize).hash(&mut h);
}
Placed::Graphic { rect, graphic, spin, .. } => {
(std::sync::Arc::as_ptr(graphic) as usize, rect.x.to_bits(), rect.y.to_bits(), rect.w.to_bits(), rect.h.to_bits()).hash(&mut h);
@ -289,7 +296,7 @@ fn texel_aligned_rect(layout: Rect, texture_px: egui::Vec2, ppp: f32) -> Rect {
Rect::from_min_size(pos2(snap(layout.min.x), snap(layout.min.y)), texture_px / ppp)
}
fn page_screen_scale(rect: Rect, page: &Page, fallback: f32) -> f32 {
pub(crate) fn page_screen_scale(rect: Rect, page: &Page, fallback: f32) -> f32 {
if page.w > 0.0 { rect.width() / page.w } else { fallback }
}
@ -441,6 +448,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
opts.display.marks = app.session.view.marks;
opts.display.placeholders = true;
opts.display.markup = app.session.view.show_markup;
opts.display.hide_ink = app.session.view.hide_ink;
opts.display.revisions = app.session.prefs.markup.clone();
opts.dark = dark_page;
opts.dark_paper = dark_paper;
@ -639,6 +647,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
crate::objects::paint(app, &painter, &t, &layout, &rects, geo.scale);
crate::ink::paint(app, &painter, &rects, &layout, geo.scale);
crate::table_pen::paint(app, &painter, &t, &layout, &rects, geo.scale);
(resp, rects)
});
@ -698,7 +707,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
// Stand down while the context menu owns the pointer so the two never double up;
// `context_menu_opened` also covers the click that dismisses the menu.
app.canvas.context_menu_open = resp.context_menu_opened();
if resp.hovered() || app.canvas.obj_drag.is_some() {
if let Some(c) = crate::ink::cursor(app).filter(|_| resp.hovered() || app.canvas.ink.is_some()) {
ui.ctx().set_cursor_icon(c);
} else if resp.hovered() || app.canvas.obj_drag.is_some() {
let over_object = ui.input(|i| i.pointer.latest_pos()).and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p));
ui.ctx().set_cursor_icon(over_object.unwrap_or(egui::CursorIcon::Text));
}
@ -982,6 +993,10 @@ pub fn nearest_page(rects: &[Rect], p: Pos2) -> Option<usize> {
}
fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) {
// A pen or the eraser (Draw tab) owns the pointer.
if crate::ink::pointer(app, ui, resp, rects, layout, scale) {
return;
}
let pointer = resp.interact_pointer_pos().or_else(|| resp.hover_pos());
// An object drag follows the pointer anywhere until it's released.
let object_pointer = pointer.or_else(|| app.canvas.obj_drag.as_ref().and_then(|_| ui.input(|i| i.pointer.latest_pos())));

View File

@ -1129,6 +1129,12 @@
Leave the equation (Esc) Salir de la ecuación (Esc)
Preview Vista previa
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Draw Ink Dibujar tinta
Erase Ink Borrar tinta
Hide Ink Ocultar tinta
Hide\nInk Ocultar\ntinta
Pens Plumas
Thickness Grosor
# Columns, Symbol and Field dialogs (#321).
(All) (Todos)

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

View File

@ -1113,6 +1113,12 @@
Leave the equation (Esc) 数式を終了 (Esc)
Preview プレビュー
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Draw Ink インクで描く
Erase Ink インクを消す
Hide Ink インクを隠す
Hide\nInk インクを\n隠す
Pens ペン
Thickness 太さ
# Columns, Symbol and Field dialogs (#321).
(All) (すべて)

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

View File

@ -1125,6 +1125,12 @@
Leave the equation (Esc) Sair da equação (Esc)
Preview Visualizar
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Draw Ink Desenhar tinta
Erase Ink Apagar tinta
Hide Ink Ocultar tinta
Hide\nInk Ocultar\ntinta
Pens Canetas
Thickness Espessura
# Columns, Symbol and Field dialogs (#321).
(All) (Todos)

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

View File

@ -1178,6 +1178,12 @@
Insert a new equation (Alt+=) Umetni novu jednačinu (Alt+=)
Leave the equation (Esc) Napusti jednačinu (Esc)
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Draw Ink Crtaj mastilom
Erase Ink Obriši mastilo
Hide Ink Sakrij mastilo
Hide\nInk Sakrij\nmastilo
Pens Pera
Thickness Debljina
# Columns, Symbol and Field dialogs (#321).
(All) (Sve)

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

View File

@ -1182,6 +1182,12 @@
Insert a new equation (Alt+=) Уметни нову једначину (Alt+=)
Leave the equation (Esc) Напусти једначину (Esc)
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Draw Ink Цртај мастилом
Erase Ink Обриши мастило
Hide Ink Сакриј мастило
Hide\nInk Сакриј\nмастило
Pens Пера
Thickness Дебљина
# Columns, Symbol and Field dialogs (#321).
(All) (Све)

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

View File

@ -1125,6 +1125,12 @@
Leave the equation (Esc) Вийти з рівняння (Esc)
Preview Попередній перегляд
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Draw Ink Малювати чорнилом
Erase Ink Стерти чорнило
Hide Ink Сховати чорнило
Hide\nInk Сховати\nчорнило
Pens Пера
Thickness Товщина
# Columns, Symbol and Field dialogs (#321).
(All) (Усі)

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

View File

@ -1124,6 +1124,12 @@
Leave the equation (Esc) 退出公式 (Esc)
Preview 预览
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Draw Ink 绘制墨迹
Erase Ink 擦除墨迹
Hide Ink 隐藏墨迹
Hide\nInk 隐藏\n墨迹
Pens 笔
Thickness 粗细
# Columns, Symbol and Field dialogs (#321).
(All) (全部)

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

View File

@ -1114,6 +1114,12 @@
Leave the equation (Esc) 離開方程式 (Esc)
Preview 預覽
getartcraft.com/apps/wordcraft getartcraft.com/apps/wordcraft
Draw Ink 繪製筆跡
Erase Ink 擦除筆跡
Hide Ink 隱藏筆跡
Hide\nInk 隱藏\n筆跡
Pens 畫筆
Thickness 粗細
# Columns, Symbol and Field dialogs (#321).
(All) (全部)

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

View File

@ -463,6 +463,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
pen.closed(&[(3.0, 13.0), (11.0, 5.0), (17.0, 11.0), (11.0, 17.0), (7.0, 17.0)], c);
pen.fill(&[(3.0, 13.0), (7.0, 9.0), (13.0, 15.0), (11.0, 17.0), (7.0, 17.0)], Color32::from_rgb(0xE8, 0x6F, 0xA0));
}
// Ink thickness: three strokes, thin to thick.
"thickness" => {
pen.frect(3.0, 4.5, 17.0, 5.5, c);
pen.frect(3.0, 9.0, 17.0, 11.0, c);
pen.frect(3.0, 14.0, 17.0, 17.0, a);
}
"lasso" => {
pen.closed(&[(4.0, 7.0), (9.0, 3.0), (16.0, 5.0), (16.0, 11.0), (10.0, 13.0), (4.0, 11.0)], c);
pen.line_c(&[(6.0, 12.5), (5.0, 17.5)], a);

111
crates/ui-egui/src/ink.rs Normal file
View File

@ -0,0 +1,111 @@
//! Drawing on the page with the Draw tab's pens and eraser. While a pen is in use, a drag draws a
//! live stroke and its release commits it with `draw.stroke` (one command, one undo step); with
//! the eraser, a click or drag over a stroke deletes it with `draw.erase` (one undo step per
//! drag). Select (or Esc) goes back to ordinary selection.
use egui::{Color32, Painter, Pos2, Rect, Stroke, Ui};
use serde_json::json;
use wordcraft_doc::freeform::{InkTool, MAX_POINTS};
use wordcraft_engine::cmd::draw::DrawMode;
use wordcraft_layout::DocLayout;
use crate::WordApp;
use crate::canvas::page_at;
/// A stroke being drawn: its page and points (page points).
pub struct InkDrag {
page: usize,
tool: InkTool,
pts: Vec<[f32; 2]>,
}
/// Smallest move that adds a point to a live stroke, screen points.
const MIN_STEP_PX: f32 = 1.5;
/// Handle the pointer while a pen or the eraser is in use; `true` when it did (no text
/// selection or object dragging then).
pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) -> bool {
let mode = app.session.view.draw.mode;
if mode == DrawMode::Select {
app.canvas.ink = None;
app.canvas.erasing = None;
return false;
}
let (pressed, down, released, at) =
ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.latest_pos()));
let starts = pressed && resp.contains_pointer();
match mode {
DrawMode::Pen(tool) => {
if starts && let Some((page, x, y)) = at.and_then(|p| page_at(rects, layout, scale, p)) {
app.canvas.ink = Some(InkDrag { page, tool, pts: vec![[x, y]] });
} else if down
&& let Some(p) = at
&& let Some(d) = app.canvas.ink.as_mut()
&& let Some((x, y)) = on_page(rects, layout, scale, d.page, p)
{
let far = d.pts.last().is_none_or(|[lx, ly]| ((x - lx).powi(2) + (y - ly).powi(2)).sqrt() * scale >= MIN_STEP_PX);
if far && d.pts.len() < MAX_POINTS {
d.pts.push([x, y]);
}
}
if released && let Some(d) = app.canvas.ink.take() {
let _ = app.run("draw.stroke", json!({"page": d.page, "points": d.pts, "tool": d.tool.name()}));
}
}
DrawMode::Eraser => {
if starts {
app.canvas.erasing = Some(false);
}
if (starts || down)
&& let Some(erased) = app.canvas.erasing
&& let Some((page, x, y)) = at.and_then(|p| page_at(rects, layout, scale, p))
&& wordcraft_engine::cmd::draw::ink_at(&mut app.session, page, x, y).is_some()
{
// The first stroke a drag erases starts an undo step; the rest join it.
if erased {
app.session.join_next_undo();
}
if app.run("draw.erase", json!({"page": page, "x": x, "y": y})).is_ok() {
app.canvas.erasing = Some(true);
}
}
if released {
app.canvas.erasing = None;
}
}
DrawMode::Select => {}
}
true
}
/// Page point under screen point `p` on `page` (outside the page too, clamped by the command).
fn on_page(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize, p: Pos2) -> Option<(f32, f32)> {
let r = rects.get(page)?;
let s = layout.pages.get(page).map_or(scale, |pg| crate::canvas::page_screen_scale(*r, pg, scale));
(s > 0.0).then(|| ((p.x - r.min.x) / s, (p.y - r.min.y) / s))
}
/// The live stroke, drawn over the page until it is committed.
pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) {
let Some(d) = &app.canvas.ink else { return };
let Some(r) = rects.get(d.page) else { return };
let s = layout.pages.get(d.page).map_or(scale, |pg| crate::canvas::page_screen_scale(*r, pg, scale));
let set = app.session.view.draw.settings(d.tool);
let alpha = (d.tool.alpha() * 255.0).round() as u8;
let color = Color32::from_rgba_unmultiplied(set.color.0, set.color.1, set.color.2, alpha);
let pts: Vec<Pos2> = d.pts.iter().map(|[x, y]| egui::pos2(r.min.x + x * s, r.min.y + y * s)).collect();
let width = (set.width * s).max(1.0);
if let [only] = pts.as_slice() {
painter.circle_filled(*only, width / 2.0, color);
} else {
painter.add(egui::Shape::line(pts, Stroke::new(width, color)));
}
}
/// The pointer over the page while a pen or the eraser is in use.
pub fn cursor(app: &WordApp) -> Option<egui::CursorIcon> {
match app.session.view.draw.mode {
DrawMode::Select => None,
DrawMode::Eraser | DrawMode::Pen(_) => Some(egui::CursorIcon::Crosshair),
}
}

View File

@ -234,6 +234,12 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
app.session.painter = None;
continue;
}
// Escape puts the pen or eraser down (back to selecting).
if key == Key::Escape && app.session.view.draw.mode != wordcraft_engine::cmd::draw::DrawMode::Select {
app.canvas.ink = None;
let _ = app.run("draw.select", json!({}));
continue;
}
if key == Key::Escape && crate::table_pen::stop(app) {
continue;
}

View File

@ -305,6 +305,7 @@ fn controls(tab: &str) -> Vec<Control> {
c("Compare", "review.compare"),
c("Block Authors", "review.blockAuthors"),
c("Restrict Editing", "review.restrict"),
c("Hide Ink", "review.hideInk"),
],
"View" => vec![
c("Read Mode", "view.readMode"),

View File

@ -27,6 +27,7 @@ pub mod file_dialogs;
pub mod frame;
pub mod i18n;
pub mod icons;
pub mod ink;
pub mod keys;
pub mod keytips;
pub mod mini_toolbar;

View File

@ -276,6 +276,7 @@ mod tests {
stroke_width: 1.0,
float: Default::default(),
story: None,
freeform: None,
effects: Default::default(),
};
let (app, hit) = app_with(shape);

View File

@ -4,7 +4,7 @@ use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use crate::theme::{Tokens, medium, regular, semibold};
use crate::widgets::{CONTENT_H, LABEL_H, big, color_grid, combo, group, menu_button, small, split};
use crate::widgets::{CONTENT_H, LABEL_H, big, big_toggle, color_grid, combo, group, menu_button, small, split};
use crate::{WordApp, icons};
pub const TABS: [&str; 12] = ["File", "Home", "Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Zotero", "Help"];
@ -645,13 +645,41 @@ fn insert(app: &mut WordApp, ui: &mut Ui) {
}
fn draw(app: &mut WordApp, ui: &mut Ui) {
use wordcraft_doc::freeform::InkTool;
use wordcraft_engine::cmd::draw::DrawMode;
let mode = app.session.view.draw.mode;
// The pen the colour and thickness menus change: the one in use, else the pen.
let tool = mode.pen().unwrap_or(InkTool::Pen);
let pen_id = |t: InkTool| match t {
InkTool::Pen => "draw.pen",
InkTool::Pencil => "draw.pencil",
InkTool::Highlighter => "draw.highlighter",
};
group(ui, "Drawing Tools", None, app, |ui, app| {
big(ui, app, "select", "Select", "draw.select", json!({}), false);
big_toggle(ui, app, "select", "Select", "draw.select", mode == DrawMode::Select);
big(ui, app, "lasso", "Lasso", "draw.lasso", json!({}), false);
big(ui, app, "eraser", "Eraser", "draw.eraser", json!({}), false);
big(ui, app, "pen", "Pen", "draw.pen", json!({}), false);
big(ui, app, "pencil", "Pencil", "draw.pencil", json!({}), false);
big(ui, app, "highlight", "Highlighter", "draw.highlighter", json!({}), false);
big_toggle(ui, app, "eraser", "Eraser", "draw.eraser", mode == DrawMode::Eraser);
big_toggle(ui, app, "pen", "Pen", "draw.pen", mode == DrawMode::Pen(InkTool::Pen));
big_toggle(ui, app, "pencil", "Pencil", "draw.pencil", mode == DrawMode::Pen(InkTool::Pencil));
big_toggle(ui, app, "highlight", "Highlighter", "draw.highlighter", mode == DrawMode::Pen(InkTool::Highlighter));
let set = app.session.view.draw.settings(tool);
stack(ui, |ui| {
let sw = Some(crate::theme::c32(set.color));
split(ui, app, "fontcolor", "Color", pen_id(tool), json!({}), false, sw, |ui, app| {
let theme = app.session.doc.settings.theme_colors.clone();
if let Some(hex) = color_grid(ui, &theme) {
let _ = app.run(pen_id(tool), json!({"color": hex}));
ui.close();
}
});
menu_button(ui, app, "thickness", None, "Thickness", false, |ui, app| {
let widths: &[f32] = if tool == InkTool::Highlighter { &[4.0, 8.0, 12.0, 18.0, 24.0] } else { &[0.5, 1.0, 1.5, 2.5, 3.5, 5.0] };
for w in widths {
let label = crate::i18n::fmt(tl!("{n} pt"), &[("n", &w.to_string())]);
mi_check(ui, app, &label, (set.width - w).abs() < 0.01, pen_id(tool), json!({"width": w}));
}
});
});
});
group(ui, "Convert", None, app, |ui, app| {
big(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", json!({}), false);
@ -1087,6 +1115,10 @@ fn review(app: &mut WordApp, ui: &mut Ui) {
big(ui, app, "blockAuthors", "Block\nAuthors", "review.blockAuthors", json!({}), false);
big(ui, app, "restrict", "Restrict\nEditing", "review.restrict", json!({}), false);
});
group(ui, "Ink", None, app, |ui, app| {
let hidden = app.session.view.hide_ink;
big_toggle(ui, app, "hideInk", "Hide\nInk", "review.hideInk", hidden);
});
}
fn view(app: &mut WordApp, ui: &mut Ui) {

View File

@ -58,10 +58,11 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written, laid out as square |
| Group shapes (`wpg:`), drawing canvas | 🟡 | 🟡 | Group shapes read and written (#267); drawing canvas (`wpc:`) dropped |
| DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds |
| Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight |
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures and text boxes, best effort (#242) |
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292); not editable. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319) |
| OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size set in WordCraft (#319). Can't be opened or edited |
| Ink, 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
| Word ink (`w14:contentPart`, InkML), 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
| Themes, font table, settings, compatibility mode | ✅ | ✅ | Embedded fonts (`w:embedRegular`) not read |
| Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom properties round-trip; bibliography sources pending (#169) |
@ -87,3 +88,4 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| 2026-10-10 | trivial | Equation breaks and `m:mathPr` wrapping options round-trip (#326) |
| 2026-10-10 | trivial | Custom table styles round-trip (#256 merged) |
| 2026-10-10 | major | First version: Word's full format list, read/write status, DOCX element coverage from the source, fidelity and hours |
| 2026-10-10 | trivial | Freeform shapes and ink as DrawingML custom geometry, read and written (#307) |

View File

@ -24,7 +24,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
| 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) |
| 10 | **Objects**: no group (#267 open), rotated text-box text (free rotation and flips landed, #332), contour (tight/through) wrap, shape effects, WordArt, connectors, Drawing Canvas, Icons | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
| 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) |
| 12 | **Draw tab / ink**: all 11 commands missing | | [parity-checklist.md](parity-checklist.md) (Draw 0/11) | Pen and tablet users | 15–25 | [hardware-parity.md](hardware-parity.md) |
| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); missing: Lasso Select, Add Pen, Ink to Shape, Ink to Math, Ink Replay, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 5/11) | Pen and tablet users | 8–14 | [hardware-parity.md](hardware-parity.md) |
| 13 | **Charts and SmartArt editing** (own renderer, chart data editing, SmartArt layouts) | | `insert.chart`, `insert.smartArt` missing | Reports and business documents | 45–70 | [target-app-parity.md](target-app-parity.md) |
| 14 | **`.doc` can't be written; metafile pictures and Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) |
| 15 | **References depth**: 4 of Word's 12 bibliography styles; sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Word's `Resources/Style` lists 12 styles | Students and researchers | 10–15 | [target-app-parity.md](target-app-parity.md) |
@ -80,3 +80,4 @@ The same gaps grouped the way the parity documents are, for agents working in on
| 2026-10-10 | trivial | Merged main: landed features removed from the feature-gap list |
| 2026-10-10 | minor | Beta note restated: full number back to the additive ~60% (method aligned with the standard, no new evidence) |
| 2026-10-10 | major | First version, from the full re-measure: 29 ranked gaps with evidence, impact and hours |
| 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink landed; ink gap narrowed |

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.
**396 of 434 catalog features have commands (91.2%).**
**402 of 434 catalog features have commands (92.6%).**
| Tab | Live | Total |
|---|---|---|
@ -10,12 +10,12 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
| Quick Access | 4 | 4 |
| Home | 99 | 102 |
| Insert | 43 | 53 |
| Draw | 0 | 11 |
| Draw | 5 | 11 |
| Design | 10 | 10 |
| Layout | 19 | 19 |
| References | 24 | 24 |
| Mailings | 17 | 17 |
| Review | 27 | 30 |
| Review | 28 | 30 |
| View | 25 | 29 |
| Table Design | 8 | 8 |
| Table Layout | 30 | 30 |
@ -49,12 +49,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
### Draw
- Tools › Select (`draw.select`)
- Tools › Lasso Select (`draw.lasso`)
- Tools › Eraser (`draw.eraser`)
- Pens › Pen (`draw.pen`)
- Pens › Pencil (`draw.pencil`)
- Pens › Highlighter (`draw.highlighter`)
- Pens › Add Pen (`draw.addPen`)
- Convert › Ink to Shape (`draw.inkToShape`)
- Convert › Ink to Math (`draw.inkToMath`)
@ -65,7 +60,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
- Language › Translate (`review.translate`)
- Protect › Block Authors (`review.blockAuthors`)
- Ink › Hide Ink (`review.hideInk`)
### View

View File

@ -55,7 +55,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay
| M8 | View | mostly done; window commands missing | 3–5 h |
| M9 | Mailings | done (first version); Excel/Outlook data sources and email merge missing | 6–10 h |
| M10 | File/Backstage | mostly done; native printing, Options depth missing | 12–20 h |
| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332) done; ink, grouping, contour wrap missing | 35–55 h |
| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332), ink pens and eraser (#307) done; lasso and ink conversion, grouping, contour wrap missing | 35–55 h |
| M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT, LaTeX, `.doc` import) | done (first versions); `.doc` write, encryption, minor formats missing | 60–100 h |
| M13 | Performance budgets | on track (1.4 ms relayout); large real-world documents unmeasured | 10–20 h |
| M14 | 1.0 polish, packaging, signing | releases v0.1.0–v0.4.0 published (signed macOS, Windows MSI x64/x86/arm64, Linux, FreeBSD, web) | — |
@ -76,3 +76,4 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i
| 2026-10-10 | minor | Current focus heading back to ~150–230 h to beta: full number restored to the additive weighted sum (~60%), method aligned with the standard, no new evidence |
| 2026-10-10 | minor | Alpha gate table added (core-workflow gate from the progress-docs standard): all six workflows pass, stage stays alpha |
| 2026-10-10 | major | Created: milestones moved from ROADMAP.md with remaining hours; Current focus from gaps.md; M15–M16 added |
| 2026-10-10 | trivial | M11: ink pens and eraser landed (#307) |

View File

@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **396 / 434 (91.2%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Ribbon/menu catalog coverage | **402 / 434 (92.6%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated |
| Feature depth (weighted by use, table below) | **~68%** | estimated |
| **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) |
@ -70,7 +70,7 @@ full parity for the area.
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules, preview, envelopes, labels. Select Recipients takes a typed list or CSV (#247); no Excel/Outlook/Contacts data sources, no email merge. |
| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, Drawing Canvas, Icons, online pictures, screenshot. |
| Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. |
| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Nothing yet: all 11 Draw tab commands missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). |
| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; lasso, ink to shape/math and replay missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). |
| View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane. Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Outline view depth, Immersive Reader depth. |
| Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. |
| Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. |
@ -249,6 +249,7 @@ can be used without committing Word output.
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink; catalog 384/430 → 390/430 (90.7%) |
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 384/430 → 387/430 (90.0%) |
| 2026-10-10 | trivial | Pictures, shapes, text boxes: free rotation and flips, rotation handle, rotated wrap bounds, DOCX `a:xfrm` rot/flipH/flipV (#332); no percentage change |
| 2026-10-10 | minor | Merged main (12 PRs): Paste Special, Advanced Find, Column Selection, Style Inspector, custom table styles, gridlines, zoom buttons, touchpad scrolling, File Info fields, Serbian. Catalog 378/428 → 384/430 (89.3%); typing 82→85%, styles 72→74%, tables 74→76%; feature depth 67→68%; mainstream 54→55%; full stays ~60%, essentials ~63% |

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). Missing: Draw tab content | Same plus Draw | 85% | (Draw in features) |
| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation; ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); missing: Lasso, Add Pen, Ink to Shape/Math, Ink Replay | Same plus Draw | 85% | (Draw in features) |
| Keytips (Alt / ⌃⌥ letters) | ✅ (#43) | ✅ | 85% | 1–2 |
| Mini-toolbar on selection | ✅ (#43) | ✅ | 80% | 1–2 |
| Quick Access Toolbar | Undo, Redo, Repeat, AutoSave | customizable | 50% | 2–3 |
@ -47,3 +47,4 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) |
| 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed |
| 2026-10-10 | major | First version: ribbon, dialog, shortcut and interaction inventory |
| 2026-10-10 | trivial | Draw tab tools and Review › Hide Ink (#307) |